Introduction

Objectif et public cible

Tout d’abord, quelques termes importants:

Software-Defined Radio (SDR):

ou Radio Logicielle en bon français. C’est une radio qui utilise un logiciel pour effectuer des tâches de traitement du signal qui étaient traditionnellement effectuées par des composants éléctroniques.

Digital Signal Processing (DSP):

ou traitements numérique du signal (RF dans notre cas) en bon français.

Ce manuel est une introduction pratique aux domaines du DSP, de la radio logicielle et des communications sans fil. Il est conçu pour quelqu’un qui:

Est intéressé à utiliser les SDR pour faire des trucs cool

a une bonne connaissance de Python

est relativement nouveau dans le domaine du DSP, des communications sans fil et de la radio logicielle.

est un apprenant plutôt visuel, préférant les animations aux équations.

comprends mieux les équations après avoir appris les concepts de base.

cherche des explications concises, et non un manuel de 1000 pages.

Il s’adresse par exemple à un étudiant en informatique intéressé par un emploi dans le domaine des communications sans fil après l’obtention de son diplôme. Ou toute autre personne désireuse d’en savoir plus sur la DSP et ayant une expérience en programmation. En tant que tel, il couvre la théorie nécessaire pour comprendre les techniques DSP sans les mathématiques intenses qui sont généralement incluses dans les cours universitaires. Au lieu de vous enterrer sous de nombreuses équations, une abondance d’images et d’animations sont utilisées pour aider à transmettre les concepts, comme l’animation du plan complexe de la série de Fourier ci-dessous. Je pense que les équations sont mieux comprises après l’apprentissage des concepts par des images et des exercices pratiques. L’utilisation intensive d’animations est la raison pour laquelle PySDR n’aura jamais de version papier vendue sur Amazon.

System Message: WARNING/2 (/home/vs/SPHINX/source/intro.rst, line 30)

Cannot scale image! Could not get size from "../_images/fft_logo_wide.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fft_logo_wide.gif'

Ce manuel a pour but d’introduire les concepts rapidement et en douceur, permettant au lecteur de réaliser des DSP et d’utiliser les SDRs intelligemment. Il n’a pas pour but d’être un manuel de référence pour tous les sujets DSP/SDR ; il existe déjà beaucoup d’excellents manuels en anglais, tels que Analog Device’s SDR textbook”. [https://www.analog.com/en/education/education-library/software-defined-radio-for-engineers.html] et dspguide.com [http://www.dspguide.com/]. Vous pouvez toujours utiliser Google pour rappeler les identités trigonométriques ou la limite de Shannon. Considérez ce manuel comme une porte d’entrée dans le monde de la DSP et de la SDR: c’est un engagement plus léger et moins coûteux en temps et en argent, comparé aux cours et manuels plus traditionnels.

Pour couvrir la théorie fondamentale du DSP, un semestre entier de « Signaux et systèmes », cours typique d’ingénierie, est condensé en quelques chapitres. Une fois les fondements du DSP couverts, nous nous lançons dans les SDR, bien que les concepts de DSP et de communications sans fil continuent d’être abordés tout au long du manuel.

Les exemples de code sont fournis en Python. Ils utilisent NumPy, qui est la bibliothèque standard de Python pour les tableaux et les mathématiques de haut niveau. Les exemples s’appuient également sur Matplotlib, qui est une bibliothèque de traçage Python permettant de visualiser facilement des signaux, des tableaux et des nombres complexes. Notez que si Python est plus « lent » que C++ en général, la plupart des fonctions mathématiques de Python/NumPy sont implémentées en C/C++ et sont fortement optimisées. De même, l’API SDR que nous utilisons est simplement un ensemble de liaisons Python pour des fonctions/classes C/C++. Ceux qui ont peu d’expérience de Python mais de solides bases en MATLAB, Ruby ou Perl se débrouilleront sans problème après s’être familiarisés avec la syntaxe de Python.

Contribution

Si vous parvenez à lire une partie de ce manuel et que vous m’envoyez un courriel à l’adresse marc@pysdr.org avec des questions/commentaires/suggestions, alors félicitations, vous aurez contribué à ce manuel!

Mais à une plus grande échelle, vous pouvez contribuer à ce manuel de la même manière que n’importe quel projet de logiciel open source - à travers Git. Ce manuel prend la forme d’un site Web, mais le matériel source à partir duquel il est généré vit sur la page GitHub du manuel https://github.com/777arc/PySDR. N’hésitez pas à soumettre un problème ou même une demande de transfert (Pull Request, PR) avec des corrections ou des améliorations. Ceux qui soumettent des commentaires/réparations de valeur seront ajoutés de façon permanente à la section des remerciements ci-dessous. Vous n’êtes pas doué pour Git mais vous avez des changements à suggérer? N’hésitez pas à m’envoyer un courriel à marc@pysdr.org.

Le site Web sur lequel ce manuel est hébergé est exempt de publicité, car nous détestons tous les publicités. Je ne fournis pas non plus d’adresse PayPal ou Bitcoin où j’accepte les dons. Il n’y a littéralement aucun moyen pour moi d’être payé pour ce manuel. Au lieu de cela, je suggère simplement de partager ce manuel avec des collègues, des étudiants et d’autres apprenants tout au long de la vie qui pourraient être intéressés par ce matériel.

Remerciements

Nous remercions tous ceux qui ont lu une partie de ce manuel et nous ont fait part de leurs commentaires, et tout particulièrement:

Barry Duggan [http://github.com/duggabe]

Matthew Hannon

James Hayek

Deidre Stuffer

Tarik Benaddi [https://tarikbenaddi.github.io] pour la traduction de PySDR en français [https://pysdr.org/fr/index-fr.html]

Beamforming & direction d’arrivée

Ce chapitre aborde les concepts de formation de faisceaux ou beamforming, de direction d’arrivée (DOA = Direction Of Arrival) et d’antennes à commande de phase. Il compare les différents types et géométries d’antennes et explique l’importance de l’espacement des éléments. Des techniques telles que MVDR/Capon et MUSIC sont présentées et illustrées par des simulations en Python.

Présentation du Beamforming

Un réseau d’antennes à commande de phase, également appelé réseau à balayage électronique, est un ensemble d’antennes utilisables en émission ou en réception dans les systèmes de communication et radar. On trouve des réseaux d’antennes à commande de phase sur des systèmes terrestres, aéroportés et satellitaires. Les antennes qui composent un réseau sont généralement appelées « éléments », et le réseau lui-même est parfois désigné comme un « capteur ». Ces éléments sont le plus souvent des antennes omnidirectionnelles, équidistantes horizontalement ou verticalement.

Le beamforming aussi appelé formation de faisceau est une opération de traitement du signal utilisée avec les réseaux d’antennes pour créer un filtre spatial ; il élimine les signaux provenant de toutes les directions sauf celle(s) souhaitée(s). Le beamforming permet d’améliorer le rapport signal/bruit des signaux utiles, d’annuler les interférences, de modeler les diagrammes de rayonnement, voire de transmettre/recevoir simultanément plusieurs flux de données à la même fréquence. Le beamforming utilise des pondérations (ou coefficients) appliquées à chaque élément du réseau, soit numériquement, soit par un circuit analogique. Nous ajustons les pondérations pour former le ou les faisceaux de l’antenne, d’où le nom de formation de faisceaux ! Nous pouvons orienter ces faisceaux (et les zones d’annulation) extrêmement rapidement, bien plus rapidement que les antennes à cardan mécanique, qui peuvent être considérées comme une alternative aux antennes à commande de phase. Nous aborderons généralement la formation de faisceaux dans le contexte d’une liaison de communication, où le récepteur vise à capter un ou plusieurs signaux avec le meilleur rapport signal/bruit possible. Les antennes jouent également un rôle crucial en radar, où l’objectif est de détecter et de suivre des cibles.

Les techniques beamforming se répartissent en trois catégories : /conventionnelle/, /adaptative/ et /aveugle/. La formation de faisceaux conventionnelle est particulièrement utile lorsque la direction d’arrivée du signal d’intérêt est connue. Le processus consiste alors à pondérer les signaux afin de maximiser le gain de l’antenne dans cette direction. Cette méthode peut être utilisée aussi bien en réception qu’en émission. La formation de faisceaux adaptative, quant à elle, ajuste généralement les pondérations en fonction des données reçues, afin d’optimiser certains critères (par exemple, éliminer un signal interférent, obtenir plusieurs faisceaux principaux, etc.). Du fait de son fonctionnement en boucle fermée et de sa nature adaptative, la formation de faisceaux adaptative est généralement utilisée uniquement en réception. Dans ce cas, les données reçues constituent les « entrées du formateur de faisceaux », et la formation de faisceaux adaptative ajuste les pondérations en fonction des statistiques de ces données.

La taxonomie suivante vise à catégoriser les différents domaines de la formation de faisceaux et propose des exemples de techniques_:

La direction d’arrivée

en traitement numérique du signal (DSP) et en radio logicielle (SDR) désigne le processus utilisant un réseau d’antennes pour détecter et estimer la direction d’arrivée d’un ou plusieurs signaux reçus par ce réseau (contrairement à la formation de faisceaux, qui vise à recevoir un signal en minimisant le bruit et les interférences). Bien que la DOA relève du domaine de la formation de faisceaux, la distinction entre les deux termes peut être source de confusion. Certaines techniques, comme la formation de faisceaux conventionnelle et MVDR, peuvent s’appliquer à la fois à la DOA et à la formation de faisceaux, car la même méthode est utilisée pour la DOA : balayer l’angle d’intérêt et effectuer l’opération de formation de faisceaux à chaque angle, puis rechercher les pics dans le résultat (chaque pic représente un signal, mais on ignore s’il s’agit du signal recherché, d’une interférence ou d’un signal réfléchi par trajets multiples). On peut considérer ces techniques de DOA comme une surcouche à un formateur de faisceaux spécifique. D’autres techniques de formation de faisceaux ne peuvent pas être simplement intégrées à une routine DOA, notamment en raison d’entrées supplémentaires non disponibles dans le contexte du DOA. Il existe également des techniques DOA telles que MUSIC et ESPIRT, spécifiquement dédiées au DOA et qui ne sont pas des techniques de formation de faisceaux. La plupart des techniques de formation de faisceaux supposant la connaissance de l’angle d’arrivée du signal d’intérêt, si la cible ou le réseau d’antennes se déplace, il sera nécessaire d’effectuer un DOA en continu comme étape intermédiaire, même si l’objectif principal est la réception et la démodulation du signal.

Les réseaux d’antennes à commande de phase et la formation de faisceaux/DOA trouvent des applications dans de nombreux domaines, notamment les radars, les nouvelles normes Wi-Fi, les communications millimétriques 5G, les communications par satellite et le brouillage. De manière générale, toute application nécessitant une antenne à gain élevé, ou une antenne à gain élevé à déplacement rapide, est une bonne candidate pour les réseaux d’antennes à commande de phase. Types de réseaux d’antennes

Différents types de réseaux

Les réseaux d’antennes à commande de phase se divisent en trois catégories : 1. Analogiques, également appelés réseaux passifs à balayage électronique (PESA) ou réseaux à commande de phase traditionnels, utilisent des déphaseurs analogiques pour orienter le faisceau. À la réception, tous les éléments sont additionnés après déphasage (et éventuellement gain ajustable) et convertis en un canal de signal, puis abaissés en fréquence avant d’être reçus. À l’émission, le processus est inverse : un signal numérique unique est émis par la partie numérique, tandis que des déphaseurs et des étages de gain sont utilisés côté analogique pour produire le signal destiné à chaque antenne. Ces déphaseurs numériques ont une résolution limitée en bits et contrôlent la latence. 2. Numériques, également appelés réseaux actifs à balayage électronique (AESA), où chaque élément possède son propre étage d’entrée RF et où la formation du faisceau est entièrement réalisée numériquement. Cette approche est la plus coûteuse, car les composants RF sont onéreux, mais elle offre une flexibilité et une vitesse bien supérieures aux PESA. Les antennes numériques sont couramment utilisées avec les SDR, bien que le nombre de canaux de réception ou d’émission du SDR limite le nombre d’éléments de l’antenne. 3. Hybrides, composés de nombreux sous-réseaux qui, individuellement, ressemblent à des antennes analogiques, chaque sous-réseau possédant son propre étage d’entrée RF, comme pour les antennes numériques. Ils constituent l’approche la plus courante pour les antennes à commande de phase modernes. Elles offrent en effet le meilleur des deux mondes.Il convient de noter que les termes PESA et AESA sont principalement utilisés dans le contexte des radars, et leur définition exacte reste parfois ambiguë. Par conséquent, l’utilisation des termes « antenne analogique/numérique/hybride » est plus claire et applicable à tout type d’application.

Un exemple concret pour chaque type est présenté ci-dessous :

En plus de ces trois types, il faut également considérer la géométrie d’un réseau. La géométrie la plus simple est le réseau linéaire uniforme (ULA = Uniform Linear Array), où les antennes sont alignées et équidistantes (c’est-à-dire disposées selon une seule dimension). Les ULA souffrent d’une ambiguïté de 180 degrés, que nous aborderons plus loin. Une solution consiste à disposer les antennes en cercle : on parle alors de réseau circulaire uniforme (UCA). Enfin, pour les faisceaux 2D, on utilise généralement un réseau rectangulaire uniforme (URA = Uniform Rectangular Array), où les antennes sont disposées en grille. Dans ce chapitre, nous nous concentrons sur les réseaux numériques, car ils sont plus adaptés à la simulation et au traitement numérique du signal (DSP), mais les concepts s’appliquent également aux réseaux analogiques et hybrides. Le chapitre suivant sera consacré à la manipulation du SDR « Phaser » d’Analog Devices, qui intègre un réseau analogique de 8 éléments fonctionnant à 10 GHz, avec des déphaseurs et des convertisseurs de gain, connecté à un Pluto et un Raspberry Pi. Nous nous concentrerons également sur la géométrie ULA car elle offre les mathématiques et le code les plus simples, mais tous les concepts s’appliquent à d’autres géométries, et à la fin du chapitre, nous aborderons la géométrie UCA.

Exigences relatives aux SDR

Les antennes réseau à commande de phase analogiques utilisent un déphaseur (et souvent un étage de gain ajustable) par canal/élément, implémenté dans des circuits RF analogiques. Cela signifie qu’une antenne réseau à commande de phase analogique est un composant matériel dédié qui doit être utilisé avec un SDR, ou conçu spécifiquement pour une application particulière. En revanche, tout SDR comportant plusieurs canaux peut être utilisé comme une antenne réseau numérique sans matériel supplémentaire, à condition que les canaux soient cohérents en phase et échantillonnés sur la même horloge, ce qui est généralement le cas pour les SDR disposant de plusieurs canaux de réception intégrés. De nombreuses SDR possèdent deux canaux de réception, comme l’Ettus USRP B210 et l’Analog Devices Pluto (le deuxième canal est accessible via un connecteur uFL sur la carte). Malheureusement, l’utilisation de plus de deux canaux implique de passer à la catégorie des SDR à plus de 10 000 € (prix constaté en 2024), comme l’Ettus USRP N310 ou l’Analog Devices QuadMXFE (16 canaux). Le principal défi réside dans l’impossibilité, pour les SDR économiques, de les chaîner afin d’augmenter le nombre de canaux. Font exception les KerberosSDR (4 canaux) et KrakenSDR (5 canaux), qui utilisent plusieurs SDR RTL partageant un oscillateur local pour former un réseau numérique économique. Leur principal inconvénient est la fréquence d’échantillonnage très limitée (jusqu’à 2,56 MHz) et la plage de fréquences très restreinte (jusqu’à 1766 MHz). La carte KrakenSDR et un exemple de configuration d’antenne sont présentés ci-dessous.

Dans ce chapitre, nous n’utilisons aucun SDR spécifique ; nous simulons plutôt la réception des signaux à l’aide de Python, puis nous passons en revue le DSP utilisé pour effectuer la formation de faisceaux/DOA pour les réseaux numériques.

Introduction aux calculs matriciels en Python/Numpy

Python présente de nombreux avantages par rapport à MATLAB : il est gratuit et open source, offre une grande diversité d’applications, une communauté dynamique, les indices commencent à 0 comme dans tous les langages, il est utilisé en IA/ML et il existe une bibliothèque pour presque tout. Cependant, son point faible réside dans la manière dont la manipulation des matrices est codée/représentée (en termes de performances, c’est très rapide, grâce à des fonctions implémentées efficacement en C/C++). Le fait qu’il existe plusieurs façons de représenter les matrices en Python, la méthode np.matrix étant obsolète et remplacée par np.ndarray, n’arrange rien. Dans cette section, nous proposons une brève introduction aux calculs matriciels en Python avec NumPy, afin que vous soyez plus à l’aise avec les exemples DOA.

Commençons par aborder l’aspect le plus complexe des calculs matriciels avec NumPy ; Les vecteurs sont traités comme des tableaux unidimensionnels (1D), il est donc impossible de distinguer un vecteur ligne d’un vecteur colonne (par défaut, il sera traité comme un vecteur ligne). En revanche, en MATLAB, un vecteur est un objet bidimensionnel (2D). En Python, vous pouvez créer un nouveau vecteur avec a = np.array([2,3,4,5]) ou convertir une liste en vecteur avec mylist   =    [2,   3,   4,   5] puis a = np.asarray(mylist). Cependant, dès que vous effectuez des calculs matriciels, l’orientation est importante et les vecteurs seront interprétés comme des vecteurs lignes. Transposer ce vecteur, par exemple avec a.T, ne le transformera pas en vecteur colonne ! Pour convertir un vecteur a en vecteur colonne, utilisez a = a.reshape(-1,1). Le paramètre -1 indique à NumPy de calculer automatiquement la taille de cette dimension, tout en conservant la longueur de la seconde dimension égale à 1. Techniquement, cela crée un tableau 2D, mais comme la seconde dimension est de longueur 1, il s’agit essentiellement d’un tableau 1D d’un point de vue mathématique. Cela ne représente qu’une ligne supplémentaire, mais peut considérablement perturber le flux de code lors de calculs matriciels.

Voici un exemple rapide de calcul matriciel en Python : multiplions une matrice 3x10 par une matrice 10x1. Rappelons que 10x1 signifie 10 lignes et 1 colonne, soit un vecteur colonne puisqu’il ne contient qu’une seule colonne. Depuis nos premières années d’école, nous savons que cette multiplication matricielle est valide car les dimensions internes correspondent et la matrice résultante a la même taille que les dimensions externes, soit 3x1. Par commodité, nous utiliserons np.random.randn() pour créer le tableau 3x10 et np.arange() pour créer le tableau 10x1 :

A = np.random.randn(3,10) # 3x10
B = np.arange(10) # Tableau 1D de longueur 10
B = B.reshape(-1,1) # 10x1
C = A @ B # Multiplication matricielle
print(C.shape) # 3x1
C = C.squeeze() # voir la sous-section suivante
print(C.shape) #  Tableau 1D  de longueur 3,  plus pratique  pour les
graphiques et autres opérations Python non-matricielles

Après avoir effectué des calculs matriciels, votre résultat pourrait ressembler à ceci : [[ 0.  0.125  0.251  -0.376  -0.251 ...]]. Ce tableau ne contient qu’une seule dimension de données, mais si vous tentez de le représenter graphiquement, vous obtiendrez soit une erreur, soit un graphique incohérent. Le résultat ne s’affiche pas. En effet, il s’agit techniquement d’un tableau 2D, qu’il faut convertir en tableau 1D à l’aide de a.squeeze(). Cette fonction supprime les dimensions de longueur 1 et s’avère très utile pour les calculs matriciels en Python. Dans l’exemple ci-dessus, le résultat serait : [ 0.  0.125  0.251  -0.376  -0.251 ...] (notez l’absence des deuxièmes parenthèses). Ce tableau peut être tracé ou utilisé dans d’autres portions de code Python qui attendent des données 1D.

Lors de la programmation de calculs matriciels, la meilleure vérification consiste à afficher les dimensions (avec A.shape) pour s’assurer qu’elles correspondent à vos attentes. Pensez à ajouter la forme du tableau en commentaire après chaque ligne pour vous y référer ultérieurement ; cela facilitera la vérification des dimensions lors de multiplications matricielles ou élément par élément.

Voici quelques opérations courantes en MATLAB et en Python, sous forme de pense-bête :

Vecteur de direction

Pour passer à la partie intéressante, il nous faut aborder quelques notions mathématiques. La section suivante est rédigée de manière à ce que les calculs restent relativement simples et soient accompagnés de schémas. Seules les propriétés trigonométriques et exponentielles les plus élémentaires sont utilisées. Il est important de comprendre les bases mathématiques qui sous-tendent les opérations que nous effectuerons en Python pour calculer la direction d’arrivée (DOA).

Considérons un réseau unidimensionnel à trois éléments uniformément espacés :

Dans cet exemple, un signal arrive par la droite et atteint donc d’abord l’élément le plus à droite. Calculons le délai entre le moment où le signal atteint ce premier élément et celui où il atteint le suivant. Pour ce faire, nous pouvons formuler le problème trigonométrique suivant. Essayez de visualiser comment ce triangle a été formé à partir du schéma ci-dessus. Le segment en rouge représente la distance que le signal doit parcourir après avoir atteint le premier élément avant d’atteindre le suivant.

Si vous vous souvenez de SOH CAH TOA, dans ce cas, nous nous intéressons au côté « adjacent » et nous connaissons la longueur de l’hypoténuse (\(d\)). Nous devons donc utiliser le cosinus :

\[\cos(90 - \theta) = \frac{\mathrm{adjacent}}{\mathrm{hypotenuse}}\]

Nous devons isoler le côté adjacent, car c’est ce qui nous indiquera la distance que le signal doit parcourir entre l’impact sur le premier et le deuxième élément. On obtient donc : \(= d \cos(90 - \theta)\). Une identité trigonométrique nous permet ensuite de convertir cette expression en : \(= d \sin(\theta)\). Il s’agit cependant d’une distance ; nous devons la convertir en temps, en utilisant la vitesse de la lumière : \(= d \sin(\theta) / c\) secondes. Cette équation s’applique entre tous les éléments adjacents de notre tableau. Cependant, pour calculer la distance entre des éléments non adjacents, puisqu’ils sont uniformément espacés, on peut multiplier l’ensemble par un entier (nous le verrons plus tard).

Appliquons maintenant ces notions de trigonométrie et de vitesse de la lumière au traitement du signal. Notons notre signal d’émission en bande de base : \(x(t)\) ; il est émis à une fréquence porteuse : \(f_c\) ; le signal d’émission est donc : \(x(t) e^{2j \pi f_c t}\). Nous utiliserons : \(d_m\) pour désigner l’espacement des antennes en mètres. Supposons que ce signal atteigne le premier élément à l’instant : t = 0 ; il atteindra donc l’élément suivant après : \(d_m \sin(\theta) / c\) secondes, comme calculé précédemment. Cela signifie que le deuxième élément reçoit :

\[x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)}\]

\[\mathrm{où} \quad \Delta t = d_m \sin(\theta) / c\]

Rappelons que lorsqu’il y a un décalage temporel, celui-ci est soustrait de l’argument temporel.

Lorsque le récepteur ou le SDR effectue la conversion de fréquence pour recevoir le signal, il le multiplie essentiellement par la porteuse, mais en sens inverse. Après la conversion, le récepteur voit donc :

\[x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} e^{-2j \pi f_c t}\]

\[= x(t - \Delta t) e^{-2j \pi f_c \Delta t}\]

On peut maintenant utiliser une petite astuce pour simplifier encore davantage cette expression ; Considérons comment, lors de l’échantillonnage d’un signal, on peut modéliser le processus en remplaçant \(t\) par \(nT\), où \(T\) est la période d’échantillonnage et \(n\) prend simplement les valeurs 0, 1, 2, 3… En substituant ces valeurs, on obtient : \(x(nT - Δt) e^{-2j π f_c Δt}\). Or, \(nT\) est tellement supérieur à Δt que l’on peut négliger le premier terme \(Δt\) et obtenir : \(x(nT) e^{-2j π f_c Δt}\). Si la fréquence d’échantillonnage devient un jour suffisamment rapide pour approcher la vitesse de la lumière sur une distance infime, on pourra réexaminer ce point. Mais n’oublions pas que notre fréquence d’échantillonnage doit seulement être légèrement supérieure à la bande passante du signal d’intérêt.

Continuons avec ces calculs, mais nous allons commencer à représenter les termes de manière discrète afin de mieux les rapprocher de notre code Python. La dernière équation peut être représentée comme suit ; remplaçons \(\Delta t\) :

\[x[n] e^{-2j \pi f_c \Delta t}\]

\[= x[n] e^{-2j \pi f_c d_m \sin(\theta) / c}\]

Nous avons presque terminé, mais heureusement, il nous reste une simplification à effectuer. Rappelons la relation entre la fréquence centrale et la longueur d’onde : \(\lambda = \frac{c}{f_c}\), ou inversement, \(f_c = \frac{c}{\lambda}\). En remplaçant ces valeurs, on obtient :

\[x[n] e^{-2j \pi d_m \sin(\theta) / \lambda}\]

En formation de faisceaux et en orientation de la direction d’arrivée (DOA), on préfère représenter la distance entre éléments adjacents, \(d\), comme une fraction de longueur d’onde (plutôt qu’en mètres). La valeur la plus courante de \(d\) lors de la conception d’un réseau d’antennes est la moitié de la longueur d’onde. Quelle que soit la valeur de \(d\), nous la représenterons désormais comme une fraction de longueur d’onde plutôt qu’en mètres, ce qui simplifie les équations et le code. Autrement dit, \(d\) (sans l’indice \(m\)) représente la distance normalisée et est égal à \(d = d_m / \lambda\). Cela signifie que nous pouvons simplifier l’équation ci-dessus comme suit :

\[x[n] e^{-2j \pi d \sin(\theta)}\]

Cette équation est spécifique aux éléments adjacents. Pour le signal reçu par le k-ième élément, il suffit de multiplier d par k :

\[x[n] e^{-2j \pi d k \sin(\theta)}\]

Considérons maintenant la convention de coordonnées que nous souhaitons utiliser. Dans cet ouvrage, 0 degré représentera la tangente à la matrice (c’est-à-dire la ligne sur laquelle se trouvent les éléments), comme illustré dans le schéma ci-dessus, et θ augmentera dans le sens horaire. L’élément de référence sera l’élément le plus à gauche, et chaque élément suivant sera situé à une distance d_m vers la droite. Ceci est l’inverse de notre diagramme précédent, nous devons donc inverser le sens du déphasage, c’est-à-dire supprimer le signe négatif :

\[x[n] e^{2j \pi d k \sin(\theta)}\]

Nous pouvons représenter cela sous forme matricielle en réarrangeant simplement l’équation ci-dessus pour tous les Nr éléments du tableau, de \(k = 0, 1, ... , N-1\) :

\[\begin{split}\begin{bmatrix} e^{2j \pi d (0) \sin(\theta)} \\ e^{2j \pi d (1) \sin(\theta)} \\ e^{2j \pi d (2) \sin(\theta)} \\ \vdots \\ e^{2j \pi d (N_r - 1) \sin(\theta)} \\ \end{bmatrix}\end{split}\]

où \(x\) est le vecteur ligne unidimensionnel contenant le signal émis, et le vecteur colonne est ce que l’on appelle le « vecteur de direction » (souvent noté \(s\) et s dans le code). Ce vecteur est représenté par un tableau, par exemple un tableau unidimensionnel pour un réseau d’antennes unidimensionnel. Comme \(e^{0} = 1\), le premier élément du vecteur de direction vaut toujours 1, et les suivants représentent les déphasages relatifs. Au premier élément :

\[\begin{split}s = \begin{bmatrix} 1 \\ e^{2j \pi d (1) \sin(\theta)} \\ e^{2j \pi d (2) \sin(\theta)} \\ \vdots \\ e^{2j \pi d (N_r - 1) \sin(\theta)} \\ \end{bmatrix}\end{split}\]

Et voilà ! Ce vecteur est celui que vous rencontrerez dans les articles sur l’optimisation par déplacement d’atomes (DOA) et les implémentations d’automates linéaires universels (ULA) ! Vous pouvez également le rencontrer avec \(2\pi\sin(\theta)\) exprimé sous la forme \(\psi\), auquel cas le vecteur directeur serait simplement \(e^{jd\psi}\), qui est la forme plus générale (nous n’utiliserons cependant pas cette forme). En Python, s s’écrit :

s = [np.exp(2j*np.pi*d*0*np.sin(theta)), np.exp(2j*np.pi*d*1*np.sin(theta)), np.exp(2j*np.pi*d*2*np.sin(theta)), ...] # notez l'augmentation de k
# ou
s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # où Nr est le nombre d'éléments de l'antenne de réception

Remarquez que l’élément 0 donne 1+0j (car \(e^{0}=1\)) ; cela est logique car tout ce qui précède était relatif à ce premier élément, qui reçoit donc le signal tel quel, sans déphasage relatif. C’est ainsi que fonctionnent les calculs ; en réalité, n’importe quel élément pourrait servir de référence, mais comme vous le verrez plus loin dans notre code, ce qui importe, c’est la différence de phase/amplitude reçue entre les éléments. Tout est relatif.

N’oubliez pas que notre \(d\) est exprimé en longueurs d’onde, et non en mètres !

Réception d’un signal

Utilisons le concept de vecteur de direction pour simuler un signal arrivant sur un réseau d’antennes. Pour le signal d’émission, nous utiliserons simplement une tonalité pour l’instant :

import numpy as np
import matplotlib.pyplot as plt

sample_rate = 1e6
N = 10000 # nombre d'échantillons à simuler

# Creation d'une tonalité qui servira de signal émetteur
t = np.arange(N)/sample_rate # vecteur temps
f_tone = 0.02e6
tx = np.exp(2j * np.pi * f_tone * t)

Simulons maintenant un réseau de trois antennes omnidirectionnelles alignées, séparées par une demi-longueur d’onde (ou « espacement d’une demi-longueur d’onde »). Nous simulerons le signal de l’émetteur arrivant sur ce réseau sous un angle donné, θ. La compréhension du vecteur de direction s (voir le code ci-dessous) justifie tous les calculs précédents.

d = 0.5 # espacement d'une demi-longueur d'onde
Nr = 3
theta_degrees = 20 # direction d'arrivée  (N'hésitez pas à modifier cela, c'est arbitraire.)
theta = theta_degrees / 180 * np.pi # convertion en radians
s = np.exp(2j * np.pi *  d * np.arange(Nr) * np.sin(theta)) # Vecteur
de direction
print(s) # Notez qu'il comporte 3 éléments, qu'il est complexe et que le premier élément est 1+0j1+0j

Pour appliquer le vecteur directeur, nous devons effectuer une multiplication matricielle de s et tx. Commençons donc par convertir les deux en 2D, en utilisant la méthode vue précédemment lors de notre révision des calculs matriciels en Python. Nous allons d’abord les transformer en vecteurs lignes à l’aide de ourarray.reshape(-1,1). Nous effectuons ensuite la multiplication matricielle, indiquée par le symbole @. Nous devons également convertir tx d’un vecteur ligne en un vecteur colonne en utilisant une transposition (imaginez une rotation de 90 degrés) afin que les dimensions internes de la multiplication matricielle correspondent.

s = s.reshape(-1,1) # modifie s en vecteur colonne
print(s.shape) # 3x1
tx = tx.reshape(1,-1) # modifie tx en vecteur ligne
print(tx.shape) # 1x10000
X = s @ tx # Simuler le signal reçu X par multiplication matricielle
print(X.shape) # 3x10000. X sera désormais un tableau 2D, 1D représentant le temps et 1D la dimension spatiale.

À ce stade, X est un tableau 2D de taille 3 x 10 000, car nous avons trois éléments et 10 000 échantillons simulés. Nous utilisons la majuscule X pour indiquer qu’il s’agit de la combinaison (empilement) de plusieurs signaux reçus. Nous pouvons extraire chaque signal individuellement et tracer les 200 premiers échantillons ; ci-dessous, nous ne représenterons que la partie réelle, mais il existe également une partie imaginaire, comme pour tout signal en bande de base. Un aspect fastidieux du calcul matriciel en Python est la nécessité d’utiliser la fonction .squeeze(), qui supprime toutes les dimensions de longueur 1, pour obtenir un tableau NumPy 1D standard, compatible avec les tracés et autres opérations.

plt.plot(np.asarray(X[0,:]).squeeze().real[0:200]) # l' asarray et le
squeeze ne sont  que des désagréments que nous devons  subit car l'on
provient d'une matrice
plt.plot(np.asarray(X[1,:]).squeeze().real[0:200])
plt.plot(np.asarray(X[2,:]).squeeze().real[0:200])
plt.show()

Observez les déphasages entre les éléments, comme prévu (sauf si le signal arrive dans l’axe de visée, auquel cas il atteindra tous les éléments simultanément et il n’y aura pas de déphasage ; fixez θ à 0 pour le constater). Essayez de modifier l’angle et observez le résultat.

Enfin, ajoutons du bruit à ce signal reçu, car tout signal que nous traiterons comporte un certain niveau de bruit. Nous souhaitons appliquer le bruit après l’application du vecteur de direction, car chaque élément subit un signal de bruit indépendant (cela est possible car un signal AWGN (Arbitrary White Gaussion Noise = Bruit blanc gaussien arbitraire) avec déphasage reste un signal AWGN).

Formation conventionnelle de faisceaux (conventionnal beamforming) et direction d’arrivée (DOA)

Nous allons maintenant traiter ces échantillons X, en supposant que nous ignorons l’angle d’arrivée, et effectuer le calcul de la direction d’arrivée (DOA). Cette opération consiste à estimer le ou les angles d’arrivée à l’aide d’un traitement numérique du signal (DSP) et d’un peu de code Python. Comme évoqué précédemment dans ce chapitre, la formation de faisceaux et le calcul du DOA sont très similaires et reposent souvent sur les mêmes techniques. Dans la suite de ce chapitre, nous étudierons différents formateurs de faisceaux. Pour chacun d’eux, nous commencerons par le code mathématique qui calcule les pondérations, \(w\). Ces pondérations peuvent être appliquées au signal entrant X grâce à la simple équation suivante : \(w^H X\), ou, en Python, à w.conj().T  @ X. Dans l’exemple ci-dessus, X est une matrice 3x10000, mais après application des pondérations, il ne reste qu’une matrice 1x10000, comme si notre récepteur ne possédait qu’une seule antenne. Nous pouvons alors utiliser un DSP RF classique pour traiter le signal. Une fois le formateur de faisceaux développé, nous l’appliquerons au problème du DOA.

Nous allons commencer par l’approche de formation de faisceau « classique », également appelée formation de faisceau par sommation et retard. Notre vecteur de pondération w doit être un tableau unidimensionnel pour un réseau linéaire uniforme ; dans notre exemple à trois éléments, w est un tableau 3x1 de pondérations complexes. Avec la formation de faisceau classique, nous laissons l’amplitude des pondérations à 1 et ajustons les phases afin que le signal s’additionne de manière constructive dans la direction du signal souhaité, que nous appellerons : \(\theta\). Il s’avère que c’est exactement le même calcul que celui effectué précédemment : nos pondérations constituent notre vecteur de direction !

\[w_{conv} = e^{2j \pi d k \sin(\theta)}\]

ou en Python:

w  =  np.exp(2j *  np.pi  *  d  *  np.arange(Nr) *  np.sin(theta))  # Formation de faisceaux conventionnelle ou à sommation et delai
X_weighted = w.conj().T @ X # Exemple d'application des pondérations au signal reçu (formation de faisceau)
print(X_weighted.shape) # 1x10000

où Nr est le nombre d’éléments de notre réseau linéaire uniforme avec un espacement de d fractions de longueur d’onde (généralement ~0,5). Comme vous pouvez le constater, les pondérations ne dépendent que de la géométrie du réseau et de l’angle d’intérêt. Si notre réseau nécessitait un étalonnage de phase, nous inclurions également les valeurs d’étalonnage correspondantes. L’équation de w vous a peut-être permis de remarquer que les pondérations sont complexes et que leurs magnitudes sont toutes égales à un.

Mais comment déterminer l’angle d’intérêt theta ? Il faut commencer par effectuer une analyse de la direction d’arrivée (DOA), qui consiste à balayer (échantillonner) toutes les directions d’arrivée de -π à +π (-180° à +180°), par exemple par incréments de 1°. Pour chaque direction, nous calculons les pondérations à l’aide d’un formateur de faisceau ; nous commencerons par utiliser le formateur de faisceau conventionnel. L’application des pondérations à notre signal X nous donne un tableau unidimensionnel d’échantillons, comme si nous l’avions reçu avec une antenne directionnelle. Nous pouvons ensuite calculer la puissance du signal en calculant sa variance avec np.var(), et répéter l’opération pour chaque angle de balayage. Nous visualiserons les résultats graphiquement, mais la plupart des logiciels de traitement numérique du signal RF déterminent l’angle de puissance maximale (grâce à un algorithme de détection de pics) et l’appellent l’estimation de la direction d’arrivée (DOA).

theta_scan  =  np.linspace(-1*np.pi,  np.pi,   1000)  #  1000  thetas
différents compris entre -180 et +180 degrés
results = []
for theta_i in theta_scan:
   w =  np.exp(2j * np.pi  * d  * np.arange(Nr) *  np.sin(theta_i)) #
   Conventionnel, c'est à dire délai et addition, beamformer
   X_weighted = w.conj().T @ X # application des poids. rappelez-vous X is 3x10000
   results.append(10*np.log10(np.var(X_weighted)))  #   puissance  du
   signal, en dB ainsi c'est  plus facile d'observer les lobes petits
   et grands en même temps
results -= np.max(results) # normalize (optional)

# affichage de l'angle qui nous donne la valeur maximale
print(theta_scan[np.argmax(results)] * 180 / np.pi) # 19.99999999999998

plt.plot(theta_scan*180/np.pi, results) # affichons l'angle en degrés
plt.xlabel("Theta [Degrees]")
plt.ylabel("DOA Metric")
plt.grid()
plt.show()

Nous avons trouvé notre signal ! Vous commencez sans doute à comprendre le principe du réseau à balayage électrique. Essayez d’augmenter le niveau de bruit pour pousser le système à ses limites ; il vous faudra peut-être simuler la réception d’un plus grand nombre d’échantillons pour les faibles rapports signal/bruit. Essayez également de modifier la direction d’arrivée.

Si vous préférez visualiser les résultats de la direction d’arrivée sur un diagramme polaire, utilisez le code suivant :

fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
ax.plot(theta_scan, results) # SOYEZ SURE D'UTILISEZTO USE RADIAN FOR POLAR
ax.set_theta_zero_location('N') # Orienter le point 0 degré vers le haut
ax.set_theta_direction(-1) # Augmenter theta dans le sens horaire
ax.set_rlabel_position(55) # Déplacement des  étiquettes de la grille
loin des autres étiquettes.
plt.show()

Nous observerons régulièrement ce phénomène de boucle angulaire, nécessitant une méthode de calcul des pondérations de formation de faisceau, puis leur application au signal reçu. Dans la méthode de formation de faisceau suivante (MVDR), nous utiliserons notre signal reçu X dans le calcul des pondérations, ce qui en fera une technique adaptative. Mais auparavant, nous examinerons certains phénomènes intéressants liés aux réseaux d’antennes à commande de phase, notamment l’origine de ce second pic à 160 degrés.

Examinons l’origine de ce second pic à 160 degrés ; la DOA simulée était de 20 degrés, mais le fait que 180 - 20 = 160 n’est pas fortuit. Imaginez trois antennes omnidirectionnelles alignées sur une table. L’axe de visée du réseau est perpendiculaire à celui-ci, comme indiqué sur le premier schéma de ce chapitre. Imaginons maintenant l’émetteur placé devant les antennes, également sur la (très grande) table, de sorte que son signal arrive avec un angle de +20 degrés par rapport à l’axe de visée. Le réseau subit le même effet, que le signal arrive par l’avant ou par l’arrière : le déphasage est identique, comme illustré ci-dessous avec les éléments du réseau en rouge et les deux directions d’arrivée possibles de l’émetteur en vert. Par conséquent, lors de l’exécution de l’algorithme de détermination de la direction d’arrivée (DOA), une ambiguïté de 180 degrés de ce type apparaîtra toujours. La seule solution consiste à utiliser un réseau 2D, ou un second réseau 1D positionné à un angle différent par rapport au premier. Vous vous demandez peut-être si cela signifie qu’il est plus simple de ne calculer que l’intervalle de -90 à +90 degrés afin d’économiser des cycles de calcul, et vous avez tout à fait raison !

Essayons de faire varier l’angle d’arrivée (AoA) de -90 à +90 degrés au lieu de le maintenir constant à 20 :

System Message: WARNING/2 (/home/vs/SPHINX/source/doa.rst, line 438)

Cannot scale image! Could not get size from "../_images/doa_sweeping_angle_animation.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/doa_sweeping_angle_animation.gif'

À l’approche de l’axe du réseau (lorsque le signal arrive sur ou près de cet axe), les performances diminuent. On observe deux dégradations principales : 1) le lobe principal s’élargit et 2) une ambiguïté apparaît, empêchant de déterminer si le signal provient de la gauche ou de la droite. Cette ambiguïté s’ajoute à l’ambiguïté à 180° évoquée précédemment, où un lobe supplémentaire apparaît à 180° - θ, ce qui peut entraîner, pour certains angles d’arrivée, la présence de trois lobes de taille sensiblement égale. Cette ambiguïté liée à l’axe du réseau est toutefois logique : les déphasages entre les éléments sont identiques, que le signal arrive de la gauche ou de la droite par rapport à l’axe du réseau. Tout comme pour l’ambiguïté à 180°, la solution consiste à utiliser un réseau 2D ou deux réseaux 1D positionnés à des angles différents. En général, la formation de faisceau est optimale lorsque l’angle est proche de l’axe de visée.

À partir de maintenant, nous n’afficherons que les degrés -90 à +90 dans nos graphiques polaires, car le motif sera toujours symétrique par rapport à l’axe du réseau, du moins pour les réseaux linéaires 1D (qui sont tous ceux que nous abordons dans ce chapitre).

Les graphiques présentés jusqu’à présent correspondent aux résultats de la direction d’arrivée (DOA) ; ils représentent la puissance reçue à chaque angle après application du formateur de faisceau. Ils étaient spécifiques à un scénario où les émetteurs arrivaient de certains angles. Mais nous pouvons également observer le diagramme de rayonnement lui-même, avant toute réception de signal. On parle alors de « diagramme de rayonnement au repos » ou de « réponse du réseau ».

Rappelons que notre vecteur de pointage, que nous voyons régulièrement,

np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta))

encapsule la géométrie du réseau linéaire uniforme (ULA), et son seul autre paramètre est la direction de pointage souhaitée. Nous pouvons calculer et tracer le diagramme de rayonnement au repos (réponse du réseau) lorsqu’il est pointé dans une direction donnée, ce qui nous indiquera la réponse naturelle du réseau si nous n’effectuons aucun formage de faisceau supplémentaire. Ceci peut être réalisé en effectuant la FFT des poids complexes conjugués ; aucune boucle n’est nécessaire ! La difficulté réside dans le remplissage pour augmenter la résolution et dans la conversion des intervalles de la sortie FFT en angles en radians ou en degrés, ce qui implique un arcsinus comme vous pouvez le voir dans l’exemple complet ci-dessous :

Nr = 3
d = 0.5
N_fft = 512
theta_degrees = 20 # il n'y a pas de SOI (Signal d'Intérêt), nous ne traitons pas d'échantillons, il s'agit simplement de la direction vers laquelle nous voulons pointer
theta = theta_degrees / 180 * np.pi
w = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # beamformer classique
w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero padding à N_fft élements pour obtenir une meilleure résolution dans la FFT
w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # amplitude de la FFT en dB
w_fft_dB -= np.max(w_fft_dB) # normalisation à 0 dB au niveau du pic

# Mapper les bins de la FFT aux angles en radians
theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # in radians

# trouver la valeur maximale afin de l'ajouter au graphique
theta_max = theta_bins[np.argmax(w_fft_dB)]

fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
ax.plot(theta_bins, w_fft_dB) # ASSUREZ-VOUS D'UTILISER LE RADIAN POUR LES POINTS POLAIRES
ax.plot([theta_max], [np.max(w_fft_dB)],'ro')
ax.text(theta_max - 0.1, np.max(w_fft_dB) - 4, np.round(theta_max * 180 / np.pi))
ax.set_theta_zero_location('N') # Orienter le point 0 degré vers le haut
ax.set_theta_direction(-1) # Augmenter dans le sens horaire
ax.set_rlabel_position(55)  # Éloignez les étiquettes de la grille des autres étiquettes.
ax.set_thetamin(-90) # Afficher uniquement la moitié supérieure
ax.set_thetamax(90)
ax.set_ylim([-30, 1]) # Comme il n'y a pas de bruit, on ne baisse que de 30 dB
plt.show()

Notez que tous les poids ont une magnitude unitaire (ils restent sur le cercle unité) et que les éléments de numéro le plus élevé « tournent » plus vite. En observant attentivement, vous remarquerez qu’à 0 degré, ils sont tous alignés ; leur déphasage est alors nul (1+0j).

Pour les plus curieux, il existe des équations permettant d’approximer la largeur du faisceau du lobe principal en fonction du nombre d’éléments, bien qu’elles ne soient précises que pour un grand nombre d’éléments (par exemple, 8 ou plus). La largeur du faisceau à mi-puissance (HPBW) est définie comme la largeur à 3 dB en dessous du pic du lobe principal et est approximativement égale à \(\frac{0,9 \lambda}{N_rd\cos(\theta)}\) [1], ce qui, pour un espacement d’une demi-longueur d’onde, se simplifie à :

\[\text{HPBW} \approx \frac{1.8}{N_r\cos(\theta)} \text{ [radians]} \qquad \text{lorsque } d = \lambda/2\]

La première largeur de faisceau nul (FNBW), la largeur du lobe principal d’un point nul à un autre, est approximativement \(\frac{2\lambda}{N_rd}\) [1], ce qui, pour un espacement d’une demi-longueur d’onde, se simplifie en :

\[\text{FNBW} \approx \frac{4}{N_r} \text{ [radians]} \qquad \text{lorsque } d = \lambda/2\]

Utilisons le code précédent, mais augmentons Nr à 16 éléments. D’après les équations ci-dessus, la largeur de faisceau à mi-puissance (HPBW) pour un angle de 20 degrés (0,35 radian) devrait être d’environ 0,12 radian, soit 6,8 degrés. La largeur de faisceau au point mort haut (FNBW) devrait être d’environ 0,25 radian, soit 14,3 degrés. Effectuons une simulation pour vérifier la précision des résultats. Pour visualiser les largeurs de faisceau, nous utilisons généralement des graphiques rectangulaires plutôt que polaires. Les résultats sont présentés ci-dessous, la HPBW est indiquée en vert et la FNBW en rouge :

Il est peut-être difficile de le voir sur le graphique, mais en zoomant fortement, on constate que la largeur de bande à mi-puissance (HPBW) est d’environ 6,8 degrés et la largeur de bande à mi-puissance (FNBW) d’environ 15,4 degrés, ce qui est très proche de nos calculs, surtout pour la HPBW !

Jusqu’à présent, nous avons utilisé une distance entre les éléments, d, égale à une demi-longueur d’onde. Par exemple, un réseau conçu pour le Wi-Fi 2,4 GHz avec un espacement de λ/2 aurait un espacement de 3 × 10⁸ / 2,4 × 10⁹ / 2 = 12,5 cm, soit environ 5 pouces. Cela signifie qu’un réseau 4 × 4 éléments aurait des dimensions d’environ 15 pouces × 15 pouces × la hauteur des antennes. Il arrive qu’un réseau ne puisse pas atteindre exactement un espacement de λ/2, par exemple lorsque l’espace est limité ou lorsque le même réseau doit fonctionner sur différentes fréquences porteuses.

Examinons le cas où l’espacement est supérieur à λ/2, c’est-à-dire un espacement excessif, en faisant varier d entre λ/2 et 4λ. Nous supprimerons la moitié inférieure du diagramme polaire puisqu’elle est de toute façon l’image miroir de la partie supérieure.

System Message: WARNING/2 (/home/vs/SPHINX/source/doa.rst, line 534)

Cannot scale image! Could not get size from "../_images/doa_d_is_large_animation.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/doa_d_is_large_animation.gif'

Comme vous pouvez le constater, outre l’ambiguïté à 180 degrés évoquée précédemment, une ambiguïté supplémentaire apparaît, qui s’aggrave lorsque d augmente (apparition de lobes supplémentaires ou incorrects). Ces lobes supplémentaires, appelés lobes de réseau, résultent du repliement de spectre spatial. Comme nous l’avons vu dans le chapitre sur échantillonnage IQ, un échantillonnage insuffisant entraîne un repliement de spectre. Le même phénomène se produit dans le domaine spatial : si les éléments ne sont pas suffisamment espacés par rapport à la fréquence porteuse du signal observé, l’analyse aboutit à des résultats erronés. On peut assimiler l’espacement des antennes à l’espace d’échantillonnage ! Dans cet exemple, les lobes de réseau ne posent pas de problème majeur tant que d > λ, mais ils apparaissent dès que l’espacement dépasse λ/2. En effet, le théorème de Nyquist stipule qu’il faut échantillonner au moins deux fois plus vite que le signal observé, soit deux échantillons par cycle. Nous mesurons notre taux d’échantillonnage spatial en échantillons par mètre, et comme l’équivalent de la fréquence radiane dans l’espace est de 2π/λ radians par mètre, et sachant qu’il y a 2π radians (360 degrés) dans un cycle, nous devons échantillonner l’espace au moins à :

\[ \begin{align}\begin{aligned}\text{fréquence d'échantillonnage spatia} \geq 2 \text{ [échantillons/cycle]} \cdot \frac{2\pi/\lambda \text{ [radians/metre]}}{2\pi \text{ [radians/cycle]}}\\ \text{fréquence d'échantillonnage spatial} \geq 2/\lambda \text{ [échantillons/metre]}\end{aligned}\end{align} \]

ou en terme de distance entre les éléments, \(d\), ce qui correspond essentiellement à des mètres par échantillon spatial :

\[d \leq \lambda/2\]

Tant que \(d \leq λ/2\), il n’y aura pas de lobes de réseau !

Que se passe-t-il lorsque d est inférieur à λ/2, par exemple lorsqu’il faut intégrer le réseau dans un espace réduit ? On sait qu’il n’y aura pas de lobes de réseau, mais un autre phénomène se produit… Répétons la même simulation en commençant par 0,5λ et en diminuant d.

System Message: WARNING/2 (/home/vs/SPHINX/source/doa.rst, line 559)

Cannot scale image! Could not get size from "../_images/doa_d_is_small_animation.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/doa_d_is_small_animation.gif'

Bien que le lobe principal s’élargisse lorsque d diminue, il présente toujours un maximum à 20 degrés, et il n’y a pas de lobes de réseau. En théorie, cela fonctionnerait donc (du moins à un rapport signal/bruit élevé et si le couplage mutuel ne devient pas un problème majeur). Pour mieux comprendre ce qui se produit lorsque d devient trop petit, répétons l’expérience en ajoutant un signal supplémentaire provenant de -40 degrés :

System Message: WARNING/2 (/home/vs/SPHINX/source/doa.rst, line 566)

Cannot scale image! Could not get size from "../_images/doa_d_is_small_animation2.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/doa_d_is_small_animation2.gif'

En dessous de λ/4, il n’est plus possible de distinguer les deux trajets, et le réseau d’antennes est fortement dégradé. Comme nous le verrons plus loin dans ce chapitre, il existe des techniques de formation de faisceaux plus précises que les techniques conventionnelles, mais maintenir d aussi proche que possible de λ/2 restera un principe fondamental.

Ajustement spatial

L’ajustement spatial est une technique utilisée conjointement avec le formateur de faisceau conventionnel. Elle consiste à ajuster l’amplitude des pondérations pour obtenir des caractéristiques spécifiques. Même si vous n’utilisez pas le formateur de faisceau conventionnel, il est important de comprendre le concept d’ajustement. Rappelons que le calcul des pondérations du formateur de faisceau conventionnel s’effectuait à l’aide d’une série de nombres complexes dont l’amplitude était égale à un. Avec l’ajustement spatial, nous multiplions les pondérations par des scalaires afin de modifier leur amplitude. Voyons ce qui se passe si nous multiplions les pondérations par des valeurs aléatoires comprises entre 0 et 1 :

Nous allons simuler la réception d’un signal dans l’axe de visée (0 degré) avec un rapport signal/bruit élevé afin d’observer le résultat. Notez que ce processus est équivalent et produira les mêmes résultats que la simulation du diagramme de rayonnement d’antenne au repos pour les pondérations données, comme nous l’expliquons à la fin de ce chapitre.

System Message: WARNING/2 (/home/vs/SPHINX/source/doa.rst, line 615)

Cannot scale image! Could not get size from "../_images/spatial_tapering_animation.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/spatial_tapering_animation.gif'

Observez la largeur du lobe principal et la position des zéros.

Il s’avère que l’atténuation permet de réduire les lobes secondaires, ce qui est souvent souhaitable, en diminuant l’amplitude des pondérations aux bords du réseau. Par exemple, une fonction fenêtre de Hamming peut être utilisée comme valeur de pondération, comme suit :

Pour le plaisir, nous allons comparer l’utilisation d’une fenêtre rectangulaire (sans fenêtre) et d’une fenêtre de Hamming comme fonction de pondération :

System Message: WARNING/2 (/home/vs/SPHINX/source/doa.rst, line 630)

Cannot scale image! Could not get size from "../_images/spatial_tapering_animation2.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/spatial_tapering_animation2.gif'

On observe deux différences. Premièrement, la largeur du lobe principal peut être augmentée ou diminuée selon la fonction de pondération utilisée (moins de lobes secondaires entraînent généralement un lobe principal plus large). Une pondération rectangulaire (c’est-à-dire sans pondération) produira le lobe principal le plus étroit, mais les lobes secondaires les plus larges. Deuxièmement, nous constatons que le gain du lobe principal diminue lorsqu’on applique un facteur d’atténuation. Cela s’explique par le fait que l’énergie du signal reçue est moindre, car le gain maximal de tous les éléments n’est pas utilisé. Ce phénomène peut s’avérer très problématique en cas de très faible rapport signal/bruit.

Si vous vous demandez pourquoi on observe autant de lobes secondaires avec une fenêtre rectangulaire (sans facteur d’atténuation), c’est pour la même raison qu’une fenêtre rectangulaire dans le domaine temporel induit une fuite spectrale dans le domaine fréquentiel. La transformée de Fourier d’une fenêtre rectangulaire est une fonction sinus cardinal, \(sin(x)/x\), dont les lobes secondaires tendent vers l’infini. Avec les réseaux d’antennes, l’échantillonnage est effectué dans le domaine spatial, et le diagramme de rayonnement est la transformée de Fourier de cet échantillonnage spatial, pondérée par les facteurs. C’est pourquoi nous avons pu visualiser le diagramme de rayonnement à l’aide d’une FFT plus tôt dans ce chapitre. Rappelons que dans la section consacrée au fenêtrage du chapitre sur le domaine fréquentiel, nous avons comparé la réponse fréquentielle de chaque type de fenêtre :

Modification manuelle des pondérations

Le formateur de faisceau classique nous fournit une équation pour calculer les pondérations afin de pointer dans une direction spécifique. Mais imaginons un instant que nous n’ayons aucune méthode de calcul des pondérations et que nous les modifions manuellement (amplitude et phase) pour observer les résultats. Ci-dessous se trouve une petite application écrite en JavaScript qui simule le diagramme de rayonnement d’un réseau à 8 éléments, avec des curseurs pour contrôler le gain et la phase de chaque élément. Vous pouvez essayer d’ajouter un effet de transition ou de simuler moins de 8 éléments en annulant l’amplitude d’un ou plusieurs d’entre eux.

Element     Amplitude (Gain)                  Phase

Formation de faisceaux adaptative

Le formateur de faisceaux conventionnel présenté précédemment est une méthode simple et efficace, mais il présente certaines limitations. Par exemple, il est peu performant en présence de plusieurs signaux provenant de directions différentes ou lorsque le niveau de bruit est élevé. Dans ces cas, il est nécessaire d’utiliser des techniques de formation de faisceaux plus avancées, souvent qualifiées de « adaptatives ». Le principe de la formation de faisceaux adaptative est d’utiliser le signal reçu pour calculer les pondérations, au lieu d’utiliser un ensemble fixe de pondérations comme avec le formateur de faisceaux conventionnel. Cela permet au formateur de faisceaux de s’adapter à l’environnement et d’offrir de meilleures performances, car les pondérations sont désormais basées sur les statistiques des données reçues.

Les techniques de formation de faisceaux adaptatives se divisent en deux catégories : les méthodes classiques et les méthodes basées sur les sous-espaces. Les méthodes de sous-espaces telles que MUSIC et ESPRIT sont très puissantes, mais elles nécessitent d’estimer le nombre de signaux présents et requièrent au moins trois éléments pour fonctionner (quatre étant recommandés).

La première technique de formation de faisceaux adaptatifs que nous allons étudier est MVDR, qui tend à être l’algorithme de référence lorsque l’on parle de formation de faisceaux adaptatifs.

Formateur de faisceau MVDR/Capon

Nous allons maintenant examiner un formateur de faisceau légèrement plus complexe que la technique conventionnelle de sommation et de retard, mais généralement beaucoup plus performant : le formateur de faisceau à réponse sans distorsion à variance minimale (MVDR), également appelé formateur de faisceau Capon. Rappelons que la variance d’un signal correspond à sa puissance. Le principe du MVDR est de maintenir le signal à l’angle d’intérêt avec un gain fixe de 1 (0 dB), tout en minimisant la variance/puissance totale du signal formé. Si le signal d’intérêt est maintenu fixe, minimiser la puissance totale revient à minimiser autant que possible les interférences et le bruit. On le qualifie souvent de formateur de faisceau « statistiquement optimal ».

Le formateur de faisceau MVDR/Capon peut être résumé par l’équation suivante :

\[\]

w_{mvdr} = frac{R^{-1} s}{s^H R^{-1} s}

Le vecteur \(s\) est le vecteur de direction correspondant à la direction souhaitée et a été présenté au début de ce chapitre. \(R\) est l’estimation de la matrice de covariance spatiale basée sur nos échantillons reçus, obtenue à l’aide de \(R = np.cov(X)\) ou calculée manuellement en multipliant \(X\) par sa transposée conjuguée complexe, c’est-à-dire \(R = X X^H\). La matrice de covariance spatiale est une matrice de taille \(Nr\) x \(Nr\) (3x3 dans les exemples précédents) qui indique la similarité des échantillons reçus des trois éléments. Bien que cette équation puisse paraître complexe au premier abord, il est utile de savoir que le dénominateur sert principalement à la mise à l’échelle, et que le numérateur, qui correspond à la matrice de covariance inversée multipliée par le vecteur de direction, est l’élément essentiel sur lequel il faut se concentrer. Cela étant dit, il est nécessaire d’inclure le dénominateur ; il agit comme une constante de normalisation afin que, lorsque \(R\) varie au fil du temps, les poids conservent leur amplitude.

Sortie du beamforming - La sortie du beamformer utilisant un vecteur de pondération :amth:`\mathbf{w}` est donnée par :

\[y(t) = \mathbf{w}^H \mathbf{x}(t)\]

Problème d’optimisation - L’objectif est de déterminer les pondérations du beamforming qui minimisent la puissance de sortie tout en assurant une réponse sans distorsion dans la direction souhaitée \(\theta_0\). Formellement, le problème peut être exprimé comme suit :

\[\min_{\mathbf{w}} \, \mathbf{w}^H \mathbf{R} \mathbf{w} \quad \text{subject to} \quad \mathbf{w}^H \mathbf{s} = 1\]

où :

\(\mathbf{R} = E[\mathbf{X}\mathbf{X}^H]\) est la matrice de covariance des signaux reçus

\(\mathbf{s}\) est le vecteur de direction vers la direction du signal souhaité \(\theta_0\)

Méthode Lagrangienne - Introduisons un multiplieur lagrangien \(\lambda\) et construisons le lagrangien :

\[L(\mathbf{w}, \lambda) = \mathbf{w}^H \mathbf{R} \mathbf{w} - \lambda (\mathbf{w}^H \mathbf{s} - 1)\]

Résolution de l’optimisation - En dérivant le lagrangien par rapport à \(\mathbf{w^H}\) et en annulant la dérivée, on obtient :

\[ \begin{align}\begin{aligned}\frac{\partial L}{\partial \mathbf{w}^*} = 2\mathbf{R}\mathbf{w} - \lambda \mathbf{s} = 0\\\mathbf{w} = \lambda \mathbf{s} \mathbf{{R^{-1}}}\end{aligned}\end{align} \]

Pour résoudre \(\lambda\), appliquons la contrainte \(\mathbf{w}^H \mathbf{s} = 1\):

\[ \begin{align}\begin{aligned}\implies (\lambda \mathbf{s^{H}}\mathbf{{R^{-1}}})s = 1\\\implies \lambda = \frac{1}{\mathbf{s}^{H}\mathbf{R}^{-1}\mathbf{s}}\\\mathbf{R}\mathbf{w} = \lambda \mathbf{s}\\\mathbf{w_{mvdr}} = \frac{\mathbf{R}^{-1} \mathbf{s}}{\mathbf{s}^H \mathbf{R}^{-1} \mathbf{s}}\end{aligned}\end{align} \]

Si la direction du signal d’intérêt est connue et reste constante, il suffit de calculer les pondérations une seule fois et de les utiliser pour recevoir ce signal. Même si la direction est constante, il est avantageux de recalculer périodiquement ces pondérations pour compenser les variations d’interférences et de bruit. C’est pourquoi on parle de formation de faisceaux « adaptative » pour ces formateurs de faisceaux numériques non conventionnels : ils utilisent les informations du signal reçu pour calculer les pondérations optimales. Pour rappel, la formation de faisceaux avec MVDR peut se faire en calculant ces pondérations et en les appliquant au signal avec w.conj().T @ X, comme avec la méthode conventionnelle. Seule la méthode de calcul des pondérations diffère.

Pour effectuer une détermination de la direction d’arrivée (DOA) avec le formateur de faisceaux MVDR, il suffit de répéter le calcul MVDR en balayant tous les angles d’intérêt. Autrement dit, on considère que le signal provient de l’angle \(\theta\), même si ce n’est pas le cas. Pour chaque angle, nous calculons les pondérations MVDR, puis nous les appliquons au signal reçu, et enfin nous calculons la puissance du signal. L’angle qui nous donne la puissance la plus élevée correspond à notre estimation de la direction d’arrivée (DOA). Mieux encore, nous pouvons tracer la puissance en fonction de l’angle pour visualiser le diagramme de rayonnement, comme nous l’avons fait précédemment avec le formateur de faisceau conventionnel. Ainsi, nous n’avons pas besoin de supposer le nombre de signaux présents.

En Python, nous pouvons implémenter le formateur de faisceau MVDR/Capon comme suit, sous forme de fonction pour faciliter son utilisation ultérieure :

# theta est la direction d'intérêt, en radians, et X est notre signal reçu
def w_mvdr(theta, X):
             s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # Vecteur de direction dans la direction souhaitée theta
             s = s.reshape(-1,1) # Transformation en vecteur colonne (taille 3x1)
             R = (X @ X.conj().T)/X.shape[1] # Calcul de la matrice de covariance. Donne une matrice de covariance Nr x Nr des échantillons
             Rinv = np.linalg.pinv(R) # 3x3. La pseudo-inverse est généralement plus performante/rapide qu'une véritable inverse.
             w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # Équation MVDR/Capon ! Le numérateur est de dimension 3x3 * 3x1, le dénominateur de dimension 1x3 * 3x3 * 3x1, ce qui donne un vecteur de pondération 3x1.
             return w

En utilisant ce formateur de faisceau MVDR dans le contexte de la DOA, on obtient l’exemple Python suivant :

theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 valeurs de theta différentes entre -180 et +180 degrés
results = []
for theta_i in theta_scan:
             w = w_mvdr(theta_i, X) # 3x1
             X_weighted = w.conj().T @ X # application des pondérations
             power_dB = 10*np.log10(np.var(X_weighted)) # puissance du signal, en dB, pour faciliter la visualisation simultanée des lobes de petite et de grande taille
             results.append(power_dB)
results -= np.max(results) # normalisation

Appliquée à l’exemple de simulation DOA précédent, cette méthode donne le résultat suivant :

align:

center

target:

../_images/doa_capons.svg

Cela semble fonctionner correctement, mais pour comparer cette technique à d’autres, il nous faut créer un problème plus intéressant. Créons une simulation avec un réseau de 8 éléments recevant trois signaux provenant d’angles différents : 20°, 25° et 40°. Le signal à 40° est reçu à une puissance bien inférieure aux deux autres, afin de complexifier la simulation. Notre objectif est de détecter les trois signaux, c’est-à-dire de repérer des pics significatifs (suffisamment élevés pour être extraits par un algorithme de détection de pics). Le code permettant de générer ce nouveau scénario est le suivant :

Vous pouvez placer ce code en haut de votre script, car nous générons un signal différent de celui de l’exemple original. Si nous appliquons notre formateur de faisceau MVDR à ce nouveau scénario, nous obtenons les résultats suivants :

align:

center

target:

../_images/doa_capons2.svg

Il fonctionne plutôt bien : nous pouvons observer les deux signaux reçus, séparés de seulement 5 degrés, ainsi que le troisième signal (à -40° ou 320°) reçu à une puissance dix fois inférieure à celle des autres. Appliquons maintenant le formateur de faisceau conventionnel à ce même scénario :

align:

center

target:

../_images/doa_complex_scenario.svg

Bien que la forme du faisceau soit plutôt esthétique, il ne détecte pas du tout les trois signaux… En comparant ces deux résultats, nous pouvons constater l’avantage.

Pour information, il est possible d’optimiser le calcul de la DOA avec MVDR grâce à une astuce. Rappelons que la puissance d’un signal est calculée en prenant sa variance, qui est la moyenne du carré de son amplitude (en supposant que la valeur moyenne de nos signaux est nulle, ce qui est presque toujours le cas pour les signaux RF en bande de base). On peut représenter la puissance de notre signal après pondération par l’équation suivante :

\[\]

P_{mvdr} = frac{1}{N} sum_{n=0}^{N-1} left| |w^H_{mvdr} r_n|^2

Si l’on remplace la sommation par l’opérateur d’espérance et que l’on substitue l’équation des poids MVDR, on obtient :

\[\]

P_{mvdr} = E left( left| w^H_{mvdr} X_n | ^2 right) = w^H_{mvdr} E left( X X^H right) w_{mvdr} = w^H_{mvdr} R w_{mvdr} = frac{s^H R^{-1} s}{s^H R^{-1} s} cdot R cdot frac{R^{-1} s}{s^H R^{-1} s} = frac{s^H R^{-1} s}{(s^H R^{-1} s)(s^H) R^{-1} s)} = frac{1}{s^H R^{-1} s}

Ce qui signifie que nous n’avons pas besoin d’appliquer les pondérations. Cette dernière équation de puissance ci-dessus peut être utilisée directement dans notre analyse DOA, ce qui nous permet d’économiser des calculs :

def power_mvdr(theta, X):

s = np.exp(2j * np.pi * d * np.arange(r.shape[0]) * np.sin(theta)) # vecteur de direction dans la direction souhaitée theta s = s.reshape(-1,1) # transformation en vecteur colonne (taille 3x1) R = (X @ X.conj().T)/X.shape[1] # Calcul de la matrice de covariance. Donne une matrice de covariance Nr x Nr des échantillons Rinv = np.linalg.pinv(R) # 3x3. La pseudo-inverse est généralement plus performante que l’inverse exacte. return 1/(s.conj().T @ Rinv @ s).squeeze()

Pour utiliser cette fonction dans la simulation précédente, au sein de la boucle for, il suffit d’effectuer le calcul suivant 10*np.log10(). C’est terminé ! Aucun poids n’est à appliquer ; nous avons omis de les calculer.

Il existe de nombreux autres formateurs de faisceaux, mais nous allons maintenant examiner l’influence du nombre d’éléments sur la formation de faisceaux et la détermination de la direction d’arrivée (DOA).

Prenons un instant pour aborder la matrice de covariance spatiale, concept clé du beamforming adaptatif. Une matrice de covariance est une représentation mathématique de la similarité entre paires d’éléments d’un vecteur aléatoire (dans notre cas, les éléments de notre réseau, d’où le terme de matrice de covariance spatiale). Une matrice de covariance est toujours carrée, et les valeurs de sa diagonale correspondent à la covariance de chaque élément avec lui-même. Nous calculons une estimation de la matrice de covariance spatiale ; il ne s’agit que d’une estimation, compte tenu du nombre limité d’échantillons.

De manière générale, la matrice de covariance est définie comme suit : \(\mathrm{cov}(X) = E \left[ (X - E[X])(X - E[X])^H \right]\)

for wireless signals at baseband, \(E[X]\) is typically zero or very close to zero, so this simplifies to:

\(\mathrm{cov}(X) = E[X X^H]\)

Given a limited number of IQ samples, \(\boldsymbol{X}\), we can estimate this covariance, which we will denote as \(\hat{R}\):

\[ \begin{align}\begin{aligned}\hat{R} = \frac{\boldsymbol{X} \boldsymbol{X}^H}{N}\\ = \frac{1}{N} \sum^N_{n=1} X_n X_n^H\end{aligned}\end{align} \]

where \(N\) is the number of samples (not the number of elements). In Python this looks like:

R = (X @ X.conj().T)/X.shape[1]

Alternatively, we can use the built-in NumPy function:

R = np.cov(X)

As an example, we will look at the spatial covariance matrix for the scenario where we only had one transmitter and three elements:

[[ 1.494+0.j    0.486+0.881j -0.543+0.839j]
 [ 0.486-0.881j 1.517 +0.j    0.483+0.886j]
 [-0.543-0.839j 0.483-0.886j  1.499+0.j   ]]

Remarquez que les éléments diagonaux sont réels et sensiblement identiques. En effet, ils indiquent uniquement la puissance du signal reçu à chaque élément, qui sera sensiblement la même d’un élément à l’autre puisque leur gain est identique. Les éléments hors diagonale contiennent les valeurs importantes, même si l’examen des valeurs brutes ne nous apprend pas grand-chose, si ce n’est une forte corrélation entre les éléments.

Dans le cadre de la formation de faisceaux adaptative, vous observerez un motif où l’on calcule l’inverse de la matrice de corrélation spatiale. Cette inverse indique la relation entre deux éléments après avoir éliminé l’influence des autres éléments. On l’appelle « matrice de précision » en statistiques et « matrice de blanchiment » en radar.

Bien que le MVDR soit puissant, que se passe-t-il si nous avons plusieurs signaux d’intérêts (SOI) ? Heureusement, grâce à une légère modification du MVDR, nous pouvons implémenter un schéma gérant plusieurs SOI, appelé formateur de faisceau à variance minimale contrainte linéaire (LCMV). Il s’agit d’une généralisation du MVDR, où l’on spécifie la réponse souhaitée pour plusieurs directions, un peu comme une version spatiale de la fonction firwin2() de SciPy pour ceux qui la connaissent. Le vecteur de pondération optimal pour le formateur de faisceau LCMV peut être résumé par l’équation suivante :

\[w_{lcmv} = R^{-1} C [C^H R^{-1} C]^{-1} f\]

où \(C\) est une matrice comprenant les vecteurs de direction des SOI et des interférents correspondants, et \(f\) est le vecteur de réponse souhaité. Le vecteur \(f\) d’une ligne donnée prend la valeur 0 lorsque le vecteur de direction correspondant doit être annulé, et la valeur 1 lorsqu’un faisceau doit être dirigé vers cette ligne. Par exemple, avec deux sources d’intérêt et deux sources d’interférence, on peut définir \(f = [1,1,0,0]\). Le formateur de faisceaux LCMV est un outil puissant permettant de supprimer les interférences et le bruit provenant de plusieurs directions, tout en amplifiant le signal d’intérêt provenant également de plusieurs directions. Cependant, le nombre total d’annulations et de faisceaux pouvant être formés simultanément est limité par la taille du réseau (le nombre d’éléments). De plus, il est nécessaire de définir le vecteur de direction pour chaque source d’intérêt et chaque interféreur, ce qui n’est pas toujours possible en pratique. L’utilisation d’estimations peut dégrader les performances du formateur de faisceaux LCMV. C’est pourquoi nous préférons orienter les zones d’interférence nulle (ou « nulls ») à l’aide de la matrice de covariance spatiale \(R\) (basée sur les statistiques du signal reçu), plutôt que de les « coder en dur » en estimant l’angle d’arrivée (AoA) de l’interférent (ce qui peut engendrer des erreurs) et en construisant le vecteur de direction dans cette direction, en ajoutant un 0 à \(f\).

L’implémentation de LCMV en Python est très similaire à celle de MVDR, mais nous devons spécifier \(C\), composé de plusieurs vecteurs de direction potentiels, et \(f\), un tableau unidimensionnel de 1 et de 0, comme mentionné précédemment. L’extrait de code suivant illustre l’implémentation du formateur de faisceau LCMV pour deux angles d’incidence (15° et 60°). Rappelons que MVDR ne prend en charge qu’un seul angle d’incidence à la fois. Par conséquent, notre \(f\) est initialisé à \([1; 1]\) sans zéros, car nous n’incluons aucune zone d’interférence nulle « codée en dur ». Nous allons simuler un scénario avec quatre interférents arrivant d’angles de -60, -30, 0 et 30 degrés.

# Pointons vers le SOI à 15° et un autre SOI potentiel, non simulé, à 60°.
soi1_theta = 15 / 180 * np.pi # Conversion en radians
soi2_theta = 60 / 180 * np.pi
# Poids LCMV
R_inv = np.linalg.pinv(np.cov(X)) # 8x8
s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi1_theta)).reshape(-1,1) # 8x1
s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi2_theta)).reshape(-1,1) # 8x1
C = np.concatenate((s1, s2), axis=1) # 8x2
f = np.ones(2).reshape(-1,1) # 2x1

# Équation LCMV
# 8x8 8x2 2x8 8x8 8x2 2x1
w = R_inv @ C @ np.linalg.pinv(C.conj().T @ R_inv @ C) @ f # Sortie : 8x1

Nous pouvons tracer le diagramme de rayonnement de w à l’aide de la méthode FFT présentée précédemment :

align:

center

target:

../_images/lcmv_beam_pattern.svg

alt:

Exemple de diagramme de rayonnement obtenu avec le formateur de faisceau LCMV

Comme vous pouvez le constater, nous avons des faisceaux pointant dans les deux directions d’intérêt. Des points nuls sont ajoutés aux emplacements des interférents (comme pour le MVDR, il n’est pas nécessaire de spécifier la position des émetteurs ; le logiciel la détermine à partir du signal reçu). Des points verts et rouges sont ajoutés au graphique pour indiquer les angles d’arrivée (AoA) des SOI et des interférents, respectivement.

<details>

<summary>Pour le code complet, développez cette section</summary>

# Simulation du signal reçu
Nr = 8 # 8 éléments
theta1 = -60 / 180 * np.pi # Conversion en radians
theta2 = -30 / 180 * np.pi
theta3 = 0 / 180 * np.pi
theta4 = 30 / 180 * np.pi
s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(the)
s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta2)).reshape(-1,1)
s3 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta3)).reshape(-1,1)
s4 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta4)).reshape(-1,1)
# we'll use 3 different frequencies.  1xN
tone1 = np.exp(2j*np.pi*0.01e6*t).reshape(1,-1)
tone2 = np.exp(2j*np.pi*0.02e6*t).reshape(1,-1)
tone3 = np.exp(2j*np.pi*0.03e6*t).reshape(1,-1)
tone4 = np.exp(2j*np.pi*0.04e6*t).reshape(1,-1)
X = s1 @ tone1 + s2 @ tone2 + s3 @ tone3 + s4 @ tone4
n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N)
X = X + 0.5*n # 8xN

# Prenons comme exemples le SOI à 15 degrés, et un autre SOI potentiel que nous n'avons pas simulé à 60 degrés.
soi1_theta = 15 / 180 * np.pi # conversion en radians
soi2_theta = 60 / 180 * np.pi

# Poids du LCMV
R_inv = np.linalg.pinv(np.cov(X)) # 8x8
s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi1_theta)).reshape(-1,1) # 8x1
s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi2_theta)).reshape(-1,1) # 8x1
C = np.concatenate((s1, s2), axis=1) # 8x2
f = np.ones(2).reshape(-1,1) # 2x1

# Équation du LCMV
#    8x8   8x2                    2x8        8x8   8x2  2x1
w = R_inv @ C @ np.linalg.pinv(C.conj().T @ R_inv @ C) @ f # la sortie est 8x1

# Tracé du diagramme de rayonnement
w = w.squeeze() # reduction à un tableau 1D
N_fft = 1024
w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero pad à N_fft éléments pour obtenir un meilleur résolution dans la FFT
w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # amplitude de la FFT en dB
w_fft_dB -= np.max(w_fft_dB) # normalisation à 0 dB au maximum
theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) #  Associer les échantillons de la FFT à des angles en radians
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
ax.plot(theta_bins, w_fft_dB) # MAKE SURE TO USE RADIAN FOR POLAR
# Add dots where interferers and SOIs are
ax.plot([theta1], [0], 'or')
ax.plot([theta2], [0], 'or')
ax.plot([theta3], [0], 'or')
ax.plot([theta4], [0], 'or')
ax.plot([soi1_theta], [0], 'og')
ax.plot([soi2_theta], [0], 'og')
ax.set_theta_zero_location('N') # Orienter 0 degré vers le haut
ax.set_theta_direction(-1) # Incrémenter dans le sens horaire
ax.set_thetagrids(np.arange(-90, 105, 15)) # c'est en degrés
ax.set_rlabel_position(55)  #  Éloigner les étiquettes de la grille des autres étiquettes
ax.set_thetamin(-90) # Afficher uniquement la moitié supérieure
ax.set_thetamax(90)
ax.set_ylim([-30, 1]) # En l'absence de bruit, réduire de 30 dB seulement
plt.show()

Il existe un cas d’utilisation particulier de LCMV auquel vous avez peut-être déjà pensé : supposons qu’au lieu de pointer le faisceau principal à exactement 20 degrés, vous souhaitiez un faisceau plus large que celui fourni par un formateur de faisceau classique. Pour ce faire, définissez le vecteur de réponse souhaité f comme un vecteur de 1 sur une plage d’angles (par exemple, plusieurs valeurs entre 10 et 30 degrés) et de 0 ailleurs. Cet outil puissant permet de créer un diagramme de rayonnement plus large que le lobe principal d’un formateur de faisceau classique, ce qui est toujours un avantage dans les situations réelles où l’angle d’arrivée exact est inconnu. La même approche peut être utilisée pour créer un zéro dans une direction spécifique, réparti sur une plage d’angles relativement large. N’oubliez pas que cela nécessite plusieurs degrés de liberté ! À titre d’exemple, simulons un réseau de 18 éléments et définissons l’angle d’intérêt entre 15 et 30 degrés à l’aide de 4 valeurs différentes de θ, et un angle nul entre 45 et 60 degrés à l’aide de 4 autres valeurs différentes de θ. Nous ne simulerons aucun interférent réel.

Nr = 18
X = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) # Simulation d'un signal reçu composé uniquement de bruit.

# Poitons vers le SOI de 15 à 30 degrés en utilisant 4 thetas différents
soi_thetas = np.linspace(15, 30, 4) / 180 * np.pi # conversio en radians

# Let's make a null from 45 to 60 degrees using 4 different thetas
null_thetas = np.linspace(45, 60, 4) / 180 * np.pi # convert to radians

# poids LCMV
R_inv = np.linalg.pinv(np.cov(X))
s = []
for soi_theta in soi_thetas:
    s.append(np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi_theta)).reshape(-1,1))
for null_theta in null_thetas:
    s.append(np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(null_theta)).reshape(-1,1))
C = np.concatenate(s, axis=1)
f = np.asarray([1]*len(soi_thetas) + [0]*len(null_thetas)).reshape(-1,1)
w = R_inv @ C @ np.linalg.pinv(C.conj().T @ R_inv @ C) @ f # LCMV equation

# Tracé du diagramme de rayonnement comme précédemment...

Le faisceau et le point d’annulation sont répartis sur la plage demandée ! Essayez de modifier le nombre de θ pour le faisceau principal et/ou le point d’annulation, ainsi que le nombre d’éléments, afin de vérifier si les pondérations résultantes permettent d’obtenir la réponse souhaitée.

Maintenant que nous avons vu le LCMV, il est intéressant d’explorer une technique plus simple, utilisable avec les réseaux analogiques et numériques : l’orientation du point d’annulation. Il s’agit d’une extension du formateur de faisceau classique, permettant non seulement de diriger un faisceau dans la direction souhaitée, mais aussi de placer des points d’annulation à des angles spécifiques. Cette technique n’implique pas de modification des pondérations en fonction du signal reçu (par exemple, le coefficient de réflexion R n’est jamais calculé) et n’est donc pas considérée comme adaptative. Dans la simulation ci-dessous, il n’est même pas nécessaire de simuler un signal : il suffit de paramétrer les poids de notre formateur de faisceau en utilisant la technique de suppression des zéros pour placer des zéros à des angles prédéfinis, puis de visualiser le diagramme de rayonnement.

Les poids pour la suppression des zéros sont calculés en partant d’un formateur de faisceau conventionnel pointé dans la direction souhaitée, puis en utilisant l’équation d’annulation des lobes secondaires pour mettre à jour les poids afin d’inclure les zéros, un à un. L’équation d’annulation des lobes secondaires est :

\[w_{\text{new}} = w_{\text{orig}} - \frac{w_{\text{null}}^H w_{\text{orig}}}{w_{\text{null}}^H w_{\text{null}}} w_{\text{null}}\]

Notez que nous obtenons toujours des zéros provenant de A et B (le zéro de B est plus faible, mais B correspond également à un signal plus faible), mais cette fois-ci, un lobe principal important est dirigé vers notre angle d’intérêt, C. C’est là toute la puissance des données d’apprentissage, et pourquoi elles sont si importantes dans les applications radar.

La méthode que nous avons utilisée tout au long de ce chapitre pour simuler les signaux atteignant notre réseau depuis un certain angle d’arrivée (en multipliant le vecteur de direction par le signal émis) repose sur une hypothèse de bande étroite : le signal est supposé avoir une seule fréquence, et le vecteur de direction est calculé à cette fréquence. Cette approximation est acceptable pour de nombreux signaux, mais elle ne convient pas aux signaux à large bande, par exemple ceux dont la bande passante est supérieure à environ 5 % de la fréquence centrale. Nous aborderons brièvement une astuce permettant de simuler du bruit à large bande provenant d’une direction donnée (par exemple, un brouillage par barrage provenant d’un seul angle d’arrivée).

Cette méthode fonctionne en construisant une matrice de covariance R obtenue en sommant les contributions de chaque source de bruit à large bande. La matrice racine carrée A est ensuite calculée, et l’ensemble d’échantillons X est généré en « colorant » un bruit gaussien complexe standard avec A. Un paramètre clé est fractional_bw, qui correspond à la bande passante du signal de bruit divisée par sa fréquence centrale. Lorsque fractional_bw = 0, le code suivant devrait reproduire le même résultat que la méthode traditionnelle de simulation des signaux reçus. Le code Python ci-dessous peut être intégré aux exemples précédents pour simuler le signal reçu X.

N = 10 # Nombre d'éléments dans le réseau linéaire uniforme (ULA)
num_samples = 10000
d = 0.5
num_jammers = 3
jammer_pow_dB = np.array([30, 30, 30]) # Puissances des brouilleurs en dB
jammer_aoa_deg = np.array([-70, -20, 40]) # Angles des brouilleurs en degrés
jammer_aoa = np.sin(np.deg2rad(jammer_aoa_deg)) * np.pi
element_gain_dB = np.zeros(N) # Gains en dB pour les éléments du réseau (tous à 0 dB dans notre cas)
element_gain_linear = 10.0 ** (element_gain_dB / ​​10) # Conversion des gains du réseau en valeurs linéaires
fractional_bw = 0.1 # si ceci Si la valeur est 0, la méthode correspond à la méthode traditionnelle utilisant le facteur de réseau pour simuler les signaux reçus.
# Construction de la matrice de covariance NxN du brouilleur R
R = np.zeros((N, N), dtype=complex)
for m in range(N):
    for n in range(N):
        for j in range(num_jammers):
            total_element_gain = np.sqrt(element_gain_linear[m] * element_gain_linear[n])
            sinc_term = np.sinc(0.5 * fractional_bw * (m - n) * jammer_aoa[j] / np.pi)
            exp_term = np.exp(1j * (m - n) * jammer_aoa[j])
            R[m, n] += 10.0 ** (jammer_pow_dB[j] / 10) * total_element_gain * sinc_term * exp_term
R = np.eye(N, dtype=complex) + R

# Générer les échantillons reçus
A = fractional_matrix_power(R, 0.5) # Calculer la racine carrée de la matrice (factorisation de Cholesky effective)
A = A / np.sqrt(2)
X = np.zeros((N, num_samples), dtype=complex)
for k in range(num_samples):
    noise_vec = np.random.randn(N) + 1j * np.random.randn(N) # bruit complexe
    X[:, k] = A.conj().T @ noise_vec

Dans les graphiques ci-dessous, les pondérations MVDR sont calculées pour une visée à 20 degrés et affichées en noir, tandis que le formateur de faisceau conventionnel pour 20 degrés est représenté en bleu pointillé. Les trois sources de bruit sont indiquées en rouge. Dans ce premier graphique, une bande passante fractionnelle de 0 est utilisée, ce qui signifie que ces pondérations MVDR devraient correspondre aux scénarios précédents utilisant l’hypothèse de bande étroite. D’après le graphique, tout semble fonctionner correctement. Cependant, si le bruit réel s’avère être à large bande passante (et que votre SOI l’est également, ce qui signifie qu’un simple filtrage du bruit est impossible), la simulation ne correspondra pas à la réalité.

align:

center

target:

../_images/doa_covariance_method_1.svg

alt:

Méthode de covariance DOA avec une bande passante fractionnelle de 0

Nous appliquons maintenant une bande passante fractionnelle de 0,1, ce qui répartit les sources de bruit sur une large bande passante et entraîne la création de zones d’annulation beaucoup plus larges par MVDR. Dans de nombreux scénarios réels, cela représente une simulation plus réaliste.

align:

center

target:

../_images/doa_covariance_method_2.svg

alt:

Méthode de covariance DOA avec une bande passante fractionnelle de 0,1

Réseaux circulaires

Nous aborderons brièvement le réseau circulaire uniforme (UCA), une géométrie de réseau couramment utilisée pour la détection d’arrivée (DOA) car elle résout le problème d’ambiguïté à 180 degrés des réseaux circulaires uniformes (ULA). Le KrakenSDR, par exemple, est un réseau à 5 éléments, généralement disposés en cercle avec un espacement régulier. En théorie, trois éléments suffisent pour former un UCA, tout comme deux éléments suffisent pour un ULA.

Tout le code étudié jusqu’à présent s’applique aux UCA ; il suffit de remplacer l’équation du vecteur de direction par une équation spécifique aux UCA :

radius = 0.05 # normalisé par la longueur d'onde !
d = np.sqrt(2 * rayon**2 * (1 - np.cos(2*np.pi/Nr)))
sf = 1.0 / (np.sqrt(2.0) * np.sqrt(1.0 - np.cos(2*np.pi/Nr))) # Facteur d'échelle basé sur la géométrie, par exemple 1.0 pour un hexagone
x = d * sf * np.cos(2 * np.pi / Nr * np.arange(Nr))
y = -1 * d * sf * np.sin(2 * np.pi / Nr * np.arange(Nr))
s = np.exp(1j * 2 * np.pi * (x * np.cos(theta) + y * np.sin(theta)))
s = s.reshape(-1, 1) # Nrx1

Enfin, il est conseillé de balayer de 0 à 360 degrés, et non seulement de -90 à +90 degrés comme avec un réseau linéaire uniforme (ULA).

Pour les réseaux 2D (par exemple, rectangulaires), consultez le chapitre 2d-beamforming-chapter.

L’ensemble du code Python, y compris celui utilisé pour générer les figures et les animations, est disponible sur la page GitHub du manuel : [https://github.com/777arc/PySDR/blob/master/figure-generating-scripts/doa.py].

Implémentation DOA dans GNU Radio - https://github.com/EttusResearch/gr-doa

Implémentation DOA utilisée par KrakenSDR - https://github.com/krakenrf/krakensdr_doa/blob/main/_signal_processing/krakenSDR_signal_processor.py

[1] Mailloux, Robert J. Phased Array Antenna Handbook. Deuxième édition, Artech House, 2005

[2] Van Trees, Harry L. Optimum Array Processing: Part IV of Detection, Estimation, and Modulation Theory. Wiley, 2002.

<br>

Index

A propos de l’auteur

Vincent Szymanski est enseignant de physique appliquée, spécialise en traitement du signal et en système embarqués et radio logicielle et la détection du spectre. Il est professeur agrégé à l’Université de Saint-Étienne, au Département Génie Électrique & Informatique Industrielle où il a créé et enseigné un cours qui a servi de base à ce manuel. Il s’agitdestiné aux étudiants de premier cycle en électronique et sytèmes embarqués du département Génie Électrique & Informatique Industrielle de l’IUT, intéressés par la SDR/DSP. Ce cours l’a amené à mieux comprendre comment rendre un matériel incroyablement lourd accessible et attrayant pour des étudiants qui étaient de grands programmeurs mais qui avaient peu ou pas de connaissances de la couche PHY. Il n’était pas rare de commencer le cours par un mini-hackathon, où les étudiants devaient trouver ou décoder des signaux cachés (transmis par Marc) en utilisant ce qu’ils venaient d’apprendre.

Vincent vit actuellement dans la région de Saint-Étienne avec sa femme Myriam et leur 2 filles. Ses loisirs incluent le jardinage, le saxophone, la musique hindoustanie, la méditation et le yo-yo.

Email: marc@pysdr.org

Page des professeurs de l’Université du Maryland: cs.umd.edu/people/sdr [https://www.cs.umd.edu/people/sdr]

System Message: WARNING/2 (/home/vs/SPHINX/source/about_author.rst, line 16)

Cannot scale image! Could not get size from "../_images/silly_marc.jpg": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/silly_marc.jpg'

Codage Canal

Dans ce chapitre, nous présentons les bases du codage de canal, c’est-à-dire les codes correcteurs d’erreur (FEC pour Forward Error Correction en anglais), la limite de Shannon, les codes de Hamming, les turbo-codes et les codes LDPC. Le codage de canal est un domaine énorme dans les communications sans fil, et est une branche de la « théorie de l’information », qui est l’étude de la quantification, du stockage et de la communication de l’information.

Pourquoi nous avons besoin du codage des canaux

Comme nous l’avons appris dans le chapitre le Bruit et les dB, les canaux sans fil sont bruyants, et nos symboles numériques n’atteindront pas parfaitement le récepteur. Si vous avez suivi un cours des réseaux, vous connaissez peut-être déjà les contrôles de redondance cyclique (CRC pour cyclic redundancy checks en anglais), qui détectent les erreurs au niveau de la réception. L’objectif du codage de canal est de détecter et de corriger les erreurs au niveau du récepteur. Si nous laissons une certaine marge d’erreur, nous pouvons transmettre avec un système de modulation d’ordre supérieur par exemple, sans que la liaison soit rompue. À titre d’exemple visuel, considérez les constellations suivantes montrant une QPSK (à gauche) et une 16QAM (à droite) avec la même quantité de bruit. La QPSK fournit 2 bits par symbole, tandis que la 16QAM offre un débit deux fois plus élevé avec 4 bits par symbole. Mais notez comment dans la constellation QPSK, les symboles ont tendance à ne pas dépasse pas les frontières de décision des symboles, ou l’axe des x et l’axe des y, ce qui signifie que les symboles seront reçus correctement. En revanche, dans le tracé de la constellation 16QAM, il y a un chevauchement des groupes de points et, par conséquent, de nombreux symboles seront mal reçus.

System Message: WARNING/2 (/home/vs/SPHINX/source/channel_coding.rst, line 15)

Cannot scale image! Could not get size from "../_images/qpsk_vs_16qam.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/qpsk_vs_16qam.png'

Un échec du CRC entraîne généralement une retransmission, du moins lorsqu’on utilise un protocole comme TCP. Si Alice envoie un message à Bob, il est préférable que Bob n’ait pas à renvoyer un message à Alice pour demander à nouveau l’information. Le but du codage de canal est de transmettre des informations redondantes. La redondance est une sécurité qui réduit le nombre de paquets erronés, de retransmissions ou de données perdues.

Nous avons vu pourquoi nous avons besoin du codage de canal, voyons donc où il intervient dans la chaîne de transmission-réception :

Observez qu’il y a plusieurs étapes de codage dans la chaîne d’émission-réception. Le codage source, notre première étape, n’est pas la même que le codage du canal; le codagesource a pour but de compresser les données à transmettre autant que possible, tout comme lorsque vous zippez des fichiers pour réduire l’espace occupé. En d’autres termes, la sortie du bloc de codage de la source doit être plus petite que l’entrée des données, mais la sortie du codage du canal sera plus grande que son entrée car la redondance est ajoutée.

Types de Codes

Pour effectuer le codage du canal, nous utilisons un « code de correction d’erreur ». Ce code nous dit, étant donné les bits que nous devons transmettre, quels sont les bits que nous transmettons réellement? Le code le plus élémentaire est appelé « code de répétition », et il consiste à répéter simplement un bit N fois de suite. Pour le code de répétition-3, on transmet chaque bit trois fois :

0 → 000

1 → 111

Le message 10010110 est transmis sous la forme 111000000111000111111000 après codage du canal.

Certains codes fonctionnent sur des « blocs » de bits d’entrée, tandis que d’autres utilisent une approche par flux continu. Les codes qui fonctionnent sur des blocs, c’est-à-dire des données d’une longueur définie, sont appelés « codes blocs », tandis que les codes qui fonctionnent sur un flux de bits, où la longueur des données est arbitraire, sont appelés « codes convolutifs ». Ce sont les deux principaux types de codes. Notre code de répétition-3 est un code de bloc où chaque bloc est de trois bits.

Soit dit en passant, ces codes de correction d’erreurs ne sont pas uniquement utilisés dans le codage des canaux pour les liaisons sans fil. Vous avez déjà stocké des informations sur un disque dur ou un SSD et vous vous êtes demandé comment il n’y avait jamais d’erreurs de bits lors de la relecture des informations? L’écriture, puis la lecture, de la mémoire est similaire à un système de communication. Les contrôleurs de disques durs et de disques SSD intègrent la correction d’erreurs. Elle est transparente pour le système d’exploitation et peut être propriétaire puisqu’elle est intégrée au disque dur/SSD. Pour les supports portables comme les CD, la correction d’erreurs doit être normalisée. Les codes Reed-Solomon étaient courants dans les CD-ROM.

Rendement de Code

Toute correction d’erreur comprend une forme de redondance. Cela signifie que si nous voulons transmettre 100 bits d’information, nous devrons en fait envoyer plus que 100 bits. Le « débit de code » est le rapport entre le nombre de bits d’information et le nombre total de bits envoyés (c’est-à-dire les bits d’information plus les bits de redondance). Pour en revenir à l’exemple du codage par répétition-3, si je dispose de 100 bits d’information, nous pouvons déterminer ce qui suit :

300 bits sont envoyés

Seuls 100 bits représentent une information

Taux de codage = 100/300 = 1/3

Le taux de codage sera toujours inférieur à 1, car il existe un compromis entre la redondance et le débit. Un taux de codage plus faible signifie plus de redondance et moins de débit.

Modulation et Codage

Dans le chapitre Modulation numérique, nous avons abordé le bruit dans les schémas de modulation. Pour un SNR (rapport signal à bruit) faible, vous avez besoin d’un schéma de modulation d’ordre faible (par exemple, QPSK) pour faire face au bruit, et pour un SNR élevé, vous pouvez utiliser une modulation comme 256QAM pour envoyer plus de bits par seconde. Il en va de même pour le codage du canal: vous souhaitez des taux de codage plus faibles à des SNR faibles, et à des SNR élevés, vous pouvez utiliser un taux de codage proche de 1. Les systèmes de communication modernes disposent d’un ensemble de schémas de modulation et de codage combinés, appelés MCS (pour modulations and coding schemes) en anglais. Chaque MCS spécifie un schéma de modulation et un schéma de codage à utiliser à des niveaux de SNR spécifiques.

Les communications modernes modifient de manière adaptative le MCS en temps réel en fonction des conditions du canal sans fil. Le récepteur envoie un retour d’information sur la qualité du canal à l’émetteur. Le retour d’information doit être partagé avant que la qualité du canal sans fil ne change, ce qui peut être de l’ordre de la ms. Ce processus adaptatif permet d’obtenir le meilleur débit de communication possible et est utilisé par les technologies modernes telles que LTE, 5G et WiFi. Ci-dessous, une visualisation d’une tour cellulaire changeant de MCS pendant la transmission en fonction de la distance entre l’utilisateur et la cellule.

Lorsque vous utilisez un MCS adaptatif, si vous tracez le débit en fonction du SNR, vous obtenez une courbe en forme d’escalier comme le graphique ci-dessous. Les protocoles comme LTE ont souvent un tableau indiquant quel MCS doit être utilisé à quel SNR.

Code de Hamming

Examinons un simple code correcteur d’erreurs. Le code de Hamming a été le premier code non trivial développé. À la fin des années 1940, Richard Hamming travaillait aux Bell Labs et utilisait un ordinateur électromécanique qui utilisait des bandes de papier perforé. Lorsque des erreurs étaient détectées dans la machine, celle-ci s’arrêtait et les opérateurs devaient les corriger. Hamming a été frustré de devoir recommencer ses programmes à partir de zéro à cause des erreurs détectées. Il s’est dit : « Bon sang, si la machine peut détecter une erreur, pourquoi ne peut-elle pas localiser la position de l’erreur et la corriger? ». Il a passé les années suivantes à développer le code de Hamming pour que l’ordinateur puisse faire exactement cela.

Dans les codes de Hamming, des bits supplémentaires, appelés bits de parité ou bits de contrôle, sont ajoutés aux informations pour assurer la redondance. Toutes les positions binaires qui sont des puissances de deux sont des bits de parité: 1, 2, 4, 8, etc. Les autres positions binaires sont destinées à l’information. Le tableau situé sous ce paragraphe met en évidence les bits de parité en vert. Chaque bit de parité « couvre » tous les bits où le ET binaire de la parité et de la position du bit est différent de zéro, marqué d’un X rouge ci-dessous. Si nous voulons utiliser un bit de données, nous avons besoin des bits de parité qui le couvrent. Pour pouvoir aller jusqu’au bit de données d9, nous avons besoin du bit de parité p8 et de tous les bits de parité qui le précèdent. Cette table nous indique donc le nombre de bits de parité dont nous avons besoin pour un certain nombre de bits. Ce schéma se poursuit indéfiniment.

Les codes de Hamming sont des codes de bloc, ils fonctionnent donc sur N bits de données à la fois. Ainsi, avec trois bits de parité, nous pouvons opérer sur des blocs de quatre bits de données à la fois. Nous représentons ce schéma de codage d’erreur par Hamming(7,4), où le premier argument est le nombre total de bits transmis et le second argument est le nombre de bits de données.

Voici trois propriétés importantes des codes de Hamming :

Le nombre minimal de changements de bits nécessaires pour passer d’un mot de code quelconque à un autre mot de code quelconque est de trois.

Il peut corriger les erreurs d’un bit

Il peut détecter mais pas corriger les erreurs de deux bits

D’un point de vue algorithmique, le processus de codage peut être réalisé à l’aide d’une simple multiplication matricielle, en utilisant ce que l’on appelle la « matrice génératrice ». Dans l’exemple ci-dessous, le vecteur 1011 représente les données à coder, c’est-à-dire les informations que nous voulons envoyer au récepteur. La matrice 2D est la matrice génératrice, et elle définit le schéma de codage. Le résultat de la multiplication fournit le mot de code à transmettre.

System Message: WARNING/2 (/home/vs/SPHINX/source/channel_coding.rst, line 103)

Cannot scale image! Could not get size from "../_images/hamming3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/hamming3.png'

L’intérêt de se plonger dans les codes de Hamming était de donner un aperçu du fonctionnement du codage des erreurs. Les codes en bloc ont tendance à suivre ce type de schéma. Les codes convolutifs fonctionnent différemment, mais nous ne nous y attarderons pas ici ; ils utilisent souvent un décodage de type Trellis, qui peut être représenté par un diagramme ressemblant à celui-ci:

Décodage souple ou dur

Rappelons qu’au niveau du récepteur, la démodulation intervient avant le décodage. Le démodulateur peut nous dire quel symbole a été envoyé, ou il peut nous donner la valeur « souple ». Pour la BPSK, au lieu de nous dire 1 ou 0, le démodulateur peut dire 0.3423 ou -1.1234, quelle que soit la valeur « souple » du symbole. En général, le décodage est conçu pour utiliser des valeurs dures ou souples.

Décodage à décision souple - utilise les valeurs souples.

Décodage à décision dure - utilise uniquement les 1 et les 0.

Les codes souples sont plus robustes parce que vous utilisez toutes les informations à votre disposition, mais ils sont aussi beaucoup plus compliqués à mettre en œuvre. Les codes de Hamming dont nous avons parlé utilisaient des décisions dures, alors que les codes convolutifs ont tendance à utiliser des décisions souples.

Limit de Shannon

La limite de Shannon ou capacité de Shannon est un incroyable élément de théorie qui nous indique combien de bits par seconde d’informations sans erreur nous pouvons envoyer :

\[C = B \cdot log_2 \left( 1 + \frac{S}{N} \right)\]

C - Capacité du canal [bits/sec]

B - Largeur de bande du canal [Hz].

S - Puissance moyenne du signal reçu [watts].

N - Puissance moyenne du bruit [watts].

Cette équation représente ce que tout schéma MCS peut faire de mieux lorsqu’il fonctionne à un rapport signal/bruit suffisamment élevé pour être exempt d’erreurs. Il est plus logique de représenter la limite en bits/sec/Hz, c’est-à-dire en bits/sec par quantité de spectre:

\[\frac{C}{B} = log_2 \left( 1 + \mathrm{SNR} \right)\]

avec SNR en termes linéaires (et non en dB). Cependant, lors de la représentation graphique, nous représentons généralement le SNR en dB pour des raisons de commodité:

Si vous voyez ailleurs des courbes de limites de Shannon qui ont l’air un peu différents, ils utilisent probablement un axe x en terme « d’énergie par bit » ou \(E_b/N_0\), qui est juste une alternative au SNR.

Il pourrait aider à simplifier les choses de réaliser que lorsque le SNR est assez élevé (par exemple, 10 dB ou plus), la limite de Shannon peut être approximée comme \(log_2 \left( \mathrm{SNR} \right)\), qui est approximativement \(\mathrm{SNR_{dB}}/3\) (expliqué ici [https://en.wikipedia.org/wiki/Shannon%E2%80%93Hartley_theorem#Bandwidth-limited_case]). Par exemple, avec un rapport signal à bruit de 24 dB, vous obtenez 8 bits/seconde/Hz, donc si vous avez 1 MHz à utiliser, cela représente 8 Mbps. Vous vous dites peut-être que ce n’est qu’une limite théorique, mais les communications modernes sont assez proches de cette limite, ce qui vous donne au moins une idée approximative. Vous pouvez toujours diviser ce chiffre par deux pour tenir compte de les champs additionnels dans les paquets/trames et d’un schéma MCS sous optimal.

Le débit maximal du WiFi 802.11n fonctionnant dans la bande 2.4 GHz (qui utilise des canaux de 20 MHz de large), suivant les spécifications, est de 300 Mbps. Il est évident que vous pourriez vous asseoir juste à côté de votre routeur et obtenir un rapport signal/bruit extrêmement élevé, peut-être 60 dB, mais pour être fiable/pratique, le débit maximal MCS (rappelez-vous la courbe en escalier ci-dessus) ne nécessitera probablement pas un rapport signal/bruit aussi élevé. Vous pouvez même jeter un coup d’oeil à la liste MCS pour 802.11n [https://en.wikipedia.org/wiki/IEEE_802.11n-2009#Data_rates]. 802.11n va jusqu’à 64-QAM, et combiné avec le codage de canal, il nécessite un SNR autour de 25 dB selon ce tableau [https://d2cpnw0u24fjm4.cloudfront.net/wp-content/uploads/802.11n-and-802.11ac-MCS-SNR-and-RSSI.pdf]. Cela signifie que, même avec un SNR de 60 dB, votre WiFi utilisera toujours la 64-QAM. Donc, à 25 dB, la limite de Shannon est d’environ 8.3 bits/sec/Hz, ce qui, compte tenu de 20 MHz de spectre, représente 166 Mbps. Cependant, si vous tenez compte de la technologie MIMO, que nous aborderons dans un prochain chapitre, vous pouvez obtenir quatre de ces flux en parallèle, ce qui donne 664 Mbps. En divisant ce chiffre par deux, vous obtenez un résultat très proche de la vitesse maximale annoncée de 300 Mbps pour le WiFi 802.11n dans la bande 2.4 GHz.

La preuve de la limite de Shannon est assez folle; elle implique des calculs qui ressemblent à ceci:

System Message: WARNING/2 (/home/vs/SPHINX/source/channel_coding.rst, line 155)

Cannot scale image! Could not get size from "../_images/shannon_limit_proof.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/shannon_limit_proof.png'

Pour plus d’informations, voir ici [https://en.wikipedia.org/wiki/Shannon%E2%80%93Hartley_theorem].

Codes de l’état de l’art

Actuellement, les meilleurs schémas de codage de canal sont :

Les turbo-codes, utilisés en 3G, 4G, et dans les vaisseaux spatiaux de la NASA.

Les codes LDPC, utilisés dans la DVB-S2, le WiMAX, l’IEEE 802.11n.

Ces deux codes s’approchent de la limite de Shannon (c’est-à-dire qu’ils l’atteignent presque sous certains SNR). Les codes de Hamming et d’autres codes plus simples sont loin d’atteindre la limite de Shannon. Du point de vue recherche, il n’y a plus beaucoup de possibilités d’amélioration des codes eux-mêmes. La recherche actuelle se concentre davantage sur l’amélioration de l’efficacité du décodage en termes de complexité et sur l’adaptation avec un canal retour.

Les codes à contrôle de parité à faible densité (LDPC pour Low Density Parity Check) sont une classe de codes en bloc linéaires très efficaces. Ils ont été introduits pour la première fois par Robert G. Gallager dans sa thèse de doctorat en 1960 au MIT. En raison de la complexité de leur mise en œuvre, ils ont été ignorés jusque dans les années 1990 ! À l’heure où nous écrivons ces lignes (2020), Gallager a 89 ans, est toujours en vie et a remporté de nombreux prix pour ses travaux (des décennies après les avoir réalisés). Le code LDPC n’est pas breveté et est donc libre d’utilisation (contrairement aux turbo-codes), c’est pourquoi il a été utilisé dans de nombreux protocoles ouverts.

Les turbo-codes sont basés sur les codes convolutifs. Il s’agit d’une classe de codes qui combine deux ou plusieurs codes convolutifs plus simples et un entrelaceur. La demande de brevet pour les turbo-codes a été déposée le 23 avril 1991. Les inventeurs étant français, et lorsque Qualcomm a voulu utiliser les turbo-codes dans le CDMA pour la 3G, elle a dû conclure un accord de licence payante avec France Télécom. Le brevet principal a expiré le 29 août 2013.

Modulation numérique

Dans ce chapitre, nous aborderons la transmission réelle de données à l’aide de la modulation numérique et des symboles sans fil! Nous concevrons des signaux qui transmettent des « informations », par exemple des 1 et des 0, en utilisant des schémas de modulation comme ASK, PSK, QAM et FSK. Nous aborderons également les diagrammes et constellations IQ et terminerons le chapitre par quelques exemples Python.

L’objectif principal de la modulation est de faire rentrer le maximum de données dans le moins de spectre possible. Techniquement parlant, nous voulons maximiser « l’efficacité spectrale » en unités bits/sec/Hz. Transmettre des 1 et des 0 plus rapidement augmentera la largeur de bande de notre signal (rappelons les propriétés de Fourier), ce qui signifie que davantage de spectre est utilisé. Nous allons également examiner d’autres techniques que la transmission plus rapide. Il y aura de nombreux compromis à faire pour décider de la façon de moduler, mais il y aura aussi de la place pour la créativité.

Symboles

Alerte au nouveau terme! Notre signal de transmission va être composé de « symboles ». Chaque symbole transporte un certain nombre de bits d’information, et nous transmettons des symboles les uns après les autres, par milliers, voire par millions.

Pour simplifier, disons que nous avons un fil et que nous envoyons des 1 et des 0 en utilisant des niveaux de tension élevés et faibles. Un symbole est l’un de ces 1 ou 0:

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 19)

Cannot scale image! Could not get size from "../_images/symbols.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/symbols.png'

Dans l’exemple ci-dessus, chaque symbole représente un bit. Comment pouvons-nous transmettre plus d’un bit par symbole? Étudions les signaux qui circulent dans les câbles Ethernet, définis par une norme IEEE appelée IEEE 802.3 1000BASE-T. Le mode de fonctionnement commun d’Ethernet utilise une modulation d’amplitude à 4 niveaux (2 bits par symbole) avec des symboles de 8 ns.

Prenez un moment pour essayer de répondre à ces questions :

Combien de bits par seconde sont transmis dans l’exemple ci-dessus ?

Combien de paires de ces fils de données seraient nécessaires pour transmettre 1 gigabit/seconde ?

Si un schéma de modulation comporte 16 niveaux différents, combien de bits par symbole cela représente-t-il ?

Avec 16 niveaux différents et des symboles de 8 ns, combien de bits par seconde cela représente-t-il ?

Réponses

250 Mbps - (1/8e-9)*2

Quatre (ce qui est le cas des câbles ethernet)

4 bits par symbole - log_2(16)

0,5 Gbps - (1/8e-9)*4

Symboles sans fils

Question : Pourquoi ne pouvons-nous pas transmettre directement le signal Ethernet illustré dans la figure ci-dessus? Il y a de nombreuses raisons, les deux plus importantes étant :

Les basses fréquences nécessitent des antennes énormes.

Les ondes carrées prennent une quantité excessive de spectre pour les bits par seconde - rappelez-vous du chapitre Domaine fréquentiel que les changements brusques dans le domaine temporel utilisent une grande quantité de bande/spectre :

Pour les signaux sans fil, nous commençons par une porteuse, qui est juste une sinusoïde. Par exemple, la radio FM utilise une porteuse comme 101,1 MHz ou 100,3 MHz. Nous modulons cette porteuse d’une manière ou d’une autre (il y en a plusieurs). Pour la radio FM, il s’agit d’une modulation analogique, et non numérique, mais c’est le même concept que la modulation numérique.

De quelles manières pouvons-nous moduler la porteuse? Autre façon de poser la même question: quelles sont les différentes propriétés d’une sinusoïde?

Amplitude

Phase

Fréquence

Nous pouvons moduler nos données sur une porteuse en modifiant l’un (ou plusieurs) de ces trois éléments.

Modulation par déplacement d’amplitude

Modulation par déplacement d’amplitude (ou ASK en anglais pour Amplitude Shift Keying) est le premier schéma de modulation numérique que nous allons aborder car la modulation d’amplitude est la plus simple à visualiser parmi les trois propriétés des sinusoïdes. Nous modulons littéralement l” amplitude de la porteuse. Voici un exemple de modulation par déplacement d’amplitude à deux niveaux, appelé 2-ASK:

Notez que la valeur moyenne est égale à zéro, ce que nous préférons dans la mesure du possible.

Nous pouvons utiliser plus de deux niveaux, ce qui permet d’avoir plus de bits par symbole. L’exemple ci-dessous montre un exemple de 4-ASK. Dans ce cas, chaque symbole porte 2 bits d’information.

Question: Combien de symboles sont représentés dans l’extrait de signal ci-dessus? Combien de bits sont représentés au total?

Réponse

20 symboles, donc 40 bits d’information

Comment créer réellement ce signal numériquement, par le biais d’un code? Tout ce que nous avons à faire est de créer un vecteur avec N échantillons par symbole, puis de multiplier ce vecteur par une sinusoïde. Cela module le signal sur une porteuse (la sinusoïde agit comme cette porteuse). L’exemple ci-dessous montre un 2-ASK avec 10 échantillons par symbole.

Le graphique du haut montre les échantillons discrets représentés par des points rouges, c’est-à-dire notre signal numérique. Le graphique du bas montre à quoi ressemble le signal modulé résultant, qui pourrait être transmis par voie aérienne. Dans les systèmes réels, la fréquence de la porteuse est généralement beaucoup plus élevée que la vitesse à laquelle les symboles changent. Dans cet exemple, il n’y a que trois cycles de la sinusoïde dans chaque symbole, mais dans la pratique, il peut y en avoir des milliers, en fonction de la hauteur du spectre dans lequel le signal est transmis.

Modulation par déplacement de phase

Envisageons maintenant de moduler la phase de la même manière que nous l’avons fait pour l’amplitude. La forme la plus simple est la modulation PSK binaire, aussi appelée BPSK, où il y a deux niveaux de phase:

Pas de changement de phase

Changement de phase à 180 degrés

Exemple de BPSK (notez les changements de phase):

Ce n’est pas très amusant de regarder des graphiques comme celui-ci:

Au lieu de cela, nous représentons habituellement la phase dans le plan complexe.

Diagrammes IQ/Constellations

Vous avez déjà vu des diagrammes IQ dans la sous-section sur les nombres complexes du chapitre échantillonnage IQ, mais maintenant nous allons les utiliser d’une manière nouvelle et amusante. Pour un symbole donné, nous pouvons montrer l’amplitude et la phase sur un diagramme IQ. Pour l’exemple BPSK, nous avons dit que nous avions des phases de 0 et 180 degrés. Traçons ces deux points sur le diagramme IQ. Nous supposerons que l’amplitude est de 1. Dans la pratique, l’amplitude utilisée n’a pas vraiment d’importance; une valeur plus élevée signifie un signal plus puissant, mais vous pouvez également augmenter le gain de l’amplificateur.

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 141)

Cannot scale image! Could not get size from "../_images/bpsk_iq.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/bpsk_iq.png'

Le diagramme IQ ci-dessus montre ce que nous allons transmettre, ou plutôt l’ensemble des symboles à partir desquels nous allons transmettre. Il ne montre pas la porteuse, vous pouvez donc considérer qu’il représente les symboles en bande de base. Lorsque nous montrons l’ensemble des symboles possibles pour un schéma de modulation donné, nous l’appelons la « constellation ». De nombreux schémas de modulation peuvent être définis par leur constellation.

Pour recevoir et décoder la BPSK, nous pouvons utiliser l’échantillonnage IQ, comme nous l’avons appris au chapitre précédent, et examiner où les points aboutissent sur le diagramme IQ. Cependant, il y aura une rotation de phase aléatoire due au canal sans fil car le signal aura un certain retard aléatoire lorsqu’il passe dans l’air entre les antennes. La rotation de phase aléatoire peut être inversée à l’aide de diverses méthodes que nous verrons plus tard. Voici un exemple de différentes façons dont un signal BPSK peut apparaître dans le récepteur (sans tenir compte du bruit) :

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 149)

Cannot scale image! Could not get size from "../_images/bpsk3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/bpsk3.png'

Retour au PSK. Et si nous voulions quatre niveaux de phase différents? C’est-à-dire 0, 90, 180 et 270 degrés. Dans ce cas, cela serait représenté comme suit sur le diagramme IQ, et cela forme un schéma de modulation que nous appelons modulation par déplacement de phase en quadrature (ou QPSK en anglais pour Quadrature Phase Shift Keying):

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 155)

Cannot scale image! Could not get size from "../_images/qpsk.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/qpsk.png'

Pour la PSK, nous avons toujours N phases différentes, également espacées sur 360 degrés pour obtenir les meilleurs résultats. Nous montrons souvent le cercle unitaire pour souligner que tous les points ont la même magnitude:

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 161)

Cannot scale image! Could not get size from "../_images/psk_set.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/psk_set.png'

Question: Qu’y a-t-il de mal à utiliser un schéma PSK comme celui de l’image ci-dessous? S’agit-il d’un schéma de modulation PSK valide?

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 167)

Cannot scale image! Could not get size from "../_images/weird_psk.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/weird_psk.png'

Réponse

Il n’y a rien d’invalide dans ce schéma PSK. Vous pouvez certainement l’utiliser, mais, comme les symboles ne sont pas uniformément espacés, ce schéma n’est pas aussi efficace qu’il pourrait l’être. L’efficacité du schéma deviendra claire lorsque nous aborderons l’impact du bruit sur nos symboles. En résumé, nous voulons laisser le plus d’espace possible entre les symboles, au cas où il y aurait du bruit, afin qu’un symbole ne soit pas interprété par le récepteur comme l’un des autres symboles (incorrects). Nous ne voulons pas qu’un 0 soit reçu comme un 1.

Revenons un instant sur ASK. Notez que nous pouvons montrer l’ASK sur le diagramme IQ tout comme pour la PSK. Voici le tracé IQ de 2-ASK, 4-ASK, et 8-ASK, dans la configuration bipolaire, ainsi que 2-ASK et 4-ASK dans la configuration unipolaire.

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 184)

Cannot scale image! Could not get size from "../_images/ask_set.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/ask_set.png'

Comme vous l’avez peut-être remarqué, 2-ASK bipolaire et BPSK sont identiques. Un déphasage de 180 degrés équivaut à multiplier la sinusoïde par -1. Nous l’appelons BPSK, probablement parce que la BPSK est beaucoup plus utilisée que l’ASK.

Modulation d’amplitude en quadrature

Et si nous combinions ASK et PSK? Nous appelons ce schéma de modulation modulation d’amplitude en quadrature (ou QAM en anglais pour Quadrature Amplitude Modulation ). La QAM ressemble généralement à ceci :

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 195)

Cannot scale image! Could not get size from "../_images/64qam.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/64qam.png'

Voici d’autres exemples de QAM:

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 201)

Cannot scale image! Could not get size from "../_images/qam.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/qam.png'

Pour un schéma de modulation QAM, nous pouvons techniquement placer des points où nous le souhaitons sur le diagramme IQ puisque la phase et l’amplitude sont modulées. Les « paramètres » d’un schéma QAM donné sont mieux définis en montrant la constellation QAM. Vous pouvez également indiquer les valeurs I et Q pour chaque point, comme ci-dessous pour la QPSK :

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 207)

Cannot scale image! Could not get size from "../_images/qpsk_list.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/qpsk_list.png'

Notez que la plupart des schémas de modulation, à l’exception des diverses ASK et BPSK, sont assez difficiles à « voir » dans le domaine temporel. Pour prouver mon propos, voici un exemple de QAM dans le domaine temporel. Pouvez-vous distinguer la phase de chaque symbole dans l’image ci-dessous ? C’est difficile.

System Message: WARNING/2 (/home/vs/SPHINX/source/digital_modulation.rst, line 213)

Cannot scale image! Could not get size from "../_images/qam_time_domain.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/qam_time_domain.png'

Étant donné la difficulté de discerner les schémas de modulation dans le domaine temporel, nous préférons utiliser des diagrammes IQ plutôt que d’afficher le signal dans le domaine temporel. Nous pouvons néanmoins montrer le signal dans le domaine temporel s’il y a une certaine structure de paquets ou si la séquence des symboles est importante.

Modulation par déplacement de fréquence

La dernière sur la liste est la modulation par déplacement de fréquence (ou FSK en anglais pour Frequency Shift Keying). La FSK est assez simple à comprendre: nous nous déplaçons simplement entre N fréquences, chaque fréquence représentant un symbole possible. Cependant, comme nous modulons une porteuse, il s’agit en fait de notre fréquence porteuse +/- ces N fréquences. Par exemple, nous pourrions être sur une porteuse de 1.2 GHz et nous déplacer entre ces quatre fréquences :

1.2005 GHz

1.2010 GHz

1.1995 GHz

1.1990 GHz

L’exemple ci-dessus serait une 4-FSK, et il y aurait deux bits par symbole. Un signal 4-FSK dans le domaine fréquenciel pourrait ressembler à ceci :

Si vous utilisez le FSK, vous devez vous poser une question essentielle: Quel doit être l’espacement entre les fréquences? Nous désignons souvent cet espacement par \(\Delta f\) en Hz. Nous voulons éviter le chevauchement dans le domaine des fréquences, donc \(\Delta f\) doit être suffisamment grand. La largeur de chaque porteuse en fréquence est fonction de notre débit de symboles. Plus de symboles par seconde signifie des symboles plus courts, ce qui signifie une largeur de bande plus large (rappelez-vous la relation inverse entre l’échelle de temps et de fréquence). Plus nous transmettons de symboles, plus chaque porteuse sera large, et par conséquent plus nous devrons augmenter \(\Delta f\) pour éviter le chevauchement des porteuses. Nous n’entrerons pas dans les détails de la conception de la FSK dans ce manuel.

Les diagrammes IQ ne peuvent pas être utilisés pour montrer des fréquences différentes. Ils montrent la magnitude et la phase. Bien qu’il soit possible de représenter la FSK dans le domaine temporel, il est difficile de distinguer les symboles s’il y a plus de deux fréquences :

En passant, notez que la radio FM utilise la modulation de fréquence (FM) qui est comme une version analogique de la FSK. Au lieu d’avoir des fréquences discrètes entre lesquelles nous sautons, la radio FM utilise un signal audio continu pour moduler la fréquence de la porteuse. Vous trouverez ci-dessous un exemple de modulation FM et AM où le « signal » en haut est le signal audio modulé sur la porteuse.

Dans ce manuel, nous nous intéressons principalement aux formes numériques de modulation.

Codage différentiel

Dans de nombreux protocoles de communication sans fil (et filaires), vous êtes susceptible de rencontrer ce que l’on appelle le codage différentiel. Pour démontrer son utilité, considérons la réception d’un signal BPSK. Lorsque le signal se déplace dans l’air, il subit un retard aléatoire entre l’émetteur et le récepteur, ce qui entraîne une rotation aléatoire de la constellation, comme nous l’avons mentionné précédemment. Lorsque le récepteur se synchronise sur ce signal et aligne la BPSK sur l’axe « I », il n’a aucun moyen de savoir si elle est déphasée de 180 degrés ou non, car la constellation a la même apparence. Ainsi, au lieu de devoir envoyer des symboles pilotes pour lui faire savoir quelle groupe représente 1 et quelle groupe représente 0, il peut choisir d’utiliser le codage différentiel et ne pas s’en soucier. L’utilisation du codage différentiel nous permet également d’utiliser un récepteur non cohérent qui est plus simple que les récepteurs cohérents.

Dans sa forme la plus élémentaire, qui est celle utilisée pour la BPSK, le codage différentiel consiste à transmettre un 0 lorsque le bit d’entrée est le même que le bit de sortie précédent, et à transmettre un 1 lorsqu’ils diffèrent. Nous transmettons donc toujours le même nombre de bits (sauf qu’un bit supplémentaire est nécessaire au début pour commencer la séquence de sortie), mais nous n’avons plus à nous soucier de l’ambiguïté de phase de 180 degrés. Pour démontrer comment cela fonctionne, considérons la transmission de la séquence de bits [1, 1, 0, 0, 0, 1, 0] en utilisant la BPSK. Supposons que nous commencions la séquence de sortie par 1; en fait, peu importe que vous utilisiez 1 ou 0. Après avoir appliqué le codage différentiel, nous transmettrions finalement [1, 0, 1, 1, 1, 1, 0, 0]. Les 1 et les 0 sont toujours associés aux symboles positifs et négatifs dont nous avons parlé précédemment. Il est peut-être plus facile de visualiser les séquences d’entrée et de sortie empilées comme ceci:

[1,  1,  0,  0,  0,  1,  0]   # avant codage différentiel (données originales)
[1,  0,  1,  1,  1,  1,  0,  0]  # après le codage différentiel (ce que nous transmettons)

Le gros inconvénient de l’utilisation du codage différentiel est que si vous avez une erreur de bit, cela entraînera deux erreurs de bit. L’alternative à l’utilisation du codage différentiel pour la BPSK est d’ajouter périodiquement des symboles pilotes, qui sont des symboles déjà connus du récepteur, et celui-ci peut utiliser les valeurs connues pour non seulement déterminer quel cluster est 1 et lequel est 0, mais aussi inverser les trajets multiples causés par le canal. Un problème avec les symboles pilotes est que le canal sans fil peut changer très rapidement, de l’ordre de dizaines ou de centaines de symboles s’il s’agit d’un récepteur et/ou d’un émetteur en mouvement, de sorte qu’il faudrait des symboles pilotes suffisamment fréquents pour refléter l’évolution du canal. Ainsi, si un protocole sans fil met l’accent sur la réduction de la complexité du récepteur, comme le RDS que nous étudions dans le chapitre Exemple bout en bout, il peut choisir d’utiliser le codage différentiel.

Exemple Python

À titre d’exemple Python, générons une QPSK en bande de base et traçons la constellation.

Bien que nous puissions générer les symboles complexes directement, partons du principe que la QPSK possède quatre symboles à des intervalles de 90 degrés autour du cercle unitaire. Nous utiliserons 45, 135, 225 et 315 degrés pour nos points. Tout d’abord, nous allons générer des nombres aléatoires entre 0 et 3 et effectuer des calculs pour obtenir les degrés souhaités avant de les convertir en radians.

import numpy as np
import matplotlib.pyplot as plt

num_symbols = 1000

x_int = np.random.randint(0, 4, num_symbols) # 0 à 3
x_degrees = x_int*360/4.0 + 45 # 45, 135, 225, 315 degrés
x_radians = x_degrees*np.pi/180.0 # sin() et cos() sont pris en radians
x_symbols = np.cos(x_radians) + 1j*np.sin(x_radians) # ceci produit nos symboles complexes QPSK
plt.plot(np.real(x_symbols), np.imag(x_symbols), '.')
plt.grid(True)
plt.show()

Observez comment tous les symboles que nous avons générés se chevauchent. Comme il n’y a pas de bruit, les symboles ont tous la même valeur. Ajoutons un peu de bruit:

n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # bruit addifitve blanc Gaussien avec une puissance unitaire
noise_power = 0.01
r = x_symbols + n * np.sqrt(noise_power)
plt.plot(np.real(r), np.imag(r), '.')
plt.grid(True)
plt.show()

Considérez comment le bruit blanc gaussien additif (ou AWGN en anglais pour additive white Gaussian Noise) produit un étalement uniforme autour de chaque point de la constellation. S’il y a trop de bruit, les symboles commencent à passer la limite (les quatre quadrants) et seront interprétés par le récepteur comme un symbole incorrect. Essayez d’augmenter noise_power jusqu’à ce que cela se produise.

Pour ceux qui souhaitent simuler le bruit de phase, qui pourrait résulter de la gigue de phase dans l’oscillateur local (LO), remplacez le r par :

phase_noise = np.random.randn(len(x_symbols)) * 0.1 # ajuster le multiplicateur pour la "force" du bruit de phase
r = x_symbols * np.exp(1j*phase_noise)

Vous pourriez même combiner le bruit de phase avec l’AWGN pour obtenir l’expérience complète:

Nous allons nous arrêter à ce point. Si nous voulions voir à quoi ressemble le signal QPSK dans le domaine temporel, nous devrions générer plusieurs échantillons par symbole (dans cet exercice, nous avons juste fait un échantillon par symbole). Vous apprendrez pourquoi vous devez générer plusieurs échantillons par symbole lorsque nous aborderons la mise en forme des impulsions. L’exercice Python du chapitre Mise en Forme reprendra là où nous nous sommes arrêtés ici.

Filtres

Dans ce chapitre, nous nous familiarisons avec les filtres numériques en utilisant Python. Nous couvrons les types de filtres (FIR/IIR et passe-bas/passe-haut/passe-bande/coupe-bande), comment les filtres sont représentés numériquement et comment ils sont conçus. Nous terminons par une introduction aux filtres de mise en forme, que nous approfondissons dans le chapitre Mise en Forme.

Principes de Base des Filtres

Les filtres sont utilisés dans de nombreuses disciplines. Par exemple, le traitement des images fait un usage intensif des filtres 2D, où l’entrée et la sortie sont des images. Vous utilisez peut-être un filtre chaque matin pour faire votre café, qui filtre les solides du liquide. En DSP, les filtres sont principalement utilisés pour :

Séparation des signaux qui ont été combinés (par exemple, extraction du signal souhaité).

Suppression de l’excès de bruit après la réception d’un signal

Restauration des signaux qui ont été déformés d’une manière ou d’une autre (par exemple, un égaliseur audio est un filtre).

Il existe certainement d’autres utilisations des filtres, mais ce chapitre a pour but d’introduire le concept plutôt que d’expliquer toutes les façons dont le filtrage peut se produire.

Vous pouvez penser que nous ne nous intéressons qu’aux filtres numériques; ce manuel explore le DSP, après tout. Cependant, il est important de savoir que de nombreux filtres seront analogiques, comme ceux de nos SDR placés avant le convertisseur analogique-numérique (CAN) du côté réception. L’image suivante juxtapose le schéma d’un circuit de filtrage analogique et la représentation sous forme d’organigramme d’un algorithme de filtrage numérique.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 24)

Cannot scale image! Could not get size from "../_images/analog_digital_filter.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/analog_digital_filter.png'

En DSP, où l’entrée et la sortie sont des signaux, un filtre a un signal en entrée et un signal en sortie :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 30)

Cannot scale image! Could not get size from "../_images/filter.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter.png'

Vous ne pouvez pas introduire deux signaux différents dans un seul filtre sans les additionner au préalable ou effectuer une autre opération. De même, la sortie sera toujours un seul signal, c’est-à-dire un tableau 1D de nombres.

Il existe quatre types de filtres de base :passe-bas, passe-haut, passe-bande et coupe-bande. Chaque type modifie les signaux pour se concentrer sur les différentes plages de fréquences qu’ils contiennent. Les graphiques ci-dessous montrent comment les fréquences des signaux sont filtrées pour chaque type.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 38)

Cannot scale image! Could not get size from "../_images/filter_types.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_types.png'

(AJOUTER LE DIAGRAMME MONTRANT LES FRÉQUENCES NÉGATIVES AUSSI)

Chaque filtre permet à certaines fréquences de rester dans un signal tout en bloquant d’autres fréquences. La gamme de fréquences qu’un filtre laisse passer est appelée « bande passante », et le « bande rejetée » fait référence à ce qui est bloqué. Dans le cas du filtre passe-bas, il laisse passer les basses fréquences et arrête les hautes fréquences, de sorte que 0 Hz sera toujours dans la bande passante. Pour un filtre passe-haut et un filtre passe-bande, 0 Hz sera toujours dans la bande coupée.

Ne confondez pas ces types de filtrage avec la mise en œuvre algorithmique du filtre (par exemple, IIR vs FIR). Le type le plus courant est de loin le filtre passe-bas (LPF pour Low Pass Filter en anglais) car nous représentons souvent des signaux en bande de base. Le LPF nous permet de filtrer tout ce qui se trouve « autour » de notre signal, en éliminant le bruit excessif et les autres signaux.

Représentation des Filtres

Pour la plupart des filtres que nous verrons (connus sous le nom de filtres FIR, pour Finite Impulse Response en anglais, ou filtres à réponse impulsionnelle finie en français), nous pouvons représenter le filtre lui-même avec un seul tableau de flottants. Pour les filtres symétriques dans le domaine fréquentiel, ces flottants seront réels (par opposition à complexes), et leur nombre tend à être impair. Nous appelons ce tableau de flottants les coéfficients du filtre ou usuellement taps en anglais. Nous utilisons souvent \(h\) comme symbole pour les taps du filtre. Voici un exemple d’un ensemble de taps qui définissent un filtre :

h =  [ 9.92977939e-04  1.08410297e-03  8.51595307e-04  1.64604862e-04
 -1.01714338e-03 -2.46268845e-03 -3.58236429e-03 -3.55412543e-03
 -1.68583512e-03  2.10562324e-03  6.93100252e-03  1.09302641e-02
  1.17766532e-02  7.60955496e-03 -1.90555639e-03 -1.48306750e-02
 -2.69313236e-02 -3.25659606e-02 -2.63400086e-02 -5.04184562e-03
  3.08099470e-02  7.64264738e-02  1.23536693e-01  1.62377258e-01
  1.84320776e-01  1.84320776e-01  1.62377258e-01  1.23536693e-01
  7.64264738e-02  3.08099470e-02 -5.04184562e-03 -2.63400086e-02
 -3.25659606e-02 -2.69313236e-02 -1.48306750e-02 -1.90555639e-03
  7.60955496e-03  1.17766532e-02  1.09302641e-02  6.93100252e-03
  2.10562324e-03 -1.68583512e-03 -3.55412543e-03 -3.58236429e-03
 -2.46268845e-03 -1.01714338e-03  1.64604862e-04  8.51595307e-04
  1.08410297e-03  9.92977939e-04]

Exemple de cas d’utilisation

Pour comprendre comment les filtres sont utilisés, prenons un exemple où nous accordons notre radio logicielle sur la fréquence d’un signal existant, et nous voulons l’isoler des autres signaux. Rappelez-vous que nous indiquons à notre SDR la fréquence à laquelle il doit s’accorder, mais que les échantillons capturés par le SDR sont en bande de base, ce qui signifie que le signal s’affichera comme centré autour de 0 Hz. Nous devrons garder la trace de la fréquence sur laquelle nous avons demandé au SDR de s’accorder. Voici ce que nous pourrions recevoir :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 76)

Cannot scale image! Could not get size from "../_images/filter_use_case.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_use_case.png'

Comme notre signal est déjà centré sur le courant continu DC (0 Hz), nous savons que nous voulons un filtre passe-bas. Nous devons choisir une « fréquence de coupure » (aussi appelée fréquence d’angle), qui déterminera le moment où la bande passante passe en bande rejetée. La fréquence de coupure sera toujours exprimée en Hz. Dans cet exemple, 3 kHz semble être une bonne valeur :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 82)

Cannot scale image! Could not get size from "../_images/filter_use_case2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_use_case2.png'

Cependant, dans la plupart des filtres passe-bas, la limite de fréquence négative sera également de -3 kHz. C’est-à-dire qu’elle est symétrique autour du DC (vous verrez plus tard pourquoi). Nos fréquences de coupure ressembleront à ceci (la bande passante est la zone intermédiaire) :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 88)

Cannot scale image! Could not get size from "../_images/filter_use_case3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_use_case3.png'

Après avoir créé et appliqué le filtre avec une fréquence de coupure de 3 kHz, nous avons maintenant :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 94)

Cannot scale image! Could not get size from "../_images/filter_use_case4.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_use_case4.png'

Ce signal filtré peut sembler déroutant jusqu’à ce que vous vous rappeliez que notre plancher de bruit était à la ligne verte autour de -65 dB. Même si nous pouvons toujours voir le signal parasite centré à 10 kHz, nous avons sévèrement diminué la puissance de ce signal. Elle est maintenant inférieure à celle du plancher de bruit! Nous avons également éliminé la plupart du bruit qui existait dans la bande rejetée.

En plus de la fréquence de coupure, l’autre paramètre principal de notre filtre passe-bas est appelé « largeur de transition ». La largeur de transition, également mesurée en Hz, indique au filtre à quelle vitesse il doit passer de la bande passante à la bande rejetée, car une transition instantanée est impossible en pratique.

Visualisons la largeur de transition. Dans le diagramme ci-dessous, la ligne :green:`verte` représente la réponse idéale pour la transition entre une bande passante et une bande d’arrêt, qui a essentiellement une largeur de transition de zéro. La ligne :red:`rouge` montre le résultat d’un filtre réaliste, qui présente une certaine ondulation et une certaine largeur de transition.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 104)

Cannot scale image! Could not get size from "../_images/realistic_filter.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/realistic_filter.png'

Vous vous demandez peut-être pourquoi nous n’avons pas simplement défini la largeur de transition la plus petite possible. La raison principale est qu’une largeur de transition plus petite entraîne un plus grand nombre de taps, et plus de taps signifie plus de calculs - nous verrons pourquoi sous peu. Un filtre de 50 taps peut fonctionner toute la journée en utilisant 1% du CPU d’un Raspberry Pi. En revanche, un filtre à 50 000 prises fera exploser votre CPU ! En général, nous utilisons un outil de conception de filtre, puis nous voyons combien de taps il produit, et si c’est beaucoup trop (par exemple, plus de 100), nous augmentons la largeur de transition. Tout dépend de l’application et du matériel qui exécute le filtre, bien sûr.

Dans l’exemple de filtrage ci-dessus, nous avions utilisé une coupure de 3 kHz et une largeur de transition de 1 kHz (il est difficile de voir la largeur de transition en regardant ces captures d’écran). Le filtre résultant a 77 taps.

Revenons à la représentation des filtres. Même si nous pouvons montrer la liste des taps d’un filtre, nous représentons généralement les filtres visuellement dans le domaine fréquentiel. Nous appelons cela la « réponse fréquencielle » du filtre, et elle nous montre le comportement du filtre en fréquence. Voici la réponse en fréquence du filtre que nous venons d’utiliser :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 115)

Cannot scale image! Could not get size from "../_images/filter_use_case5.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_use_case5.png'

Notez que ce que je montre ici n’est pas un signal - c’est juste la représentation du filtre dans le domaine fréquenciel. Cela peut être un peu difficile à comprendre au début, mais au fur et à mesure des exemples et du code, cela deviendra plus clair.

Un filtre donné a également une représentation dans le domaine temporel; on l’appelle la « réponse impulsionnelle » du filtre car c’est ce que vous voyez dans le domaine temporel si vous prenez une impulsion et la faites passer par le filtre. (Cherche sur Google « fonction delta de Dirac » pour plus d’informations sur ce qu’est une impulsion). Pour un filtre de type FIR, la réponse impulsionnelle est simplement les taps eux-mêmes. Pour le filtre à 77 taps que nous avons utilisé précédemment, les prises sont les suivantes:

h =  [-0.00025604525581002235, 0.00013669139298144728, 0.0005385575350373983,
0.0008378280326724052, 0.000906112720258534, 0.0006353431381285191,
-9.884083502996931e-19, -0.0008822851814329624, -0.0017323142383247614,
-0.0021665366366505623, -0.0018335371278226376, -0.0005912294145673513,
0.001349081052467227, 0.0033936649560928345, 0.004703888203948736,
0.004488115198910236, 0.0023609865456819534, -0.0013707970501855016,
-0.00564080523326993, -0.008859002031385899, -0.009428252466022968,
-0.006394983734935522, 4.76480351940553e-18, 0.008114570751786232,
0.015200719237327576, 0.018197273835539818, 0.01482443418353796,
0.004636279307305813, -0.010356673039495945, -0.025791890919208527,
-0.03587324544787407, -0.034922562539577484, -0.019146423786878586,
0.011919975280761719, 0.05478153005242348, 0.10243935883045197,
0.1458890736103058, 0.1762896478176117, 0.18720689415931702,
0.1762896478176117, 0.1458890736103058, 0.10243935883045197,
0.05478153005242348, 0.011919975280761719, -0.019146423786878586,
-0.034922562539577484, -0.03587324544787407, -0.025791890919208527,
-0.010356673039495945, 0.004636279307305813, 0.01482443418353796,
0.018197273835539818, 0.015200719237327576, 0.008114570751786232,
4.76480351940553e-18, -0.006394983734935522, -0.009428252466022968,
-0.008859002031385899, -0.00564080523326993, -0.0013707970501855016,
0.0023609865456819534, 0.004488115198910236, 0.004703888203948736,
0.0033936649560928345, 0.001349081052467227, -0.0005912294145673513,
-0.0018335371278226376, -0.0021665366366505623, -0.0017323142383247614,
-0.0008822851814329624, -9.884083502996931e-19, 0.0006353431381285191,
0.000906112720258534, 0.0008378280326724052, 0.0005385575350373983,
0.00013669139298144728, -0.00025604525581002235]

Et même si nous n’avons pas encore abordé la conception des filtres, voici le code Python qui a généré ce filtre:

import numpy as np
from scipy import signal
import matplotlib.pyplot as plt

num_taps = 51 # Il est utile d'utiliser un nombre impair de robinets.
cut_off = 3000 # Hz
sample_rate = 32000 # Hz

# créer notre filtre passe-bas
h = signal.firwin(num_taps, cut_off, fs=sample_rate)

# tracer la réponse impulsionnelle
plt.plot(h, '.-')
plt.show()

Le simple fait de tracer ce tableau de flottants nous donne la réponse impulsionnelle du filtre:

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 173)

Cannot scale image! Could not get size from "../_images/impulse_response.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/impulse_response.png'

Et voici le code qui a été utilisé pour produire la réponse fréquentielle, présentée plus tôt. C’est un peu plus compliqué car nous devons créer le tableau des fréquences sur l’axe des x.

# tracer la réponse en fréquence
H = np.abs(np.fft.fft(h, 1024)) # prendre la FFT 1024 points et la magnitude
H = np.fft.fftshift(H) # centrer à 0 Hz
w = np.linspace(-sample_rate/2, sample_rate/2, len(H)) # axe des x
plt.plot(w, H, '.-')
plt.show()

Filtres Réels et Complexes

Le filtre que je vous ai montré avait des taps réelles, mais les taps peuvent aussi être complexes. Le fait que les taps soient réelles ou complexes ne doit pas nécessairement correspondre au signal que vous faites passer par le filtre, c’est-à-dire que vous pouvez faire passer un signal complexe par un filtre avec des taps réelles et vice versa. Lorsque les taps sont réelles, la réponse en fréquence du filtre sera symétrique autour du DC (0 Hz). En général, nous utilisons des prises complexes lorsque nous avons besoin d’asymétrie, ce qui arrive très rarement.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 193)

Cannot scale image! Could not get size from "../_images/complex_taps.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/complex_taps.png'

Pour illustrer les prises complexes, revenons au cas d’utilisation du filtrage, sauf que cette fois, nous voulons recevoir l’autre signal parasite (sans avoir à réaccorder la radio). Cela signifie que nous voulons un filtre passe-bande, mais pas un filtre symétrique. Nous voulons seulement garder (c’est-à-dire « passer ») les fréquences entre environ 7 kHz et 13 kHz (nous ne voulons pas passer également de -13 kHz à -7 kHz) :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 199)

Cannot scale image! Could not get size from "../_images/filter_use_case6.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_use_case6.png'

Une façon de concevoir ce type de filtre est de réaliser un filtre passe-bas avec une coupure de 3 kHz, puis de le décaler en fréquence. Rappelez-vous que nous pouvons décaler la fréquence de x(t) (domaine temporel) en la multipliant par \(e^{j2\pi f_0t}\). Dans ce cas, \(f_0\) devrait être 10 kHz, ce qui décale notre filtre de 10 kHz. Rappelez-vous que dans notre code Python ci-dessus, \(h\) était les taps du filtre passe-bas. Afin de créer notre filtre passe-bande, il suffit de multiplier ces prises par \(e^{j2\pi f_0t}\), bien que cela implique la création d’un vecteur pour représenter le temps basé sur notre période d’échantillonnage (inverse de la fréquence d’échantillonnage) :

# (h a été trouvé en utilisant le premier extrait de code)

# Décaler le filtre en fréquence en multipliant par exp(j*2*pi*f0*t)
f0 = 10e3 # le montant que nous allons transférer
Ts = 1.0/sample_rate # période de l'échantillon
t = np.arange(0.0, Ts*len(h), Ts) # vecteur temps. les arguments sont (début, fin, pas)
exponential = np.exp(2j*np.pi*f0*t) # il s'agit essentiellement d'une onde sinusoïdale complexe

h_band_pass = h * exponential # faire le décallage

# tracer la réponse impulsionnelle
plt.figure('impulsion')
plt.plot(np.real(h_band_pass), '.-')
plt.plot(np.imag(h_band_pass), '.-')
plt.legend(['reél', 'imag'], loc=1)

# tracer la réponse en fréquence
H = np.abs(np.fft.fft(h_band_pass, 1024)) # prendre la FFT 1024 points et l'amplitude
H = np.fft.fftshift(H) # faire 0 Hz au centre
w = np.linspace(-sample_rate/2, sample_rate/2, len(H)) # axes des x
plt.figure('freq')
plt.plot(w, H, '.-')
plt.xlabel('Fréquence [Hz]')
plt.show()

The plots of the impulse response and frequency response are shown below:

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 234)

Cannot scale image! Could not get size from "../_images/shifted_filter.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/shifted_filter.png'

Comme notre filtre n’est pas symétrique autour de 0 Hz, il doit utiliser des taps complexes. Nous avons donc besoin de deux lignes pour tracer ces taps complexes. Ce que nous voyons dans le graphique de gauche ci-dessus est toujours la réponse impulsionnelle. Notre courbe de réponse fréquencielle est ce qui valide réellement le fait que nous avons créé le type de filtre que nous espérions, où il filtrera tout sauf le signal centré autour de 10 kHz. Une fois encore, n’oubliez pas que le tracé ci-dessus n’est pas un signal réel: il s’agit simplement d’une représentation du filtre. Cela peut être très déroutant à comprendre, car lorsque vous appliquez le filtre au signal et que vous tracez la sortie dans le domaine fréquentiel, dans de nombreux cas, elle aura à peu près la même apparence que la réponse en fréquence du filtre lui-même.

Si cette sous-section a ajouté à la confusion, ne vous inquiétez pas, dans 99% des cas, vous aurez affaire à de simples filtres passe-bas avec des taps réelles de toute façon.

Implémentation des Filtres

Nous n’allons pas nous plonger trop profondément dans l’implémentation des filtres. Je me concentre plutôt sur la conception des filtres (de toute façon, vous pouvez trouver des implémentations prêtes à l’emploi dans n’importe quel langage de programmation). Pour l’instant, voici ce qu’il faut retenir: pour filtrer un signal avec un filtre FIR, il suffit de convoluer la réponse impulsionnelle (le vecteur de taps) avec le signal d’entrée. (Ne vous inquiétez pas, une section ultérieure explique la convolution.) Dans le monde discret, nous utilisons une convolution discrète (exemple ci-dessous). Les triangles labelisés par des b sont les taps. Dans le schéma, les carrés labelisés \(z^{-1}\) au-dessus des triangles signifient qu’il faut retarder d’un pas de temps.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 248)

Cannot scale image! Could not get size from "../_images/discrete_convolution.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/discrete_convolution.png'

Vous pouvez peut-être comprendre pourquoi nous les appelons maintenant des « taps » (robinet en anglais) de filtre, compte tenu de la façon dont le filtre lui-même est mis en œuvre.

FIR vs IIR

Il existe deux grandes classes de filtres numériques: FIR et IIR 1. Réponse impulsionnelle finie (FIR pour Finite Impulse Response en anglais) 2. Réponse impulsionnelle infinie (IIR pour InFinite Impulse Response en anglais)

Nous n’entrerons pas trop dans la théorie, pour l’instant, souvenez-vous que Les filtres FIR sont plus faciles à concevoir et peuvent faire tout ce que vous voulez si vous utilisez suffisamment de taps. Les filtres IIR en revanche sont plus compliqués et peuvent être instables, mais ils sont plus efficaces (ils utilisent moins de CPU et de mémoire pour un filtre donné). Si quelqu’un vous donne une liste de taps, on suppose qu’il s’agit de taps pour un filtre FIR. S’il commence à mentionner des « pôles », il s’agit de filtres IIR. Nous nous en tiendrons aux filtres FIR dans ce manuel.

Vous trouverez ci-dessous un exemple de réponse fréquencielle, comparant un filtre FIR et un filtre IIR qui effectuent presque exactement le même filtrage; ils ont une largeur de transition similaire qui, comme nous l’avons appris, détermine le nombre de taps nécessaires. Le filtre FIR a 50 prises et le filtre IIR a 12 pôles, ce qui revient à avoir 12 taps en termes de calculs nécessaires.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 265)

Cannot scale image! Could not get size from "../_images/FIR_IIR.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/FIR_IIR.png'

La leçon à retenir est que le filtre FIR nécessite beaucoup plus de ressources informatiques que le filtre IIR pour effectuer à peu près la même opération de filtrage.

Voici quelques exemples concrets de filtres FIR et IIR que vous avez peut-être déjà utilisés.

Si vous effectuez une « moyenne glissante » sur une liste de nombres, il s’agit simplement d’un filtre FIR avec des taps de 1: - h = [1 1 1 1 1 1 1 1 1 1 1 1] pour un filtre de moyenne glissante avec une taille de fenêtre de 10. Il s’agit également d’un filtre passe-bas, pourquoi? Quelle est la différence entre l’utilisation de 1 et l’utilisation de taps qui diminuent jusqu’à zéro ?

Réponse

Un filtre à moyenne glissante est un filtre passe-bas car il atténue les changements de « haute fréquence », ce qui est généralement la raison pour laquelle les gens en utilisent un. La raison pour laquelle il faut utiliser des taps qui diminuent jusqu’à zéro aux deux extrémités est d’éviter un changement soudain dans la sortie, comme si le signal filtré était nul pendant un certain temps, puis augmentait soudainement.

Maintenant, un exemple de filtre IIR. L’un d’entre vous a-t-il déjà fait ceci :

x = x*0.99 + nouvelle_valeur*0.01

où les 0,99 et 0,01 représentent la vitesse de mise à jour de la valeur (ou le taux de décroissance, même chose). C’est un moyen pratique de mettre à jour lentement une variable sans avoir à se souvenir des dernières valeurs. Il s’agit en fait d’une forme de filtre IIR passe-bas. Avec un peu de chance, vous avez compris pourquoi les filtres IIR sont moins stables que les filtres FIR: les valeurs ne disparaissent jamais complètement !

Outils de conception de filtres

En pratique, la plupart des gens utiliseront un outil de conception de filtre ou une fonction dans le code qui conçoit le filtre. Il existe de nombreux outils différents, mais pour les étudiants, je recommande cette application Web facile à utiliser de Peter Isza qui vous montrera la réponse impulsionnelle et fréquencielle : http://t-filter.engineerjs.com. En utilisant les valeurs par défaut, du moins au moment de l’écriture de ce document, l’application est configurée pour concevoir un filtre passe-bas avec une bande passante de 0 à 400 Hz et une bande rejetée à partir de 500 Hz. La fréquence d’échantillonnage est de 2 kHz, donc la fréquence maximale que nous pouvons « voir » est de 1 kHz.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 299)

Cannot scale image! Could not get size from "../_images/filter_designer1.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_designer1.png'

Cliquez sur le bouton « Design Filter » pour créer les prises et tracer la réponse en fréquence.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 305)

Cannot scale image! Could not get size from "../_images/filter_designer2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_designer2.png'

Cliquez sur le texte « Impulse Response » au-dessus du graphique pour voir la réponse impulsionnelle, qui est une courbe des taps puisqu’il s’agit d’un filtre FIR.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 311)

Cannot scale image! Could not get size from "../_images/filter_designer3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_designer3.png'

Cette application inclut même le code source C++ pour implémenter et utiliser ce filtre. L’application web n’inclut aucun moyen de concevoir des filtres IIR, qui sont en général beaucoup plus difficiles à concevoir.

Convolution

Nous allons faire un bref détour pour présenter l’opérateur de convolution. N’hésitez pas à sauter cette section si elle vous est déjà familière.

L’addition de deux signaux est une façon de combiner deux signaux en un seul. Dans le chapitre Domaine fréquentiel, nous avons étudié comment la propriété de linéarité s’applique à l’addition de deux signaux. La convolution est une autre façon de combiner deux signaux en un seul, mais elle est très différente de leur simple addition. La convolution de deux signaux revient à en glisser un sur l’autre et à l’intégrer. Elle est très similaire à une corrélation croisée, si vous êtes familier avec cette opération. En fait, elle est équivalente à une corrélation croisée dans de nombreux cas.

Je pense que l’opération de convolution s’apprend mieux par des exemples. Dans ce premier exemple, nous convoluons deux impulsions carrées ensemble :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 329)

Cannot scale image! Could not get size from "../_images/convolution_animation1.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/convolution_animation1.gif'

Comme il s’agit simplement d’une intégration glissante, le résultat est un triangle avec un maximum au point où les deux impulsions carrées s’alignent parfaitement. Voyons ce qui se passe si nous convolvons une impulsion carrée avec une impulsion triangulaire :

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 335)

Cannot scale image! Could not get size from "../_images/convolution_animation2.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/convolution_animation2.gif'

Dans les deux exemples, nous avons deux signaux d’entrée (un rouge, un bleu), puis la sortie de la convolution est affichée. Vous pouvez voir que la sortie est l’intégration des deux signaux, l’un glissant sur l’autre. En raison de cette nature « glissante », la longueur de la sortie est en fait plus longue que celle de l’entrée. Si un signal contient M échantillons et l’autre N échantillons, la convolution des deux signaux peut produire N+M-1 échantillons. Cependant, des fonctions telles que numpy.convolve() permettent de spécifier si vous voulez la totalité du résultat (max(M, N) échantillons) ou seulement les échantillons où les signaux se chevauchent complètement (max(M, N) - min(M, N) + 1 si vous êtes curieux). Il n’est pas nécessaire de s’attarder sur ces détails. Sachez simplement que la longueur de la sortie d’une convolution n’est pas seulement la longueur des entrées.

Alors pourquoi la convolution est-elle importante en DSP? Pour commencer, pour filtrer un signal, nous pouvons simplement prendre la réponse impulsionnelle de ce filtre et la convoluer avec le signal. Le filtrage FIR est simplement une opération de convolution.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 343)

Cannot scale image! Could not get size from "../_images/filter_convolve.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_convolve.png'

Cela peut prêter à confusion car nous avons mentionné précédemment que la convolution prend deux signaux et en sort un. Nous pouvons traiter la réponse impulsionnelle comme un signal, et la convolution est un opérateur mathématique après tout, qui opère sur deux tableaux 1D. Si l’un de ces tableaux 1D est la réponse impulsionnelle du filtre, l’autre tableau 1D peut être un morceau du signal d’entrée, et la sortie sera une version filtrée de l’entrée.

Voyons un autre exemple. Dans l’exemple ci-dessous, le triangle représente la réponse impulsionnelle de notre filtre, et le signal :green:`vert` est notre signal filtré.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 351)

Cannot scale image! Could not get size from "../_images/convolution.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/convolution.gif'

La sortie :red:`rouge` est le signal filtré.

Question : Quel type de filtre était le triangle ?

Réponse

Il atténue les composantes haute fréquence du signal vert (c’est-à-dire les transitions nettes du carré) et agit donc comme un filtre passe-bas.

Maintenant que nous commençons à comprendre la convolution, je vais vous présenter son équation mathématique. L’astérisque (*) est généralement utilisé comme symbole de la convolution :

\[(f * g)(t) = \int f(\tau) g(t - \tau) d\tau\]

Dans l’expression ci-dessus, \(g(t)\) est le signal ou l’entrée qui est inversée et glisse sur \(f(t)\), mais \(g(t)\) et \(f(t)\) peuvent être intervertis et il s’agit toujours de la même expression. En général, le vecteur le plus court sera utilisé comme \(g(t)\). La convolution est égale à une corrélation croisée, définie comme \(\int f(\tau) g(t+\tau)\), lorsque \(g(t)\) est symétrique, c’est-à-dire qu’il ne change pas lorsqu’il est retourné autour de l’origine.

Conception de Filtres en Python

Nous allons maintenant étudier une façon de concevoir nous-mêmes un filtre FIR en Python. Bien qu’il existe de nombreuses approches de la conception de filtres, nous utiliserons la méthode consistant à commencer dans le domaine fréquentiel et à revenir en arrière pour trouver la réponse impulsionnelle. Car en fin de compte, c’est ainsi que notre filtre est représenté (par ses taps).

Vous commencez par créer un vecteur de votre réponse en fréquence souhaitée. Concevons un filtre passe-bas de forme arbitraire illustré ci-dessous:

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 387)

Cannot scale image! Could not get size from "../_images/filter_design1.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design1.png'

Le code utilisé pour créer ce filtre est assez simple :

import numpy as np
import matplotlib.pyplot as plt
H = np.hstack((np.zeros(20), np.arange(10)/10, np.zeros(20)))
w = np.linspace(-0.5, 0.5, 50)
plt.plot(w, H, '.-')
plt.show()

hstack() est une façon de concaténer des vecteur en numpy. Nous savons que cela mènera à un filtre avec des taps complexes. Pourquoi ?

Réponse

Il n’est pas symétrique autour de 0 Hz.

Notre objectif final est de trouver les prises de ce filtre afin de pouvoir l’utiliser. Comment obtenir les taps, étant donné la réponse fréquentielle? Eh bien, comment convertir le domaine fréquentiel en domaine temporel? La FFT inverse (IFFT)! Rappelez-vous que la fonction IFFT est presque exactement la même que la fonction FFT. Nous devons également décaler la réponse en fréquence souhaitée avant la IFFT, puis décaler à nouveau la réponse en fréquence après la IFFT (non, elles ne s’annulent pas toutes seules, vous pouvez essayer). Ce processus peut sembler déroutant. Rappelez-vous simplement que vous devez toujours effectuer un FFTshift après un FFT et un IFFshift après un IFFT.

h = np.fft.ifftshift(np.fft.ifft(np.fft.ifftshift(H)))
plt.plot(np.real(h))
plt.plot(np.imag(h))
plt.legend(['réél','imag'], loc=1)
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 426)

Cannot scale image! Could not get size from "../_images/filter_design2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design2.png'

Nous allons utiliser les taps indiqués ci-dessus comme filtre. Nous savons que la réponse impulsionnelle consiste à tracer les taps, donc ce que nous voyons ci-dessus est notre réponse impulsionnelle. Prenons la FFT de nos taps pour voir à quoi ressemble réellement la réponse fréquentielle. Nous allons faire une FFT de 1 024 points pour obtenir une haute résolution :

H_fft = np.fft.fftshift(np.abs(np.fft.fft(h, 1024)))
plt.plot(H_fft)
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 438)

Cannot scale image! Could not get size from "../_images/filter_design3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design3.png'

Voyez comment la réponse en fréquence n’est pas très droite… elle ne correspond pas très bien à notre forme originale, si vous vous souvenez de la forme pour laquelle nous voulions initialement faire un filtre. Une des raisons principales est que notre réponse impulsionnelle n’a pas fini de décroître, c’est-à-dire que les côtés gauche et droit n’atteignent pas zéro. Nous avons deux options qui lui permettront de décroître jusqu’à zéro :

Option 1: Nous « fenêtrons » notre réponse impulsionnelle actuelle de manière à ce qu’elle décroisse vers 0 des deux côtés. Il s’agit de multiplier notre réponse impulsionnelle par une « fonction de fenêtrage » qui commence et se termine à zéro.

# Après avoir créé h en utilisant le code précédent, créez et appliquez la fenêtre
window = np.hamming(len(h))
h = h * window

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 452)

Cannot scale image! Could not get size from "../_images/filter_design4.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design4.png'

Option 2: Nous générons à nouveau notre réponse impulsionnelle en utilisant davantage de points afin qu’elle ait le temps de s’annuler. Nous devons ajouter de la résolution à notre vecteur original dans le domaine des fréquences (appelé interpolation).

H = np.hstack((np.zeros(200), np.arange(100)/100, np.zeros(200)))
w = np.linspace(-0.5, 0.5, 500)
plt.plot(w, H, '.-')
plt.show()
# (le reste du code est le même)

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 467)

Cannot scale image! Could not get size from "../_images/filter_design5.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design5.png'

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 471)

Cannot scale image! Could not get size from "../_images/filter_design6.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design6.png'

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 476)

Cannot scale image! Could not get size from "../_images/filter_design7.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/filter_design7.png'

Les deux options ont fonctionné. Laquelle choisiriez-vous? La deuxième méthode a permis d’obtenir plus de prises, mais la première méthode a permis d’obtenir une réponse en fréquence qui n’était pas très nette et dont le front descendant n’était pas très raide. Il existe de nombreuses façons de concevoir un filtre, chacune ayant ses propres compromis. Beaucoup considèrent la conception de filtres comme un art.

Introduction à la Mise en Forme

Nous allons présenter brièvement un sujet très intéressant au sein de la DSP: la mise en forme. Nous l’étudierons plus tard en profondeur dans son propre chapitre, voir Mise en Forme. Il est intéressant de le mentionner en même temps que le filtrage, car la mise en forme est finalement un type de filtre, utilisé dans un but spécifique, avec des propriétés spéciales.

Comme nous l’avons appris, les signaux numériques utilisent des symboles pour représenter un ou plusieurs bits d’information. Nous utilisons un schéma de modulation numérique tel que ASK, PSK, QAM, FSK, etc., pour moduler une porteuse afin que les informations puissent être envoyées sans fil. Lorsque nous avons simulé la QPSK dans le chapitre Modulation numérique, nous n’avons simulé qu’un seul échantillon par symbole, c’est-à-dire que chaque nombre complexe que nous avons créé était l’un des points de la constellation - c’était un symbole. En pratique, nous générons normalement plusieurs échantillons par symbole, et la raison est liée au filtrage.

Nous utilisons des filtres pour façonner la « forme » de nos symboles car la forme dans le domaine temporel modifie la forme dans le domaine fréquentiel. Le domaine des fréquences nous informe de la quantité de spectre/largeur de bande que notre signal utilisera, et nous voulons généralement la minimiser. Ce qu’il est important de comprendre, c’est que les caractéristiques spectrales (dans le domaine des fréquences) des symboles de la bande de base ne changent pas lorsque nous modulons sur une porteuse; la bande de base est simplement déplacée vers le haut en fréquence alors que sa forme reste la même, ce qui signifie que la quantité de bande passante qu’elle utilise reste la même. Lorsque nous utilisons 1 échantillon par symbole, cela revient à transmettre des impulsions carrées. En fait, la BPSK utilisant 1 échantillon par symbole est juste une onde carrée de 1 et -1 aléatoires :

Et comme nous l’avons appris, les impulsions carrées ne sont pas efficaces car elles utilisent une quantité excessive de spectre:

Nous procédons donc à une « mise en forme » de ces symboles en forme de blocs afin qu’ils occupent moins de bande passante dans le domaine des fréquences. Pour ce faire, nous utilisons un filtre passe-bas qui élimine les composantes haute fréquence de nos symboles. Vous trouverez ci-dessous un exemple de symboles dans les domaines temporel (en haut) et fréquentiel (en bas), avant et après l’application d’un filtre de mise en forme:

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 503)

Cannot scale image! Could not get size from "../_images/pulse_shaping.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pulse_shaping.png'

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 509)

Cannot scale image! Could not get size from "../_images/pulse_shaping_freq.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pulse_shaping_freq.png'

Notez la rapidité avec laquelle le signal chute en fréquence. Les lobes secondaires sont inférieurs de 30 dB après la mise en forme, soit 1 000 fois moins! Et surtout, le lobe principal est plus étroit, donc moins de spectre est utilisé pour le même nombre de bits par seconde.

Pour l’instant, sachez que les filtres de mise en forme les plus courants sont les suivants :

Filtre à cosinus surélevé

Filtre à racines cosinus surélevé

Filtre Sinc

Filtre gaussien

Ces filtres ont généralement un paramètre que vous pouvez ajuster pour diminuer la bande passante utilisée. La figure ci-dessous montre le domaine temporel et fréquentiel d’un filtre cosinus surélevé avec différentes valeurs de \(\beta\), le paramètre qui définit la pente de l’amortissement, souvent appelé roll-off.

System Message: WARNING/2 (/home/vs/SPHINX/source/filters.rst, line 524)

Cannot scale image! Could not get size from "../_images/pulse_shaping_rolloff.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pulse_shaping_rolloff.png'

Vous pouvez voir qu’une valeur plus faible de \(\beta\) réduit le spectre utilisé (pour la même quantité de données). Cependant, si la valeur est trop faible, les symboles du domaine temporel mettent plus de temps à revenir à zéro. En fait, lorsque \(\beta=0\), les symboles ne tombent jamais complètement à zéro, ce qui signifie que nous ne pouvons pas transmettre ces symboles dans la pratique. Une valeur de \(\beta\) autour de 0,35 est courante.

Vous en apprendrez beaucoup plus sur la mise en forme, y compris certaines propriétés spéciales que les filtres de mise en forme doivent satisfaire, dans le chapitre Mise en Forme.

Domaine fréquentiel

Ce chapitre présente le domaine fréquentiel d’un signal et couvre les séries de Fourier, la transformée de Fourier, ses propriétés, la FFT, le fenêtrage et les spectrogrammes, à l’aide d’exemples Python.

L’une des conséquences indirectes les plus intéressantes de l’apprentissage du DSP (en anglais digital signal processing pour traitements numériques du signal en français) et des communications sans fil est que vous apprendrez également à raisonner dans le domaine fréquentiel. L’expérience de la plupart des gens en matière de manipulation dans le domaine fréquenciel se limite à l’ajustement de boutons de réglage des basses, des médiums et des aigus sur le système audio d’une voiture. L’expérience de la plupart des gens en matière de visualisation d’un signal dans le domaine fréquenciel se limite à voir un égaliseur audio, comme cette animation :

À la fin de ce chapitre, vous comprendrez ce que signifie réellement le domaine fréquentiel, comment basculer entre le domaine temporel et fréquentiel (et ce qui se passe lorsque nous le faisons), ainsi que certains principes intéressants que nous utiliserons tout au long de nos études de DSP et de SDR (Software-Defined Radio en anglaie pour radio logicielle en français). À la fin de ce manuel, vous deviendrez un maître dans l’art de manipuler un signal dans le domaine fréquentiel, c’est garanti!

Tout d’abord, pourquoi aimons-nous regarder les signaux dans le domaine des fréquences? Voici deux exemples de signaux, présentés à la fois dans le domaine temporel et dans le domaine fréquentiel:

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 20)

Cannot scale image! Could not get size from "../_images/time_and_freq_domain_example_signals.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/time_and_freq_domain_example_signals.png'

Comme vous pouvez le voir, dans le domaine temporel, ils ressemblent tous les deux à du bruit, mais dans le domaine fréquentiel, nous pouvons voir des caractéristiques différentes. Dans le domaine temporel, tout se trouve sous sa forme naturelle; lorsque nous échantillonnons des signaux, nous le faisons dans le domaine temporel, car vous ne pouvez pas échantillonner directement un signal dans le domaine fréquentiel. Et pourtant, les choses les plus intéressantes se passent généralement dans le domaine des fréquences.

Séries de Fourier

Les bases du domaine fréquentiel commencent par comprendre que tout signal peut être représenté par la somme d’ondes sinusoïdales. Lorsque nous décomposons un signal en ses ondes sinusoïdales, on obtient ce qu’on appelle une série de Fourier. Voici un exemple de signal composé de seulement deux ondes sinusoïdales:

Voici un autre exemple: la courbe rouge ci-dessous reproduit approximativement une onde en dents de scie en additionnant jusqu’à 10 ondes sinusoïdales. On peut observer qu’il ne s’agit pas d’une reconstruction parfaite - il faudrait en fait sommer un nombre infini d’ondes sinusoïdales pour reproduire le signal en dents de scie à cause des transitions abruptes:

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 38)

Cannot scale image! Could not get size from "../_images/fourier_series_triangle.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fourier_series_triangle.gif'

Certains signaux nécessitent ainsi de sommer plus d’ondes sinusoïdales que d’autres, alors que d’autres ont besoin d’un nombre infini, bien qu’ils puissent toujours être approximés avec un nombre limité. Voici un autre exemple de décomposition d’un signal en une série d’ondes sinusoïdales :

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 44)

Cannot scale image! Could not get size from "../_images/fourier_series_arbitrary_function.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fourier_series_arbitrary_function.gif'

Pour comprendre comment décomposer un signal en somme de sinusoïdes, nous devons d’abord définir les trois paramètres d’une onde sinusoïdale :

Amplitude

Fréquence

Phase

L’amplitude indique la « force » de l’onde, tandis que la fréquence est le nombre d”ondulations par seconde. La phase est utilisée pour représenter la façon dont l’onde sinusoïdale est décalée dans le temps, de 0 à 360 degrés (ou de 0 à \(2\pi\)), mais elle doit être relative à quelque chose pour avoir un sens, comme deux signaux de même fréquence déphasés de 30 degrés l’un par rapport à l’autre.

À ce stade, vous avez peut-être compris qu’un « signal » n’est essentiellement qu’une fonction, généralement représentée « dans le temps » (c’est-à-dire sur l’axe des x). Un autre attribut facile à retenir est la période, qui est l’inverse de la fréquence. La période d’une sinusoïde est le temps, en secondes, que met l’onde pour terminer un cycle. Ainsi, l’unité de fréquence est 1/seconde, ou Hz.

Lorsque nous décomposons un signal en une somme d’ondes sinusoïdales, chacune d’entre elles aura une certaine amplitude, phase et fréquence. L”amplitude de chaque onde sinusoïdale nous indique l’intensité de la fréquence présente dans le signal d’origine. Ne vous préoccupez pas trop de la phase pour l’instant, si ce n’est pour réaliser que la seule différence entre sin() et cos() est un déphasage (décalage temporel).

Il est plus important de comprendre le concept sous-jacent que les équations réelles pour résoudre une série de Fourier, mais pour ceux qui sont intéressés par les équations, je vous renvoie à l’explication concise de Wolfram : https://mathworld.wolfram.com/FourierSeries.html.

La dualité temps-fréquence

Nous avons expliqué que les signaux peuvent être représentés comme des ondes sinusoïdales, chacune ayant plusieurs paramètres. Maintenant, voyons comment tracer ces signaux dans le domaine des fréquences. Là où la représentation dans le domaine temporel montre comment un signal évolue dans le temps, le domaine fréquentiel, lui, montre la proportion du signal qui se trouve dans telle ou telle fréquence. De ce fait, l’axe des x ne sera plus le temps mais la fréquence. Nous pouvons représenter un signal donné à la fois en temps et en fréquence. Prenons quelques exemples simples pour commencer.

Voici à quoi ressemble une onde sinusoïdale, de fréquence f, dans le domaine temporel et fréquentiel:

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 74)

Cannot scale image! Could not get size from "../_images/sine-wave.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/sine-wave.png'

Le domaine temporel devrait vous sembler très familier. Il s’agit tout simplement d’une fonction oscillante. Ne vous souciez pas de savoir à quel moment du cycle elle commence à l’instant t=0 ou combien de temps elle dure. Ce qu’il faut retenir, c’est que le signal a une unique fréquence, c’est pourquoi nous voyons un seul pic dans le domaine des fréquences. Quelle que soit la fréquence à laquelle cette onde sinusoïdale oscille, c’est là que nous verrons le pic dans le domaine des fréquences. Le nom mathématique d’un pic de ce type est appelé « impulsion ».

Maintenant, et si nous avions une impulsion dans le domaine temporel ? Imaginez l’enregistrement sonore d’une personne tapant dans ses mains ou frappant un clou avec un marteau. Cette paire temps-fréquence est un peu moins intuitive.

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 83)

Cannot scale image! Could not get size from "../_images/impulse.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/impulse.png'

Comme nous pouvons le voir, un pic/impulsion dans le domaine temporel est un plat dans le domaine fréquentiel; il contient théoriquement toutes les fréquences. Il n’existe pas d’impulsion théoriquement parfaite, car elle devrait être infiniment courte dans le domaine temporel. Comme pour l’onde sinusoïdale, l’endroit où l’impulsion se produit dans le domaine temporel n’a pas d’importance. Ce qu’il faut retenir ici, c’est que les changements rapides dans le domaine temporel entraînent l’apparition de nombreuses fréquences dans le domaine fréquentiel.

Examinons mainteant les tracés d’une onde carrée dans le domaine temporel et fréquentiel:

Cet exemple est également pas très intuitif, mais nous pouvons dans un premier temps observer que la présence d’une forte pointe à 10 Hz, qui est la fréquence de l’onde carrée. En revanche, on observe aussi que les fréquences ne s’arrête pas à ce pic mais continue au delà. Cela est dû au changements rapides qu’on peut observer dans le domaine temporel, tout comme dans l’exemple précédent. Toutefois, la courbe dans le domaine fréquentiel ici n’est plus plate. Elle présente plutôt des pics à certains intervalles, et le niveau décroît lentement (bien qu’il se poursuive indéfiniment). Une onde carrée dans le domaine temporel a la forme de la fonction sin(x)/x dans le domaine fréquentiel (fonction aussi appelée sinc).

Maintenant, que se passe-t-il si nous avons un signal constant dans le domaine temporel? Un signal constant n’a pas de « fréquence ». Voyons voir :

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 99)

Cannot scale image! Could not get size from "../_images/dc-signal.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/dc-signal.png'

Comme il n’y a pas de fréquence, nous avons un pic à 0 Hz dans le domaine des fréquences. C’est logique si vous y réfléchissez. Le domaine des fréquences ne sera pas « vide », car cela ne se produit que lorsqu’il n’y aucun signal de présent (c’est-à-dire un signal de valeur dans le domaine temporel). Nous appelons ce 0Hz dans le domaine des fréquences la « CC » (pour courant continue) ou souvent « DC » (pour direct current en anglais), car il est causé par un signal continu et constant dans le temps. Notez que si nous augmentons l’amplitude de notre signal DC dans le domaine temporel, le pic à 0 Hz dans le domaine fréquentiel augmentera également.

Nous verrons plus tard ce que signifie exactement l’axe des y dans le graphique du domaine des fréquences, mais pour l’instant, vous pouvez le considérer comme une sorte d’amplitude qui vous indique la quantité de cette fréquence présente dans le signal temporel.

Transformée de Fourier

Mathématiquement, la « transformation » que nous utilisons pour passer du domaine temporel au domaine fréquentiel ou inversement s’appelle la transformation de Fourier. Elle est définie comme suit :

\[X(f) = \int x(t) e^{-j2\pi ft} dt\]

Pour un signal temporel x(t), nous pouvons obtenir sa version dans le domaine fréquentiel, X(f), en utilisant cette formule. Nous représenterons la version temporelle d’une fonction par x(t) ou y(t), et la version fréquentielle correspondante par X(f) et Y(f). Notez qu’on utlise la variable « t » pour le temps, et « f » pour la fréquence. Le « j » est simplement l’unité imaginaire pure. Vous l’avez peut-être vu sous la forme « i » en cours de mathématiques au lycée. Nous utilisons « j » en ingénierie et en informatique car « i » fait souvent référence au courant, et en programmation, il est souvent utilisé comme itérateur de boucle.

Pour revenir au domaine temporel à partir de la fréquence, c’est presque la même chose, à part un une constante multiplicative et un signe négatif :

\[x(t) = \frac{1}{2 \pi} \int X(f) e^{j2\pi ft} df\]

Notez que de nombreux manuels utilisent plutôt \(w\) à la place de \(2\pi f\). \(w\) est la fréquence angulaire en radians par seconde, alors que \(f\) est en Hz. Tout ce que vous devez savoir, c’est que

\[\omega = 2 \pi f\]

Même si cela ajoute un terme \(2 \pi\) à de nombreuses équations, il est plus facile de s’en tenir à la fréquence en Hz. En fin de compte, vous travaillerez avec des Hz dans votre application SDR.

L’équation ci-dessus pour la transformée de Fourier est la forme continue, que vous ne verrez que dans les problèmes de mathématiques. La forme discrète est beaucoup plus proche de ce qui est mis en œuvre dans les implémentations matérielles:

\[X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{j2\pi}{N}kn}\]

Notez que la principale différence est que nous avons remplacé l’intégrale par une sommation. L’indice \(k\) va de 0 à N-1.

Ce n’est pas grave si aucune de ces équations ne vous intéresse. En fait, nous n’avons pas besoin de les utiliser directement pour faire des trucs cool avec les DSP et les SDR!

Propriétés temps-fréquence

Nous avons examiné précédemment des exemples de la manière dont les signaux apparaissent dans le domaine temporel et dans le domaine fréquentiel. Nous allons maintenant aborder cinq importantes « propriétés de Fourier ». Il s’agit de propriétés qui nous disent que si nous appliquons ____ à notre signal dans le domaine temporel, alors ____ s’appliquera à notre signal dans le domaine fréquentiel. Cela nous donnera un aperçu important du type de traitement numérique du signal (DSP) que nous effectuerons sur les signaux du domaine temporel dans la pratique.

Propriété de linéarité:

\[a x(t) + b y(t) \leftrightarrow a X(f) + b Y(f)\]

Cette propriété est probablement la plus facile à comprendre. Si nous additionnons deux signaux dans le temps, la version dans le domaine des fréquences sera également la somme de la représentation fréquentielle des deux signaux. Elle nous indique également que si nous multiplions l’un ou l’autre par une constante, l’équivalent fréquentiel sera également multiplié par la même quantité. L’utilité de cette propriété deviendra plus évidente lorsque nous additionnerons plusieurs signaux à la fois.

Propriété du décalage de fréquence:

\[e^{2 \pi j f_0 t}x(t) \leftrightarrow X(f-f_0)\]

Le terme à gauche de x(t) est ce que nous appelons une « sinusoïde complexe » ou une « exponentielle complexe ». Pour l’instant, il nous suffit de savoir qu’il s’agit essentiellement d’une onde sinusoïdale à la fréquence \(f_0\). Cette propriété nous indique que si nous prenons un signal \(x(t)\) et que nous le multiplions par une onde sinusoïdale, nous obtenons dans le domaine fréquentiel \(X(f)\) sauf qu’il est décalé d’une certaine fréquence, \(f_0\). Ce décalage en fréquence peut être plus facile à comprendre avec une visualisation:

Le décalage de fréquence fait partie intégrante du DSP parce que nous voulons décaler les signaux vers le haut et vers le bas en fréquence pour de nombreuses raisons. Cette propriété nous indique comment le faire (en multipliant par une onde sinusoïdale). Voici une autre façon de visualiser cette propriété:

Propriété du changement d’échelle dans le temps:

\[x(at) \leftrightarrow X\left(\frac{f}{a}\right)\]

Sur la partie gauche de l’équation, nous pouvons voir que nous faisons un changement d’échelle de notre signal x(t) dans le domaine temporel. Voici un exemple du changement d’échelle d’un signal temporel, puis ce qu’il advient des versions dans le domaine des fréquences de chacun d’entre eux.

Le changement d’échelle dans le temps a pour effet de rétrécir ou d’étendre le signal sur l’axe des x. Cette propriété nous indique que la mise à l’échelle dans le domaine temporel entraîne une mise à l’échelle inverse dans le domaine fréquentiel. Par exemple, lorsque nous transmettons des bits plus rapidement, nous devons utiliser davantage de fréquences. Cette propriété permet d’expliquer pourquoi les signaux à haut débit de données occupent une plus grande largeur de bande/spectre. Si l’échelle temps-fréquence était proportionnelle et non inversement proportionnelle, les opérateurs de téléphonie mobile pourraient transmettre tous les bits par seconde qu’ils souhaitent sans avoir à payer des milliards pour avoir accès à plus de spectre!

Les personnes déjà familiarisées avec cette propriété remarqueront peut-être l’absence d’une constante multiplicative, qui a été laissé de côté pour des raisons de simplicité. En pratique, cela ne fait pas de différence.

Propriété de la convolution dans le temps:

\[\int x(\tau) y(t-\tau) d\tau \leftrightarrow X(f)Y(f)\]

On l’appelle la propriété de convolution car, dans le domaine temporel, nous convoluons x(t) et y(t). Vous ne connaissez peut-être pas encore l’opération de convolution. Pour l’instant, imaginez-la comme une corrélation croisée. Lorsque nous convoluons des signaux du domaine temporel, cela revient à multiplier les versions du domaine fréquentiel de ces deux signaux. C’est très différent de juste la sommation de deux signaux. Lorsque vous additionnez deux signaux, comme nous l’avons vu, rien ne se passe vraiment, vous additionnez simplement les versions du domaine fréquentiel. Mais lorsque vous convoluez deux signaux, c’est comme si vous créiez un nouveau signal à partir de ceux-ci. La convolution est la technique la plus importante du traitement numérique des signaux, même si nous devons d’abord comprendre le fonctionnement des filtres pour l’appréhender pleinement.

Avant de poursuivre, pour expliquer brièvement pourquoi cette propriété est si importante, considérez la situation suivante: vous avez un signal que vous voulez recevoir, et il y a un signal d’interférence à côté.

Le concept de masquage est très utilisé en programmation, alors utilisons-le ici. Et si nous pouvions créer le masque ci-dessous, et le multiplier par le signal ci-dessus afin de masquer celui que nous ne voulons pas ?

Nous effectuons généralement des opérations de traitement numérique des signaux dans le domaine temporel, alors utilisons la propriété de convolution pour voir comment nous pouvons effectuer ce masquage dans le domaine temporel. Disons que x(t) est notre signal reçu. Soit Y(f) le masque que nous voulons appliquer dans le domaine des fréquences. Cela signifie que y(t) est la représentation dans le domaine temporel de notre masque, et que si nous le convolvons avec x(t), nous pouvons « filtrer » le signal que nous ne voulons pas.

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 205)

Cannot scale image! Could not get size from "../_images/masking-equation.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/masking-equation.png'

Lorsque nous aborderons le filtrage, la propriété de convolution prendra tout son sens.

Convolution en propriété de fréquence:

Enfin, je tiens à souligner que la propriété de convolution fonctionne en sens inverse, même si nous ne l’utiliserons pas autant que la convolution dans le domaine temporel :

\[x(t)y(t) \leftrightarrow \int X(g) Y(f-g) dg\]

Il existe d’autres propriétés, mais les cinq ci-dessus sont, à mon avis, les plus importantes à comprendre. Même si nous n’avons pas démontré chaque propriété, le fait est que nous utilisons les propriétés mathématiques pour comprendre ce qui arrive aux signaux réels lorsque nous les analysons et les traitons. Ne vous attardez pas sur les équations. Assurez-vous de bien comprendre la description de chaque propriété.

Transformée de Fourier rapide (FFT)

Revenons maintenant à la Transformée de Fourier. Je vous ai montré l’équation de la transformée de Fourier discrète, mais ce que vous utiliserez en codant 99,9% du temps sera la fonction fft(). La transformée de Fourier rapide (FFT pour Fast Fourier Transform) est simplement un algorithme permettant de calculer la transformée de Fourier discrète. Il a été développé il y a plusieurs dizaines d’années, et même s’il existe plusieurs variations dans son implémentation, il reste le principal algorithme de calcul de la transformée de Fourier discrète. Une chance pour nous, vu qu’ils ont utilisé le mot « rapide » dans le nom.

La FFT est donc une fonction avec une seul entrée et une seul sortie. Elle convertit un signal temporel en sa représentation fréquentielle:

Dans ce manuel, nous ne traiterons que des FFT à une dimension (la 2D est utilisée pour le traitement des images et d’autres applications). Pour nos besoins, considérez la fonction FFT comme ayant une entrée: un vecteur d’échantillons, et une sortie: la version dans le domaine fréquentiel de ce vecteur d’échantillons. La taille de la sortie est toujours la même que celle de l’entrée. Si j’introduis 1024 échantillons dans la FFT, j’obtiendrai 1024 échantillons en sortie. Ce qui est déroutant, c’est que la sortie sera toujours dans le domaine des fréquences, et donc l’intervalle de l’axe des x si nous devions la tracer ne change pas en fonction du nombre d’échantillons dans le domaine temporel de l’entrée. Visualisons cela en regardant les tableaux d’entrée et de sortie, ainsi que les unités de leurs indices:

La sortie étant dans le domaine fréquentiel, l’intervalle de l’axe des x est basé sur le taux d’échantillonnage, que nous aborderons au chapitre suivant. Lorsque nous utilisons plus d’échantillons pour le vecteur d’entrée, nous obtenons une meilleure résolution dans le domaine fréquentiel (en plus de traiter plus d’échantillons à la fois). Nous ne « voyons » pas réellement plus de fréquences en ayant une entrée plus grande. Le seul moyen serait d’augmenter la fréquence d’échantillonnage (c-à-d diminuer la période d’échantillonnage \(\Delta t\)).

Comment faire pour tracer cette sortie? Imaginons par exemple que notre fréquence d’échantillonnage soit de 1 million d’échantillons par seconde (1 MHz). Comme nous l’apprendrons au chapitre suivant, cela signifie que nous ne pouvons voir que les signaux jusqu’à 0.5 MHz, quel que soit le nombre d’échantillons introduits dans la FFT. La façon dont la sortie de la FFT est représentée est la suivante :

La sortie de la FFT montrera toujours \(\text{-} f_s/2\) à \(f_s/2\) où \(f_s\) est la fréquence d’échantillonnage. C’est-à-dire que la sortie aura toujours une partie négative et une partie positive. Si l’entrée est complexe, les portions négative et positive seront différentes, mais si elle est réelle, elles seront identiques.

En ce qui concerne l’intervalle de fréquence, chaque case correspond à \(f_s/N\) Hz, c’est-à-dire que si vous ajoutez plus d’échantillons à chaque FFT, vous obtiendrez une meilleure résolution dans votre sortie. Un détail très mineur qui peut vous être inconnu si vous êtes nouveau: mathématiquement, le tout dernier indice ne correspond pas exactement à \(f_s/2\), mais plutôt à \(f_s/2 - f_s/N\) qui, pour un grand \(N\), sera approximativement \(f_s/2\).

Fréquences négatives

Qu’est-ce qu’une fréquence négative? Pour l’instant, sachez simplement qu’il s’agit de l’utilisation de nombres complexes (nombres imaginaires) - il n’existe pas vraiment de « fréquence négative » lorsqu’il s’agit de transmettre/recevoir des signaux RF, c’est juste une représentation que nous utilisons. Voici une façon intuitive d’y penser. Imaginons que nous demandions à notre SDR de se régler sur 100 MHz (la bande radio FM) et d’échantillonner à une fréquence de 10 MHz. En d’autres termes, nous allons visualiser le spectre de 95 MHz à 105 MHz. Peut-être y a-t-il trois signaux présents:

Maintenant, quand le SDR nous donne les échantillons, cela apparaîtra comme ceci:

Rappelez-vous que nous avons réglé le SDR sur 100 MHz. Ainsi, le signal qui était à environ 97.5 MHz apparaît à -2.5 MHz lorsque nous le représentons numériquement, ce qui est techniquement une fréquence négative. En réalité, il s’agit simplement d’une fréquence inférieure à la fréquence centrale. Cela prendra tout son sens lorsque nous en saurons plus sur l’échantillonnage et que nous aurons acquis de l’expérience avec nos SDR.

L’ordre dans le domaine temporel n’a pas d’importance

Une dernière propriété avant de passer aux FFT. La fonction FFT « mélange » en quelque sorte le signal d’entrée pour former la sortie, qui a une échelle et des unités différentes. Après tout, nous ne sommes plus dans le domaine temporel. Une bonne façon de comprendre cette différence entre les domaines est de réaliser que le fait de changer l’ordre des choses dans le domaine temporel ne change pas les composantes de fréquence du signal. Par exemple, la FFT des deux signaux suivants présentera les deux mêmes pics parce que le signal n’est que deux ondes sinusoïdales à des fréquences différentes. Le fait de changer l’ordre dans lequel les ondes sinusoïdales se produisent ne change pas le fait qu’il s’agit de deux ondes sinusoïdales à des fréquences différentes.

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 275)

Cannot scale image! Could not get size from "../_images/fft_signal_order.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fft_signal_order.png'

Techniquement, la phase des valeurs de la FFT change en raison du décalage temporel des sinusoïdes. Cependant, dans les premiers chapitres de ce manuel, nous nous intéresserons principalement à la magnitude de la FFT.

FFT en Python

Maintenant que nous avons appris ce qu’est une FFT et comment le résultat est représenté, regardons un peu de code Python et utilisons la fonction FFT de Numpy, np.fft.fft(). Il est recommandé d’utiliser une console/IDE Python complète sur votre ordinateur, mais en cas de besoin, vous pouvez utiliser la console Python en ligne sur le Web dont le lien se trouve en bas de la barre de navigation à gauche.

Tout d’abord, nous devons créer un signal dans le domaine temporel. N’hésitez pas à suivre avec votre propre console Python. Pour garder les choses simples, nous allons créer une simple onde sinusoïdale à 0.15Hz. Nous utiliserons également une fréquence d’échantillonnage de 1Hz, ce qui signifie que dans le temps, nous échantillonnons à 0, 1, 2, 3 secondes etc.

import numpy as np
t = np.arange(100)
s = np.sin(0.15*2*np.pi*t)

Si nous traçons s cela ressemble à:

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 297)

Cannot scale image! Could not get size from "../_images/fft-python1.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fft-python1.png'

Utilisons ensuite la fonction FFT de Numpy:

S = np.fft.fft(s)

Si nous regardons S, nous voyons que c’est un tableau de nombres complexes:

S =  array([-0.01865008 +0.00000000e+00j, -0.01171553 -2.79073782e-01j,0.02526446 -8.82681208e-01j,  3.50536075 -4.71354150e+01j, -0.15045671 +1.31884375e+00j, -0.10769903 +7.10452463e-01j, -0.09435855 +5.01303240e-01j, -0.08808671 +3.92187956e-01j, -0.08454414 +3.23828386e-01j, -0.08231753 +2.76337148e-01j, -0.08081535 +2.41078885e-01j, -0.07974909 +2.13663710e-01j,...

Conseil: indépendamment de ce que vous faites, si vous rencontrez des nombres complexes, essayez de calculer la magnitude et la phase et voyez si elles ont plus de sens. Faisons exactement cela et traçons la magnitude et la phase. Dans la plupart des langages, abs() est la fonction pour le module d’un nombre complexe. La fonction pour obtenir la phase varie, mais en Python c’est np.angle().

import matplotlib.pyplot as plt
S_mag = np.abs(S)
S_phase = np.angle(S)
plt.plot(t,S_mag,'.-')
plt.plot(t,S_phase,'.-')

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 323)

Cannot scale image! Could not get size from "../_images/fft-python2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fft-python2.png'

Pour l’instant, nous ne fournissons pas d’axe x aux graphiques, il s’agit simplement de l’index du tableau (en comptant à partir de 0). Pour des raisons mathématiques, la sortie de la FFT a le format suivant:

Mais nous voulons 0Hz (DC) au centre et les fréquences négatives à gauche (c’est ainsi que nous aimons classiquement visualiser les choses). Ainsi, chaque fois que nous effectuons une FFT, nous devons effectuer un « décalage FFT », qui n’est qu’une simple opération de réarrangement de tableau, un peu comme un décalage circulaire, mais plus du type « mettez ceci ici et cela là ». Le diagramme ci-dessous définit entièrement ce que fait l’opération de décalage FFT :

Pour notre confort, Numpy possède une fonction de décalage FFT, np.fft.fftshift(). Remplacez la ligne np.fft.fft() par :

S = np.fft.fftshift(np.fft.fft(s))

Nous devons également déterminer les valeurs/labels de l’axe des x. Rappelez-vous que nous avons utilisé une fréquence d’échantillonnage de 1Hz pour garder les choses simples. Cela signifie que le bord gauche du graphique du domaine fréquentiel sera de -0.5Hz et le bord droit de 0.5Hz. Si cela n’a pas de sens, cela en aura après avoir lu le chapitre sur échantillonnage IQ. Restons-en à l’hypothèse que notre fréquence d’échantillonnage était de 1Hz, et traçons la magnitude et la phase de la sortie de la FFT avec un label approprié sur l’axe des x. Voici la version finale de cet exemple Python ainsi que sa sortie:

import numpy as np
import matplotlib.pyplot as plt

Fs = 1 # Hz
N = 100 # le nombre de points à simuler, et la taille de notre FFT

t = np.arange(N) # parce que notre taux d'échantillonnage est de 1 Hz
s = np.sin(0.15*2*np.pi*t)
S = np.fft.fftshift(np.fft.fft(s))
S_mag = np.abs(S)
S_phase = np.angle(S)
f = np.arange(Fs/-2, Fs/2, Fs/N)
plt.figure(0)
plt.plot(f, S_mag,'.-')
plt.figure(1)
plt.plot(f, S_phase,'.-')
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 367)

Cannot scale image! Could not get size from "../_images/fft-python5.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fft-python5.png'

Notez que nous voyons notre pic à 0.15Hz, qui est la fréquence que nous avons utilisée pour créer l’onde sinusoïdale. Cela signifie donc que notre FFT a fonctionné! Si nous ne connaissions pas le code utilisé pour générer cette onde sinusoïdale, mais qu’on nous donnait simplement la liste des échantillons, nous pourrions utiliser la FFT pour déterminer la fréquence. La raison pour laquelle nous voyons également un pic à -0.15 Hz est liée au fait qu’il s’agissait d’un signal réel, non complexe, et nous y reviendrons plus tard.

Fenêtrage

Lorsque nous utilisons une FFT pour mesurer les composantes de fréquence de notre signal, la FFT suppose qu’on lui fournit un morceau de signal périodique. Elle se comporte comme si le morceau de signal que nous avons fourni continuait à se répéter indéfiniment. C’est comme si le dernier échantillon de la tranche était relié au premier échantillon. Cela découle de la théorie de la transformation de Fourier. Cela signifie que nous voulons éviter les transitions soudaines entre le premier et le dernier échantillon, car les transitions soudaines dans le domaine temporel ressemblent à la somme de nombreuses hautes fréquences, alors qu’en réalité, c’est juste notre dernier échantillon qui ne se « connecte » pas à notre premier échantillon. Pour dire plus simplemenet: si nous faisons une FFT de 100 échantillons, en utilisant np.fft.fft(x), nous voulons que x[0] et x[99] soient égaux ou proches en valeur.

La façon dont nous compensons cette propriété cyclique est le « fenêtrage ». Juste avant la FFT, nous multiplions la tranche de signal par une fonction de fenêtrage, c’est-à-dire n’importe quelle fonction qui se tend vers zéro aux deux extrémités. Cela garantit que la tranche de signal commencera et finira à zéro, formant ainsi une transition lisse. Les fonctions de fenêtre courantes sont Hamming, Hanning, Blackman et Kaiser. Lorsque vous n’appliquez aucun fenêtrage, on dit que vous utilisez une fenêtre « rectangulaire » car cela revient à multiplier par un rectangle de uns. Voici à quoi ressemblent plusieurs fonctions de fenêtrage :

Une approche simple pour les débutants est de s’en tenir à une fenêtre de Hamming, qui peut être créée en Python avec np.hamming(N) où N est le nombre d’éléments du tableau, qui est tout simplement la taille de votre FFT. Dans l’exercice ci-dessus, nous appliquerons la fenêtre juste avant la FFT. Après la 2ème ligne de code, nous insérons :

s = s * np.hamming(100)

Si vous avez peur de choisir la mauvaise fenêtre, ne le soyez pas. Les différences entre les fenêtres de Hamming, Hanning, Blackman et Kaiser sont très minimes par rapport à l’absence totale de fenêtre, car elles tendent toutes à zéro des deux côtés et résolvent le problème de discontinuité.

Taille de la FFT

La dernière chose à pointer est la taille de la FFT. La meilleure taille de FFT est toujours une puissance de 2 en raison de la façon dont la FFT est implémentée. Vous pouvez utiliser une taille qui n’est pas d’un ordre de 2, mais elle ne sera plus rapide. Les tailles courantes se situent entre 128 et 4 096, mais vous pouvez certainement aller plus loin. Dans la pratique, nous pouvons être amenés à traiter des signaux de plusieurs millions ou milliards d’échantillons, ce qui nous oblige à décomposer le signal et à effectuer de nombreuses FFT. Cela signifie que nous obtiendrons de nombreuses sorties. Nous pouvons soit en faire la moyenne, soit les tracer dans le temps (en particulier lorsque notre signal évolue dans le temps). Il n’est pas nécessaire de soumettre chaque échantillon d’un signal à une FFT pour obtenir une bonne représentation du domaine fréquentiel de ce dernier. Par exemple, vous pouvez ne soumettre à la FFT que 1024 échantillons sur 100k d’un signal et le résultat sera probablement satisfaisant, à condition que le signal soit toujours présent.

Spectrogramme

Un spectrogramme est le graphique qui montre l’évolution des fréquences en fonction du temps. Il s’agit simplement d’un ensemble de FFT empilées ensemble (verticalement, si vous voulez que la fréquence soit sur l’axe horizontal). On peut aussi le visualiser en temps réel. L’analyseur de spectre est l’appareil qui permet d’afficher ce spectrogramme. Voici un exemple de spectrogramme, avec la fréquence sur l’axe horizontal et le temps sur l’axe vertical. Le bleu représente l’amplitude la plus faible et le rouge la plus élevée. Nous pouvons voir qu’il y a un pic fort à DC (0 Hz) au centre avec un signal variable autour de lui. Le bleu représente notre plancher de bruit.

System Message: WARNING/2 (/home/vs/SPHINX/source/frequency_domain.rst, line 406)

Cannot scale image! Could not get size from "../_images/waterfall.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/waterfall.png'

Comme exercice, essayez d’écrire le code Python nécessaire pour produire un spectrogramme. N’oubliez pas qu’il s’agit simplement de « rangées » de FFT empilées les unes sur les autres, chaque rangée représentant une FFT. Veillez à découper votre signal d’entrée en tranches de la taille de votre FFT (par exemple, 1024 échantillons par tranche). Pour simplifier les choses, vous pouvez rentrer un signal réel et éliminer simplement la moitié négative des fréquences avant de tracer le spectrogramme. Voici un exemple de signal que vous pouvez utiliser, il s’agit simplement d’un tonnalité dans un bruit blanc:

import numpy as np
import matplotlib.pyplot as plt

sample_rate = 1e6

# Générer une tonnalité plus un bruit
t = np.arange(1024*1000)/sample_rate # vecteur de temps
f = 50e3 # fréquence de la tonnalité
x = np.sin(2*np.pi*f*t) + 0.2*np.random.randn(len(t))

Voici à quoi cela ressemble dans le domaine temporel (200 premiers échantillons) :

Exemple de code de spectrogramme (essayez d'abord de l'écrire vous-même !)

# simuler le signal ci-dessus, ou utiliser votre propre signal

fft_size = 1024
num_rows = int(np.floor(len(x)/fft_size))
spectrogram = np.zeros((num_rows, fft_size))
for i in range(num_rows):
    spectrogram[i,:] = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(x[i*fft_size:(i+1)*fft_size])))**2)
spectrogram = spectrogram[:,fft_size//2:] # se débarrasser des fréquences négatives parce que nous avons simulé un signal réel

plt.imshow(spectrogram, aspect='auto', extent = [0, sample_rate/2/1e6, 0, len(x)/sample_rate])
plt.xlabel("Frequency [MHz]")
plt.ylabel("Time [s]")
plt.show()

Ce qui devrait produire le spectrogramme suivant, qui n’est pas le plus intéressant car il n’y a pas de comportement variable dans le temps. Comme exercice supplémentaire, essayez d’ajouter un comportement variable dans le temps, par exemple en faisant en sorte que la tonnalité commence et s’arrête.

Fichiers IQ et SigMF

Dans tous nos exemples Python précédents, nous avons stocké les signaux sous forme de tableaux NumPy 1D de type « flottants complexes ». Dans ce chapitre, nous apprenons comment les signaux peuvent être stockés dans un fichier, puis relus dans Python, et nous présentons la norme SigMF. Le stockage des données de signaux dans un fichier est extrêmement utile ; vous pouvez souhaiter enregistrer un signal dans un fichier afin de l’analyser manuellement hors ligne, de le partager avec un collègue ou de constituer un ensemble de données complet.

Fichiers binaires

Rappelons qu’un signal numérique en bande de base est une séquence de nombres complexes.

Exemple : [0.123 + j0.512, 0.0312 + j0.4123, 0.1423 + j0.06512, …]

Ces nombres correspondent à [I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, …].

Lorsque nous voulons enregistrer des nombres complexes dans un fichier, nous les enregistrons au format IQIQIQIQIQIQIQIQ. C’est-à-dire que nous stockons un tas de flottants dans une rangée, et lorsque nous les relisons, nous devons les séparer en [I+jQ, I+jQ, …].

Bien qu’il soit possible de stocker les nombres complexes dans un fichier texte ou un fichier csv, nous préférons les enregistrer dans ce que l’on appelle un « fichier binaire » pour gagner de l’espace. À des taux d’échantillonnage élevés, vos enregistrements de signaux peuvent facilement atteindre plusieurs Go, et nous voulons être aussi économes en mémoire que possible. Si vous avez déjà ouvert un fichier dans un éditeur de texte et qu’il semblait incompréhensible comme la capture d’écran ci-dessous, il s’agissait probablement d’un fichier binaire. Les fichiers binaires contiennent une série d’octets, et vous devez garder la trace du format vous-même. Les fichiers binaires sont le moyen le plus efficace de stocker des données, en supposant que toutes les compressions possibles ont été effectuées. Étant donné que nos signaux ressemblent généralement à une séquence aléatoire de flottants, nous n’essayons généralement pas de compresser les données. Les fichiers binaires sont utilisés pour beaucoup d’autres choses, par exemple pour les programmes compilés (appelés « binaires »). Lorsqu’ils sont utilisés pour enregistrer des signaux, nous les appelons « fichiers IQ » binaires, en utilisant l’extension de fichier .iq.

System Message: WARNING/2 (/home/vs/SPHINX/source/iq_files.rst, line 23)

Cannot scale image! Could not get size from "../_images/binary_file.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/binary_file.png'

En Python, le type complexe par défaut est np.complex128, qui utilise deux flottants de 64 bits par échantillon. Mais en DSP/SDR, nous avons tendance à utiliser des flottants de 32 bits à la place, car les CAN de nos SDR n’ont pas tant de précision que cela pour justifier des flottants de 64 bits. En Python, nous utiliserons np.complex64, qui utilise deux flottants de 32 bits. Lorsque vous traitez simplement un signal en Python, cela n’a pas vraiment d’importance, mais lorsque vous allez enregistrer le tableau 1d dans un fichier, vous voulez d’abord vous assurer qu’il s’agit d’un tableau de np.complex64.

Exemples Python

En Python, et spécifiquement en numpy, nous utilisons la fonction tofile() pour enregistrer un tableau numpy dans un fichier. Voici un court exemple de création d’un simple signal BPSK plus du bruit et de son enregistrement dans un fichier dans le même répertoire que celui à partir duquel nous avons exécuté notre script : .. code-block:: python

import numpy as np import matplotlib.pyplot as plt

num_symbols = 10000

x_symbols = np.random.randint(0, 2, num_symbols)*2-1 # -1 et 1 n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # AWGN de puissance unitaire r = x_symbols + n * np.sqrt(0.01) # puissance du bruit de 0.01 print(r) plt.plot(np.real(r), np.imag(r), “.”) plt.grid(True) plt.show()

# Sauvegarder le fichier IQ print(type(r[0])) # Vérifier le type de données. Oups, c’est 128 et non 64 ! r = r.astype(np.complex64) # Convertir en64 print(type(r[0])) # Verifier que c’est bien 64 r.tofile(“bpsk_in_noise.iq”) # Sauvegarder le fichier

Maintenant, examinez les détails du fichier produit et vérifiez combien d’octets il contient. Ce devrait être num_symbols * 8 parce que nous avons utilisé np.complex64, ce qui représente 8 octets par échantillon, 4 octets par flottant (2 flottants par échantillon).

En utilisant un nouveau script Python, nous pouvons lire ce fichier en utilisant np.fromfile(), comme ceci :

import numpy as np
import matplotlib.pyplot as plt

samples = np.fromfile('bpsk_in_noise.iq', np.complex64) # Lire dans le fichier.  Nous devons lui dire quel est son format
print(samples)

# Tracez la constellation pour vous assurer qu'elle est correcte
plt.plot(np.real(samples), np.imag(samples), '.')
plt.grid(True)
plt.show()

Une grosse erreur est d’oublier d’indiquer à np.fromfile() le format du fichier. Les fichiers binaires n’incluent aucune information sur leur format. Par défaut, np.fromfile() suppose qu’il lit un tableau de float64s.

La plupart des autres langages ont des méthodes pour lire les fichiers binaires, par exemple, dans MATLAB vous pouvez utiliser fread(). Pour l’analyse visuelle d’un fichier RF, voir la section ci-dessous.

Si vous vous retrouvez un jour à traiter des int16 (alias ints courts), ou tout autre type de données pour lequel numpy n’a pas d’équivalent complexe, vous serez obligé de lire les échantillons en tant que réels, même s’ils sont en fait complexes. L’astuce est de les lire en tant que réels, mais ensuite de les entrelacer dans le format IQIQIQ… vous-même, quelques manières différentes de le faire sont montrées ci-dessous :

samples = np.fromfile('iq_samples_as_int16.iq', np.int16).astype(np.float32).view(np.complex64)

or

samples = np.fromfile('iq_samples_as_int16.iq', np.int16)
samples /= 32768 # convertir en -1 en +1 (facultatif)
samples = samples[::2] + 1j*samples[1::2] # convertir en IQIQIQ...

Analyse visuelle d’un fichier RF

Bien que nous ayons appris à créer notre propre tracé de spectrogramme dans le chapitre Domaine fréquentiel, rien ne vaut l’utilisation d’un logiciel déjà créé, et quand il s’agit d’analyser un long enregistrement RF, je recommande d’utiliser inspectrum [https://github.com/miek/inspectrum]. Inspectrum est un outil graphique assez simple mais puissant pour balayer visuellement un fichier RF, avec un contrôle fin sur la gamme de cartes de couleurs et la taille de la FFT (quantité de zoom). Vous pouvez maintenir la touche alt et utiliser la molette de défilement pour vous déplacer dans le temps. Il dispose de curseurs optionnels pour mesurer le delta-temps entre deux rafales d’énergie, et la possibilité d’exporter une tranche du fichier RF dans un nouveau fichier. Pour l’installation sur des plateformes basées sur Debian comme Ubuntu, utilisez les commandes suivantes :

sudo apt-get install qt5-default libfftw3-dev cmake pkg-config libliquid-dev
git clone https://github.com/miek/inspectrum.git
cd inspectrum
mkdir build
cd build
cmake ..
make
sudo make install
inspectrum

System Message: WARNING/2 (/home/vs/SPHINX/source/iq_files.rst, line 109)

Cannot scale image! Could not get size from "../_images/inspectrum.jpg": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/inspectrum.jpg'

Valeurs maximales et saturation

Lorsque vous recevez des échantillons d’un SDR, il est important de connaître la valeur maximale de l’échantillon. De nombreux SDR émettent les échantillons sous forme de flottants avec une valeur maximale de 1.0 et une valeur minimale de -1.0. D’autres SDR vous donneront des échantillons sous forme d’entiers, généralement 16 bits, auquel cas les valeurs max et min seront +32767 et -32768 (sauf indication contraire), et vous pouvez choisir de diviser par 32 768 pour les convertir en flottants de -1,0 à 1,0. La raison pour laquelle il faut connaître la valeur maximale de votre SDR est due à la saturation : lors de la réception d’un signal extrêmement fort (ou si le gain est réglé trop haut), le récepteur va « saturer » et il va tronquer les valeurs élevées à la valeur maximale de l’échantillon. Les CANs de nos SDRs ont un nombre limité de bits. Lorsque vous créez une application SDR, il est sage de toujours vérifier la saturation, et lorsque cela se produit, vous devez l’indiquer d’une manière ou d’une autre. Un signal qui est saturé aura l’air perturbé dans le domaine temporel, comme ceci :

System Message: WARNING/2 (/home/vs/SPHINX/source/iq_files.rst, line 120)

Cannot scale image! Could not get size from "../_images/saturated_time.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/saturated_time.png'

En raison des changements soudains dans le domaine temporel, dus à la troncature, le domaine fréquentiel peut sembler étalé. En d’autres termes, le domaine des fréquences comprendra de fausses caractéristiques, des caractéristiques résultant de la saturation et ne faisant pas réellement partie du signal, ce qui peut déconcerter les gens lors de l’analyse d’un signal.

SigMF et l’annotation des fichiers IQ

Comme le fichier IQ lui-même n’est associé à aucune métadonnée, il est courant d’avoir un second fichier contenant des informations sur le signal, portant le même nom de fichier mais une extension .txt ou autre. Ces informations devraient au minimum inclure la fréquence d’échantillonnage utilisée pour collecter le signal, et la fréquence sur laquelle le SDR était accordé. Après l’analyse du signal, le fichier de métadonnées peut inclure des informations sur les plages d’échantillonnage des caractéristiques intéressantes, telles que les rafales d’énergie. L’index d’échantillon est simplement un nombre entier qui commence à 0 et s’incrémente à chaque échantillon complexe. Si vous savez qu’il y a de l’énergie entre les échantillons 492342 et 528492, vous pouvez lire le fichier et extraire cette partie du tableau : samples[492342:528493].

Heureusement, il existe désormais une norme ouverte qui spécifie un format de métadonnées utilisé pour décrire les enregistrements de signaux, connue sous le nom de SigMF [https://github.com/sigmf/SigMF]. En utilisant une norme ouverte comme SigMF, de multiples parties peuvent partager des enregistrements RF plus facilement, et utiliser différents outils pour opérer sur les mêmes ensembles de données. Cela permet également d’éviter le « bitrot » des ensembles de données RF où les détails de la capture sont perdus au fil du temps en raison de détails de l’enregistrement qui ne sont pas colocalisés avec l’enregistrement lui-même. La façon la plus simple (et minimale) d’utiliser le standard SigMF pour décrire un fichier IQ binaire que vous avez créé est de renommer le fichier .iq en .sigmf-data et de créer un nouveau fichier avec le même nom mais l’extension .sigmf-meta, et de s’assurer que le champ datatype dans le méta-fichier correspond au format binaire de votre fichier de données. Ce fichier méta est un fichier en texte clair rempli de json, vous pouvez donc simplement l’ouvrir avec un éditeur de texte et le remplir manuellement (nous verrons plus tard comment le faire de manière automatique). Voici un exemple de fichier .sigmf-meta que vous pouvez utiliser comme modèle :

{
    "global": {
        "core:datatype": "cf32_le",
        "core:sample_rate": 1000000,
        "core:hw": "PlutoSDR with 915 MHz whip antenna",
        "core:author": "Art Vandelay",
        "core:version": "1.0.0"
    },
    "captures": [
        {
            "core:sample_start": 0,
            "core:frequency": 915000000
        }
    ],
    "annotations": []
}

Notez que core:cf32_le indique que votre fichier .sigmf-data est de type IQIQIQIQ… avec des flottants 32 bits, c’est-à-dire np.complex64 comme nous l’avons utilisé précédemment. Référez-vous aux spécifications pour les autres types de données disponibles, par exemple si vous avez des données réelles au lieu de complexes, ou si vous utilisez des entiers 16 bits au lieu de flottants pour gagner de la place.

En dehors du type de données, les lignes les plus importantes à remplir sont core:sample_rate et core:frequency. Il est bon de saisir également des informations sur le matériel (core:hw) utilisé pour capturer l’enregistrement, comme le type de SDR et l’antenne, ainsi qu’une description de ce que l’on sait du ou des signaux dans l’enregistrement dans core:description. Le core:version est simplement la version de la norme SigMF utilisée au moment de la création du fichier de métadonnées.

Si vous capturez votre enregistrement RF à partir de Python, par exemple en utilisant l’API Python pour votre SDR, vous pouvez éviter de devoir créer manuellement ces fichiers de métadonnées en utilisant le paquetage SigMF Python. Celui-ci peut être installé sur un système d’exploitation basé sur Ubuntu/Debian comme suit :

pip install sigmf

Le code Python permettant d’écrire le fichier .sigmf-meta pour l’exemple du début de ce chapitre, où nous avons enregistré bpsk_in_noise.iq, est présenté ci-dessous :

import datetime as dt

import numpy as np
import sigmf
from sigmf import SigMFFile

# <code pour exemple>

# r.tofile('bpsk_in_noise.iq')
r.tofile('bpsk_in_noise.sigmf-data') # remplacer la ligne ci-dessus par celle-ci

# créer les metadata
meta = SigMFFile(
    data_file='bpsk_in_noise.sigmf-data', # extension optionalle
    global_info = {
        SigMFFile.DATATYPE_KEY: 'cf32_le',
        SigMFFile.SAMPLE_RATE_KEY: 8000000,
        SigMFFile.AUTHOR_KEY: 'Your name and/or email',
        SigMFFile.DESCRIPTION_KEY: 'Simulation of BPSK with noise',
        SigMFFile.VERSION_KEY: sigmf.__version__,
    }
)

# créer une clé de capture à l'index temporel 0
meta.add_capture(0, metadata={
    SigMFFile.FREQUENCY_KEY: 915000000,
    SigMFFile.DATETIME_KEY: dt.datetime.now(dt.timezone.utc).isoformat(),
})

# vérifier les erreurs et écrire sur le disque
assert meta.validate()
meta.tofile('bpsk_in_noise.sigmf-meta') # extension optionalle

Remplacez simplement 8000000 et 915000000 par les variables que vous avez utilisées pour stocker respectivement la fréquence d’échantillonnage et la fréquence centrale.

Pour lire un enregistrement SigMF dans Python, utilisez le code suivant. Dans cet exemple, les deux fichiers SigMF doivent être nommés bpsk_in_noise.sigmf-meta et bpsk_in_noise.sigmf-data.

from sigmf import SigMFFile, sigmffile

# charger les données
filename = 'bpsk_in_noise'
signal = sigmffile.fromfile(filename)
samples = signal.read_samples().view(np.complex64).flatten()
print(samples[0:10]) # examinons les 10 premiers échantillons

# Obtenir certaines métadonnées et toutes les annotations
sample_rate = signal.get_global_field(SigMFFile.SAMPLE_RATE_KEY)
sample_count = signal.sample_count
signal_duration = sample_count / sample_rate

Pour plus de détails, voir la référence the SigMF Python documentation [https://github.com/sigmf/sigmf-python].

Un petit bonus pour ceux qui ont lu jusqu’ici: le logo SigMF est en fait stocké comme un enregistrement SigMF lui-même, et quand le signal est tracé comme une constellation (IQ plot) dans le temps, il produit l’animation suivante :

System Message: WARNING/2 (/home/vs/SPHINX/source/iq_files.rst, line 224)

Cannot scale image! Could not get size from "../_images/sigmf_logo.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/sigmf_logo.gif'

Le code Python utilisé pour lire le fichier du logo (situé ici [https://github.com/sigmf/SigMF/tree/main/logo]) et produire le gif animé ci-dessus est présenté ci-dessous, pour les curieux :

from pathlib import Path
from tempfile import TemporaryDirectory

import numpy as np
import matplotlib.pyplot as plt
import imageio.v3 as iio
from sigmf import SigMFFile, sigmffile

# charger les données
filename = 'sigmf_logo' # supposez qu'il se trouve dans le même répertoire que ce script
signal = sigmffile.fromfile(filename)
samples = signal.read_samples().view(np.complex64).flatten()

# Ajoutez des zéros à la fin pour que ce soit clair lorsque l'animation se répète.
samples = np.concatenate((samples, np.zeros(50000)))

sample_count = len(samples)
samples_per_frame = 5000
num_frames = int(sample_count/samples_per_frame)

with TemporaryDirectory() as temp_dir:
   filenames = []
   output_dir = Path(temp_dir)
   for i in range(num_frames):
       print(f"frame {i} out of {num_frames}")
       # tracer le cadre
       fig, ax = plt.subplots(figsize=(5, 5))
       samples_frame = samples[i*samples_per_frame:(i+1)*samples_per_frame]
       ax.plot(np.real(samples_frame), np.imag(samples_frame), color="cyan", marker=".", linestyle="None", markersize=1)
       ax.axis([-0.35,0.35,-0.35,0.35])  # garder les axes existants
       ax.set_facecolor('black')  # couleur d'arrière plan

       # Enregister la figure dans un fichier
       filename = output_dir.joinpath(f"sigmf_logo_{i}.png")
       fig.savefig(filename, bbox_inches='tight')
       plt.close()
       filenames.append(filename)

   # Créer un gif animé
   images = [iio.imread(f) for f in filenames]
   iio.imwrite('sigmf_logo.gif', images, fps=20)

Bilans de liaison

Ce chapitre couvre les bilans de liaison, dont une grande partie consiste à comprendre la puissance d’émission/réception, l’atténuation due au trajet, le gain d’antenne, le bruit et le rapport signal à bruit. Nous terminons par la construction d’un exemple de bilan de liaison pour l’ADS-B, qui sont des signaux transmis par les avions commerciaux pour partager entre autres leur position.

Introduction

Un bilan de liaison est une comptabilité de tous les gains et pertes de l’émetteur jusqu’au récepteur dans un système de communication. Les bilans de liaison décrivent une direction de la liaison sans fil. La plupart des systèmes de communication étant bidirectionnels, il doit y avoir un budget distinct pour la liaison montante et la liaison descendante. Le « résultat » du bilan de liaison vous indique approximativement le rapport signal à bruit (abrégé RSB en français SNR pour « signal to noise ratio » en anglais) que vous devez vous attendre à avoir au niveau de votre récepteur. Une analyse plus approfondie sera nécessaire pour vérifier si ce SNR est suffisamment élevé pour votre application.

Nous étudions ici les bilans de liaison non pas dans le but d’être capable de réaliser un bilan de liaison pour une situation donnée, mais pour apprendre et développer une approche système des communications sans fil.

Nous traiterons d’abord du bilan de puissance du signal reçu, puis du bilan de puissance du bruit, et enfin nous combinerons les deux pour trouver le SNR (puissance du signal divisée par la puissance du bruit).

bilan de puissance du signal

La figure ci-dessous montre le schéma le plus élémentaire d’une liaison sans fil générique. Dans ce chapitre, nous nous concentrerons sur une direction, c’est-à-dire d’un émetteur (Tx) à un récepteur (Rx). Pour un système donné, nous connaissons la puissance d’émission ; il s’agit généralement d’un paramètre de l’émetteur. Comment déterminer la puissance reçue au niveau du récepteur ?

Nous avons besoin de quatre paramètres système pour déterminer la puissance reçue, qui sont indiqués ci-dessous avec leurs abréviations courantes. Ce chapitre examinera en détail chacun d’entre eux.

Pt - puissance d’émission

Gt - Gain d’antenne d’émission

Gr - Gain d’antenne de réception

Lp - Distance entre la Tx et la Rx (c.-à-d., combien de perte dû au chemin sans fil)

Puissance d’émission \(P_{t}\)

La puissance d’émission est assez simple; il s’agit d’une valeur en watts, dBW ou dBm (rappelons que dBm est l’abréviation de dBmW). Chaque émetteur possède un ou plusieurs amplificateurs, et la puissance d’émission est principalement fonction de ces amplificateurs. Une analogie pour la puissance d’émission serait la puissance d’une ampoule électrique: plus cette puissace est élevée, plus l’ampoule transmet de lumière. Voici des exemples de puissance d’émission approximative pour différentes technologies :

Bluetooth | 10 mW | -20 dBW
WiFi | 100mW | -10 dBW
Station de base LTE | 1W | 0 dBW
Station FM | 10kW | 40 dBW

Gains d’antenne \(G_{t}\) et \(G_{r}\)

Les gains d’antenne d’émission et de réception sont cruciaux pour le calcul des bilans de liaison. Qu’est-ce que le gain d’antenne, me direz-vous? Il indique la directivité de l’antenne. Vous pouvez y faire référence en tant que gain de puissance de l’antenne, mais ne vous y trompez pas, la seule façon pour une antenne d’avoir un gain plus élevé est de diriger l’énergie dans une région plus ciblée.

Les gains seront représentés en dB (sans unité) ; n’hésitez pas à apprendre ou à vous rappeler pourquoi le dB est sans unité pour notre scénario dans le chapitre le Bruit et les dB. Généralement, les antennes sont soit omnidirectionnelles, ce qui signifie que leur puissance rayonne dans toutes les directions, soit directionnelles, ce qui signifie que leur puissance rayonne dans une direction spécifique. Si elles sont omnidirectionnelles, leur gain sera de 0 dB à 3 dB. Une antenne directionnelle aura un gain plus élevé, généralement 5 dB ou plus, et jusqu’à 60 dB environ.

System Message: WARNING/2 (/home/vs/SPHINX/source/link_budgets.rst, line 63)

Cannot scale image! Could not get size from "../_images/antenna_gain_patterns.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/antenna_gain_patterns.png'

Lorsqu’une antenne directionnelle est utilisée, elle doit être soit installée dans la bonne direction, soit fixée à un cardan mécanique. Il peut également s’agir d’un réseau phasé, qui peut être piloté électroniquement (c’est-à-dire par un logiciel).

System Message: WARNING/2 (/home/vs/SPHINX/source/link_budgets.rst, line 69)

Cannot scale image! Could not get size from "../_images/antenna_steering.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/antenna_steering.png'

Les antennes omnidirectionnelles sont utilisées lorsque pointer dans la bonne direction n’est pas possible, comme votre téléphone cellulaire ou votre ordinateur portable. Dans la 5G, les téléphones peuvent fonctionner dans les bandes de fréquences plus élevées, comme les 28 GHz (Verizon) et les 39 GHz (AT&T), à l’aide d’un réseau d’antennes et d’une orientation électronique du faisceau.

Dans un bilan de liaison, nous devons supposer que toute antenne directionnelle, qu’elle émette ou reçoive, est pointée dans la bonne direction. Si elle n’est pas pointée correctement, notre budget de liaison ne sera pas précis et il pourrait y avoir une perte de communication (par exemple, l’antenne satellite sur votre toit est frappée par un ballon de basket et se déplace). En général, nos budgets de liaison supposent des circonstances idéales tout en ajoutant une perte diverse pour tenir compte des facteurs du monde réel.

Perte due à la distance entre la Tx et la Rx \(L_{p}\)

Lorsqu’un signal se déplace dans l’air (ou le vide), sa force diminue. Imaginez que vous tenez un petit panneau solaire devant une ampoule électrique. Plus le panneau solaire est éloigné, moins l’ampoule absorbera d’énergie. Le flux est un terme de physique et de mathématiques, défini comme « la quantité de matière qui passe à travers votre objet ». Pour nous, c’est la quantité de champ électromagnétique qui passe dans notre antenne de réception. Nous voulons savoir combien de puissance est perdue, pour une distance donnée.

System Message: WARNING/2 (/home/vs/SPHINX/source/link_budgets.rst, line 82)

Cannot scale image! Could not get size from "../_images/flux.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/flux.png'

L’affaiblissement de trajectoire en espace libre (FSPL pour « Free Space Path Loss » en anglais) nous indique l’affaiblissement de trajectoire lorsqu’il n’y a pas d’obstacles pour une distance donnée. Sous sa forme générale, \(\mathrm{FSPL} = ( 4\pi d / \lambda )^2\). Cherchez sur google la formule de transmission de Friis pour plus d’informations. (Fait amusant : les signaux rencontrent une impédance de 377 ohms en se déplaçant dans l’espace libre.) Pour générer des budgets de liaison, nous pouvons utiliser cette même équation mais convertie en dB:

\[\mathrm{FSPL}_{dB} = 20 \log_{10} d + 20 \log_{10} f - 147.55 \left[ dB \right]\]

Dans les bilans de liaison, elle sera exprimée en dB, sans unité car il s’agit d’une perte. \(d\) est en mètres et représente la distance entre l’émetteur et le récepteur. \(f\) est en Hz et représente la fréquence porteuse. Il n’y a qu’un seul problème avec cette équation simple: nous n’aurons pas toujours un espace libre entre l’émetteur et le récepteur. Les fréquences rebondissent beaucoup à l’intérieur (la plupart des fréquences peuvent traverser les murs, mais pas le métal ou la maçonnerie épaisse). Dans ces situations, il existe différents modèles de non-espace libre. Un modèle courant pour les villes et les banlieues (par exemple, pour les téléphones portables) est le modèle d’Okumura-Hata :

\[L_{path} = 69.55 + 26.16 \log_{10} f - 13.82 \log_{10} h_B - C_H + \left[ 44.9 - 6.55 \log_{10} h_B \right] \log_{10} d\]

où \(L_{path}\) est l’affaiblissement sur le trajet en dB, \(h_B\) est la hauteur de l’antenne d’émission au-dessus du sol en mètres, \(f\) est la fréquence porteuse en MHz, \(d\) est la distance entre Tx et Rx en km, et \(C_H\) est appelé le « facteur de correction de la hauteur de l’antenne » et est défini en fonction de la taille de la ville et de la gamme de fréquences porteuses :

\(C_H\) pour les petites/moyennes villes:

\[C_H = 0.8 + (1.1 \log_{10} f - 0.7 ) h_M - 1.56 \log_{10} f\]

\(C_H\) pour les grandes villes quand \(f\) est en dessous de 200 MHz:

\[C_H = 8.29 ( log_{10}(1.54 h_M))^2 - 1.1\]

\(C_H\) pour les grandes villes quand \(f\) est entre 200 MHz et 1.5 GHz:

\[C_H = 3.2 ( log_{10}(11.75 h_M))^2 - 4.97\]

où \(h_M\) est la hauteur de l’antenne de réception au-dessus du sol, en mètres.

Ne vous inquiétez pas si le modèle Okumura-Hata ci-dessus vous a semblé confus; il est principalement présenté ici pour démontrer comment les modèles de perte de chemin hors espace libre sont beaucoup plus compliqués que notre simple équation en FSPL. Le résultat final de n’importe lequel de ces modèles est un nombre unique que nous pouvons utiliser pour la partie perte de chemin de notre budget de liaison. Nous nous en tiendrons à l’équation FSPL pour le reste de ce chapitre.

Pertes diverses \(L_{misc}\)

Dans notre budget de liaison, nous voulons également prendre en compte les pertes diverses. Nous les regrouperons en un seul terme, généralement entre 1 et 3 dB. Exemples de pertes diverses :

Perte de câble

Perte atmosphérique

Imperfections de pointage de l’antenne

Précipitations

Le graphique ci-dessous montre la perte atmosphérique en dB/km en fonction de la fréquence (nous serons généralement < 40 GHz). Si vous prenez le temps de comprendre l’axe des y, vous verrez que les communications à courte portée inférieures à 40 GHz et inférieures à 1 km présentent une perte atmosphérique de 1 dB ou moins, et que nous l’ignorons donc généralement. Lorsque la perte atmosphérique entre vraiment en jeu, c’est dans le cas des communications par satellite, où le signal doit parcourir plusieurs kilomètres à travers l’atmosphère.

Signal Power Equation

Il est maintenant temps d’assembler tous ces gains et pertes pour calculer la puissance de notre signal au niveau du récepteur, \(P_r\) :

\[P_r = P_t + G_t + G_r - L_p - L_{misc} \quad \mathrm{dBW}\]

Globalement, c’est une équation facile. On additionne les gains et les pertes. Certains pourraient même ne pas considérer cela comme une équation du tout. Nous montrons généralement les gains, les pertes et le total dans un tableau, comme en comptabilité, comme ceci :

Pt = 1.0 W | 0 dBW
Gt = 100 | 20.0 dB
Gr = 1 | 0 dB
Lp | -162.0 dB
Lmisc | -1.0 dB
Pr | -143.0 dBW

Bilan de puissance du bruit

Maintenant que nous connaissons la puissance du signal reçu, changeons de sujet pour parler du bruit reçu, puisque nous avons besoin des deux pour calculer le SNR. Nous pouvons trouver le bruit reçu avec un bilan de puissance de style similaire.

C’est le bon moment pour parler de l’endroit où le bruit entre dans notre liaison de communication. Réponse : Au niveau du récepteur!. Le signal n’est pas corrompu par le bruit jusqu’à ce que nous allions le recevoir. Il est extrêmement important de comprendre ce fait! De nombreux étudiants ne l’intériorisent pas vraiment et finissent par commettre une erreur stupide. Il n’y a pas de bruit qui flotte dans l’air autour de nous. Le bruit vient du fait que notre récepteur possède un amplificateur et d’autres composants électroniques qui ne sont pas parfaits et ne sont pas à 0 degré Kelvin (K).

Une formulation populaire et simple pour le bilan de bruit utilise l’approche « kTB » :

\[P_{bruit} = kTB\]

\(k\) - Constante de Boltzmann = 1,38 x 10-23 J/K = -228,6 dBW/K/Hz. Pour les curieux, la constante de Boltzmann est une constante physique qui relie l’énergie cinétique moyenne des particules dans un gaz à la température du gaz.

\(T\) - Température de bruit du système en K, largement basée sur notre amplificateur. C’est le terme le plus difficile à trouver, et il est généralement très approximatif. Vous payez en général plus pour un amplificateur avec une température de bruit plus basse.

\(B\) - Largeur de bande du signal en Hz, en supposant que vous filtrez le bruit autour de votre signal. Ainsi, un signal de liaison descendante LTE d’une largeur de 10 MHz aura \(B\) réglé sur 10 MHz, soit 70 dBHz.

En multipliant (ou en ajoutant en dB) par kTB, on obtient la puissance du bruit, c’est-à-dire le dernier terme de notre équation SNR.

SNR

Maintenant que nous avons les deux nombres, nous pouvons prendre le rapport pour trouver le SNR, (voir le chapitre le Bruit et les dB pour plus d’informations sur le SNR):

\[\mathrm{SNR} = \frac{P_{signal}}{P_{bruit}}\]

\[\mathrm{SNR_{dB}} = P_{signal\_dB} - P_{bruit\_dB}\]

Nous visons généralement un SNR > 10 dB, bien que cela dépende vraiment de l’application. En pratique, le SNR peut être vérifié en regardant la FFT du signal reçu ou en calculant la puissance avec et sans le signal présent (rappelons que variance = puissance). Plus le SNR est élevé, plus vous pouvez gérer de bits par symbole sans trop d’erreurs.

Exemple de bilan de liaison: ADS-B

L’ADS-B (Automatic Dependent Surveillance-Broadcast) est une technologie utilisée par les avions pour diffuser des signaux qui permettent de partager leur position et d’autres informations avec les stations au sol de contrôle du trafic aérien et d’autres avions. L’ADS-B est automatique en ce sens qu’il ne nécessite aucune intervention du pilote ou d’un tiers; il dépend des données du système de navigation de l’avion et d’autres calculateurs. Les messages ne sont pas cryptés (youpi !). L’équipement ADS-B est actuellement obligatoire dans certaines parties de l’espace aérien australien, tandis que les États-Unis exigent que certains avions soient équipés, en fonction de leur taille.

System Message: WARNING/2 (/home/vs/SPHINX/source/link_budgets.rst, line 202)

Cannot scale image! Could not get size from "../_images/adsb.jpg": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/adsb.jpg'

La couche physique (PHY) de l’ADS-B présente les caractéristiques suivantes :

Transmis sur 1 090 MHz

Le signal a une largeur de bande de 50 kHz (ce qui est très faible).

Modulation PPM

Les messages contiennent 15 octets de données chacun, ce qui nécessite généralement plusieurs messages.

L’accès multiple est obtenu en diffusant des messages dont la période varie aléatoirement entre 0.4 et 0.6 seconde. Cette randomisation est conçue pour empêcher les aéronefs d’avoir toutes leurs transmissions les unes au-dessus des autres (certaines peuvent encore entrer en collision, mais ce n’est pas grave).

Les antennes ADS-B sont polarisées verticalement.

La puissance d’émission varie, mais devrait être de l’ordre de 100 W (20 dBW).

Le gain de l’antenne d’émission est omnidirectionnel mais seulement dirigé vers le bas, disons 3 dB.

Les récepteurs ADS-B ont également un gain d’antenne omnidirectionnel, disons 0 dB.

L’affaiblissement dû au trajet dépend de la distance entre l’avion et notre récepteur. À titre d’exemple, il y a environ 30 km entre l’Université du Maryland (où le cours dont est issu le contenu de ce manuel a été enseigné) et l’aéroport BWI. Calculons le FSPL pour cette distance avec une fréquence de 1 090 MHz :

\[ \begin{align}\begin{aligned}\mathrm{FSPL}_{dB} = 20 \log_{10} d + 20 \log_{10} f - 147.55 \left[ \mathrm{dB} \right]\\\mathrm{FSPL}_{dB} = 20 \log_{10} 30e3 + 20 \log_{10} 1090e6 - 147.55 \left[ \mathrm{dB} \right]\\\mathrm{FSPL}_{dB} = 122.7 \left[ \mathrm{dB} \right]\end{aligned}\end{align} \]

Une autre option est de laisser \(d\) comme variable dans le budget de liaison et de déterminer à quelle distance nous pouvons entendre les signaux en fonction d’un SNR requis.

Maintenant, comme nous n’aurons certainement pas d’espace libre, ajoutons encore 3 dB de pertes diverses. Nous ferons un total de 6 dB de pertes diverses, pour prendre en compte le fait que notre antenne n’est pas bien adaptée en plus des pertes de câbles/connecteurs. Compte tenu de tous ces critères, notre bilan de liaison de signal ressemble à ceci :

Pt | 20 dBW
Gt | 3 dB
Gr | 0 dB
Lp | -122.7 dB
Lmisc | -6 dB
Pr | -105.7 dBW

Pour le bilan du bruit:

B = 50 kHz = 50e3 = 47 dBHz

T nous devons faire une approximation, disons 300 K, ce qui correspond à 24,8 dBK. Elle varie en fonction de la qualité du récepteur

k est toujours -228,6 dBW/K/Hz

\[P_{bruit} = k + T + B = -156.8 \quad \mathrm{dBW}\]

Par conséquent, notre SNR est de -105,7 - (-156,8) = 51,1 dB. Il n’est pas surprenant que ce soit un chiffre énorme, étant donné que nous prétendons être seulement à 30 km de l’avion en espace libre. Si les signaux ADS-B ne pouvaient pas atteindre 30 km, alors l’ADS-B ne serait pas un système très efficace - personne ne s’entendrait avant d’être très proche. Dans cet exemple, nous pouvons facilement décoder les signaux; la modulation par impulsions et positions (PPM) est assez robuste et ne nécessite pas un SNR aussi élevé. Ce qui est difficile, c’est d’essayer de recevoir l’ADS-B à l’intérieur d’une salle de classe, avec une antenne très mal adaptée et une forte station de radio FM à proximité qui cause des interférences. Ces facteurs peuvent facilement entraîner des pertes de 20 à 30 dB.

Cet exemple n’était en fait qu’un calcul à l’aveuglette, mais il a permis de démontrer les bases de la création d’un bilan de liaisons et de comprendre les paramètres importants d’un lien de communication.

Evanouissement par Multi-Trajets

Dans ce chapitre, nous présentons les trajets multiples, un phénomène de propagation qui fait que les signaux atteignent le récepteur par deux chemins ou plus, ce qui est le cas dans les systèmes sans fil du monde réel. Jusqu’à présent, nous n’avons abordé que le « canal AWGN », c’est-à-dire un modèle de canal sans fil où le signal est simplement ajouté au bruit, qui ne s’applique réellement qu’aux signaux transmis par câble et à certains systèmes de communication par satellite.

Multi-Trajets

Tous les canaux sans fil réalistes comportent de nombreux « réflecteurs », étant donné que les signaux RF rebondissent. Tout objet situé entre ou près de l’émetteur (Tx) ou du récepteur (Rx) peut créer des chemins supplémentaires que le signal emprunte. Chaque chemin subit un déphasage (retard) et une atténuation (réduction d’amplitude) différents. Au niveau du récepteur, tous les chemins s’additionnent. Ils peuvent s’additionner de manière constructive, destructive ou un mélange des deux. Nous appelons ce concept de trajets multiples du signal « multi-trajets ». Il y a le chemin en visibilité directe (LOS pour line of sight en anglais), puis tous les autres chemins. Dans l’exemple ci-dessous, nous montrons le chemin LOS et un seul chemin non-LOS :

Des interférences destructrices peuvent se produire si vous n’avez pas de chance avec la façon dont les chemins s’additionnent. Prenons l’exemple ci-dessus avec seulement deux trajets. Selon la fréquence et la distance exacte des trajets, les deux trajets peuvent être reçus avec un déphasage de 180 degrés et à peu près la même amplitude, ce qui les annule l’un par rapport à l’autre (voir ci-dessous). Vous avez peut-être appris l’interférence constructive et destructive en cours de physique. Dans les systèmes sans fil, lorsque les trajets se combinent de manière destructrice, nous appelons cette interférence « évanouissement profond » car notre signal disparaît brièvement.

Les trajets peuvent également s’additionner de manière constructive, entraînant la réception d’un signal fort. Chaque chemin a un déphasage et une amplitude différents, que nous pouvons visualiser sur un graphique dans le domaine temporel appelé « profil de retard de puissance »:

Le premier chemin, le plus proche de l’axe des y, sera toujours le chemin LOS (en supposant qu’il y en ait un) car aucun autre chemin ne peut atteindre le récepteur plus rapidement que le chemin LOS. En général, l’amplitude diminue lorsque le délai augmente, car un chemin qui a mis plus de temps à arriver au récepteur aura voyagé plus loin.

Evanouissement

Ce qui a tendance à se produire, c’est un mélange d’interférences constructives et destructives, et cela change au fil du temps lorsque la Rx, la Tx ou l’environnement se déplace/change. Nous utilisons le terme « evanouissement » pour désigner les effets d’un canal à trajets multiples changeant au fil du temps. C’est pourquoi nous parlons souvent d“« évanouissement par multi-trajets »; il s’agit en fait de la combinaison d’une interférence constructive/destructrice et d’un environnement changeant. Le résultat est un rapport signal/bruit qui varie dans le temps; les changements sont généralement de l’ordre de la milliseconde à la microseconde, selon la vitesse de déplacement de la Tx/Rx. Vous trouverez ci-dessous un graphique du rapport signal/bruit en fonction du temps en millisecondes qui illustre l’évanouissement par multi-trajets.

System Message: WARNING/2 (/home/vs/SPHINX/source/multipath_fading.rst, line 39)

Cannot scale image! Could not get size from "../_images/multipath_fading.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/multipath_fading.png'

Il existe deux types d’évanouissement du point de vue du domaine temporel :

Évanouissement lent: Le canal ne change pas pendant la durée d’un paquet de données. C’est-à-dire qu’un zéro pendant un évanouissement lent effacera tout le paquet.

L’évanouissement rapide: le canal change très rapidement par rapport à la longueur d’un paquet. La correction d’erreur, combinée à l’entrelacement, peut combattre l’évanouissement rapide.

Il existe également deux types d’évanouissement du point de vue du domaine fréquentiel :

Evanouissement sélectif en fréquence : L’interférence constructive/destructive change dans la gamme de fréquences du signal. Lorsque nous avons un signal à large bande, nous couvrons une large gamme de fréquences. Rappelez-vous que la longueur d’onde détermine si elle est constructive ou destructive. Si notre signal couvre une large gamme de fréquences, il couvre également une large gamme de longueurs d’onde (puisque la longueur d’onde est l’inverse de la fréquence). Par conséquent, nous pouvons obtenir différentes qualités de canal dans différentes parties de notre signal (dans le domaine fréquentiel). D’où le nom d’évanouissement sélectif en fréquence.

Evanouissement plat : se produit lorsque la largeur de bande du signal est suffisamment étroite pour que toutes les fréquences subissent à peu près le même effet du canal. S’il y a un évanouissement profond, l’ensemble du signal disparaît (pendant la durée de l’évanouissement).

Dans la figure ci-dessous, la forme :red:`rouge` montre notre signal dans le domaine fréquentiel, et la ligne courbe noire montre l’état actuel du canal sur la fréquence. Comme le signal le plus étroit connaît les mêmes effets de canal sur l’ensemble du signal, il subit un évanouissement plat. Le signal plus large subit un évanouissement sélectif en fréquence.

System Message: WARNING/2 (/home/vs/SPHINX/source/multipath_fading.rst, line 56)

Cannot scale image! Could not get size from "../_images/flat_vs_freq_selective.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/flat_vs_freq_selective.png'

Voici un exemple d’un signal de 16 MHz de large qui émet en continu. Il y a plusieurs moments au milieu où il manque une partie du signal. Cet exemple illustre l’évanouissement sélectif en fréquence, qui provoque des trous dans le signal qui effacent certaines fréquences mais pas d’autres.

System Message: WARNING/2 (/home/vs/SPHINX/source/multipath_fading.rst, line 62)

Cannot scale image! Could not get size from "../_images/fading_example.jpg": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fading_example.jpg'

Simulation de l’évanouissement de Rayleigh

L’évanouissement de Rayleigh est utilisé pour modéliser l’évanouissement dans le temps, lorsqu’il n’y a pas de trajet LOS significatif. Lorsqu’il y a un chemin LOS dominant, le modèle d’évanouissement de Rice devient plus approprié, mais nous nous concentrerons sur Rayleigh. Notez que les modèles de Rayleigh et de Rice ne tiennent pas compte de l’évanouissement sur le trajet principal entre l’émetteur et le récepteur (telle que l’atténuation sur le trajet calculée dans le cadre d’un bilan de liaison), ni de l’ombrage (shadowing en anglais) causé par de grands objets. Leur rôle est de modéliser l’évanouissement par trajets multiples qui se produit au fil du temps, en raison du mouvement et des diffuseurs (scatterers en anglais) dans l’environnement.

De nombreuses théories sont issues du modèle d’évanouissement de Rayleigh, telles que des expressions pour le durée moyenne du dépassement de l’envelope du signal d’un certain seuil et la durée moyenne d’évanouissement. Mais le modèle d’évanouissement de Rayleigh ne nous dit pas directement comment simuler un canal à l’aide de ce modèle. Pour générer des évanouissements de Rayleigh en simulation, nous devons utiliser l’une des nombreuses méthodes publiées. Dans l’exemple Python suivant, nous utiliserons la méthode « somme-de-sinusoides » de Clarke.

Pour générer un canal d’évanouissement de Rayleigh en Python, nous devons d’abord spécifier le décalage Doppler maximum, en Hz, qui est basé sur la vitesse de déplacement de l’émetteur et/ou du récepteur, noté :math:\Delta v. Lorsque la vitesse est faible par rapport à la vitesse de la lumière, ce qui sera toujours le cas dans les communications sans fil, le décalage Doppler peut être calculé comme suit:

\[f_D = \frac{\Delta v f_c}{c}\]

où \(c\) est la vitesse de la lumière, environ 3e8 m/s, et \(f_c\) est la fréquence porteuse sur laquelle la transmission est effectuée.

Nous choisissons également le nombre de sinusoïdes à simuler, et il n’y a pas de bonne réponse car elle est basée sur le nombre de diffuseurs dans l’environnement, que nous ne connaissons jamais réellement. Dans le cadre des calculs, nous supposons que la phase du signal reçu de chaque chemin est uniformément aléatoire entre 0 et \(2\pi\). Le code suivant simule un canal à évanouissement de Rayleigh en utilisant la méthode de Clarke :

import numpy as np
import matplotlib.pyplot as plt

# Paramètres de simulation, n'hésitez pas à les modifier.
v_mph = 60 # la vitesse de TX ou RX, en miles par heure
center_freq = 200e6 # Fréquence de la porteuse RF en Hz
Fs = 1e5 # fréquence d'échantillonnage de la simulation
N = 100 # nombre de sinusoïdes à additionner

v = v_mph * 0.44704 # convertir en m/s
fd = v*center_freq/3e8 # max du décallage Doppler
print("max Doppler shift:", fd)
t = np.arange(0, 1, 1/Fs) # verteur de temps. (début, fin, pas)
x = np.zeros(len(t))
y = np.zeros(len(t))
for i in range(N):
    alpha = (np.random.rand() - 0.5) * 2 * np.pi
    phi = (np.random.rand() - 0.5) * 2 * np.pi
    x = x + np.random.randn() * np.cos(2 * np.pi * fd * t * np.cos(alpha) + phi)
    y = y + np.random.randn() * np.sin(2 * np.pi * fd * t * np.cos(alpha) + phi)

# z est le coefficient complexe représentant le canal, vous pouvez penser à cela comme un déphasage et attenuation d'amplitude
z = (1/np.sqrt(N)) * (x + 1j*y) # c'est ce que vous utiliserez réellement lorsque vous simulerez le canal
z_mag = np.abs(z) # prendre la magnitude pour les besoins de la figure
z_mag_dB = 10*np.log10(z_mag) # convertir en dB

# Plot fading over time
plt.plot(t, z_mag_dB)
plt.plot([0, 1], [0, 0], ':r') # 0 dB
plt.legend(['Evanouissement de Rayleigh', 'Pas d évanouissement'])
plt.axis([0, 1, -15, 5])
plt.show()

Si vous avez l’intention d’utiliser ce modèle de canal dans le cadre d’une simulation plus large, il vous suffit de multiplier le signal reçu par le nombre complexe z, représentant l’évanouissement plat. La valeur z est alors mise à jour à chaque pas de temps. Cela signifie que toutes les composantes de fréquence du signal sont soumises au même canal à un moment donné. Vous ne simulez donc pas un évanouissement sélectif en fréquence, qui nécessite une réponse impulsionnelle du canal à plusieurs taps, que nous n’aborderons pas dans ce chapitre. Si nous regardons l’amplitude de z, nous pouvons voir l’évanouissement de Rayleigh dans le temps :

Observez les évanouissements profonds qui se produisent brièvement, ainsi que la petite fraction de temps où le canal est de meilleur qualité que s’il n’y avait pas d’évanouissement du tout.

Atténuation de l’Evanouissement Multi-Trajets

Dans les communications modernes, nous avons développé des moyens de lutter contre les évanouissements par trajets multiples.

CDMA

La 3G utilise une technologie appelée accès multiple par répartition de code (CDMA). Avec la CDMA, vous prenez un signal à bande étroite et vous l’étalez sur une large bande passante avant de le transmettre (en utilisant une technique d’étalement du spectre appelée DSSS). Dans le cas d’un évanouissement sélectif en fréquence, il est peu probable que toutes les fréquences se trouvent dans un évanouissement profond au même moment. Au niveau du récepteur, l’étalement est inversé, et ce processus de désétalement atténue grandement l’effet de l’évanouissement profonde.

System Message: WARNING/2 (/home/vs/SPHINX/source/multipath_fading.rst, line 138)

Cannot scale image! Could not get size from "../_images/cdma.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/cdma.png'

OFDM

La 4G, le WiFi et de nombreuses autres technologies utilisent un schéma appelé multiplexage par répartition orthogonale de la fréquence (OFDM). L’OFDM utilise ce que l’on appelle des sous-porteuses, qui divisent le signal dans le domaine des fréquences en un ensemble de signaux étroits et rapprochés. Pour lutter contre les évanouissements par trajets multiples, nous pouvons éviter d’assigner des données aux sous-porteuses qui sont dans un évanouissement profond, bien que cela nécessite que le récépteur renvoie les informations du canal à l’émetteur assez rapidement. Nous pouvons également affecter des schémas de modulation d’ordre élevé aux sous-porteuses dont le canal est de bonne qualité afin de maximiser notre débit de données global.

le Bruit et les dB

Dans ce chapitre, nous aborderons le bruit, notamment la façon dont il est modélisé et traité dans un système de communication sans fil. Les concepts comprennent l’AWGN, le bruit complexe et le SNR/SINR. Nous introduirons également les décibels (dB) en cours de route, car ils sont largement utilisés dans les communications sans fil et la radio logicielle.

Le Bruit Gaussien

La plupart des gens connaissent le concept de bruit: des fluctuations indésirables qui peuvent masquer le ou les signaux souhaités. Le bruit ressemble à quelque chose comme:

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 15)

Cannot scale image! Could not get size from "../_images/noise.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noise.png'

Notez que la valeur moyenne est égale à zéro dans le graphique temporel. Si la valeur moyenne n’était pas nulle, nous pourrions soustraire la valeur moyenne, l’appeler un biais, et il nous resterait une moyenne de zéro. Notez également que les points individuels du graphique ne sont pas « uniformément aléatoires », c’est-à-dire que les grandes valeurs sont plus rares et la plupart des points sont plus proches de zéro.

Nous appelons ce type de bruit « bruit gaussien ». C’est un bon modèle pour le type de bruit provenant de nombreuses sources naturelles, comme les vibrations thermiques des atomes dans le silicium des composants RF de notre récepteur. Le théorème de la limite centrale nous dit que la somme de nombreux processus aléatoires aura tendance à avoir une distribution gaussienne, même si les processus individuels ont d’autres distributions. En d’autres termes, lorsqu’un grand nombre d’événements aléatoires se produisent et s’accumulent, le résultat semble approximativement gaussien, même si les événements individuels n’ont pas une distribution gaussienne.

La distribution gaussienne est également appelée distribution « normale » (rappelons une courbe en cloche).

La distribution gaussienne possède deux paramètres: la moyenne et la variance. Nous avons déjà discuté de la façon dont la moyenne peut être considérée comme nulle, car vous pouvez toujours supprimer la moyenne, ou le biais, si elle n’est pas nulle. La variance modifie la « puissance » du bruit. Une variance plus élevée se traduira par des nombres plus importants. C’est pour cette raison que la variance définit la puissance du bruit.

La variance est égale à l’écart-type au carré (\(sigma^2\)).

Decibels (dB)

Nous allons prendre une brève pause pour présenter officiellement les dB. Vous avez peut-être déjà entendu parler de dB, et si vous êtes déjà familier avec cette technologie, vous pouvez sauter cette section.

Le travail en dB est extrêmement utile lorsque nous devons traiter simultanément des petits et des grands nombres, ou simplement un ensemble de très grands nombres. Considérez combien il serait fastidieux de travailler avec des nombres de l’échelle de l’exemple 1 et de l’exemple 2.

Exemple 1: Le signal 1 est reçu à 2 watts et le plancher de bruit est à 0.0000002 watts.

Exemple 2: un broyeur d’ordures est 100 000 fois plus bruyant qu’une zone rurale calme, et une tronçonneuse est 10 000 fois plus bruyante qu’un broyeur d’ordures (en termes de puissance des ondes sonores).

Sans dB, c’est-à-dire en travaillant en termes « linéaires » normaux, nous devons utiliser beaucoup de 0 pour représenter les valeurs des exemples 1 et 2. Franchement, si nous devions représenter quelque chose comme le signal 1 en fonction du temps, nous ne verrions même pas le plancher de bruit. Si l’échelle de l’axe des y allait de 0 à 3 watts, par exemple, le bruit serait trop faible pour apparaître sur le graphique. Pour représenter ces échelles simultanément, nous travaillons avec une échelle logarithmique.

Pour illustrer davantage les problèmes d’échelle que nous rencontrons dans le traitement du signal, considérons les cascades ci-dessous de trois des mêmes signaux. La partie gauche représente le signal original en échelle linéaire, et la partie droite montre les signaux convertis en échelle logarithmique (dB). Les deux représentations utilisent exactement la même carte de couleurs, où le bleu est la valeur la plus basse et le jaune la plus haute. Vous pouvez à peine voir le signal de gauche dans l’échelle linéaire.

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 52)

Cannot scale image! Could not get size from "../_images/linear_vs_log.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/linear_vs_log.png'

Pour une valeur x donnée, on peut représenter x en dB à l’aide de la formule suivante :

\[x_{dB} = 10 \log_{10} x\]

En Python:

x_db = 10.0 * np.log10(x)

Vous avez peut-être vu que 10 * est un 20 * dans d’autres domaines. Chaque fois que vous avez affaire à une puissance quelconque, vous utilisez 10, et vous utilisez 20 si vous avez affaire à une valeur qui n’est pas une puissance, comme la tension ou le courant. En DSP, nous avons tendance à traiter avec une puissance. En fait, il n’y a pas une seule fois dans ce manuel où nous devons utiliser 20 au lieu de 10.

Nous convertissons les dB en linéaires (nombres normaux) en utilisant:

\[x = 10^{x_{dB}/10}\]

En Python:

x = 10.0 ** (x_db / 10.0)

Ne vous laissez pas prendre par la formule, car il y a un concept clé à retenir ici. En DSP, nous traitons à la fois de très grands et de très petits nombres (par exemple, la force d’un signal par rapport à la force du bruit). L’échelle logarithmique en dB nous permet d’avoir une plus grande plage dynamique lorsque nous exprimons des nombres ou les traçons. Elle offre également certaines commodités, comme la possibilité d’additionner alors qu’on multiplierait normalement (comme nous le verrons dans le chapitre Bilans de liaison).

Les erreurs les plus courantes que les gens rencontrent lorsqu’ils sont novices en matière de dB sont les suivantes :

Utiliser le log naturel au lieu du log base 10 car la fonction log() de la plupart des langages de programmation est en fait le log naturel.

Oublier d’inclure le dB lors de l’expression d’un nombre ou de l’étiquetage d’un axe. Si nous sommes en dB, nous devons l’identifier quelque part.

Lorsque vous êtes en dB, vous additionnez/soustrayez des valeurs au lieu de multiplier/diviser, par ex:

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 88)

Cannot scale image! Could not get size from "../_images/db.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/db.png'

Il est également important de comprendre que le dB n’est pas techniquement une « unité ». Une valeur en dB seule est sans unité, comme si quelque chose était 2x plus grand, il n’y a pas d’unités jusqu’à ce que je vous dise les unités. dB est une chose relative. En audio, quand on dit dB, on veut dire dBA, qui est l’unité du niveau sonore (le A est l’unité). Dans le domaine du sans fil, nous utilisons généralement les watts pour faire référence à un niveau de puissance réel. Par conséquent, vous pouvez voir l’unité dBW, qui est relative à 1 W. Vous pouvez également voir l’unité dBmW (souvent écrite dBm pour faire court) qui est relative à 1 mW. Par exemple, quelqu’un peut dire « notre émetteur est réglé sur 3 dBW » (donc 2 watts). Parfois, nous utilisons le terme dB seul, ce qui signifie qu’il est relatif et qu’il n’y a pas d’unité. On peut dire « notre signal a été reçu 20 dB au-dessus du bruit de fond ». Voici une petite astuces: 0 dBm = -30 dBW.

Voici quelques conversions courantes que je vous recommande de mémoriser :

Linéaire | dB
1x | 0 dB
2x | 3 dB
10x | 10 dB
0.5x | -3 dB
0.1x | -10 dB
100x | 20 dB
1000x | 30 dB
10000x | 40 dB

Enfin, pour mettre ces chiffres en perspective, voici quelques exemples de niveaux de puissance, en dBm :

80 dBm | Puissance d’émission d’une station de radio FM rurale
62 dBm | Puissance maximale d’un émetteur radio amateur
60 dBm | Puissance d’un micro-ondes domestique typique
37 dBm | Puissance maximale d’une radio CB ou d’une radio amateur portative typique
27 dBm | Puissance d’émission typique d’un téléphone cellulaire
15 dBm | Puissance d’émission typique du WiFi
10 dBm | Puissance d’émission maximale du Bluetooth (version 4)
-10 dBm | Puissance de réception maximale du WiFi
-70 dBm | Exemple de puissance reçue pour un signal radio amateur
-100 dBm | Puissance minimale reçue pour le WiFi
-127 dBm | Puissance reçue typique des satellites GPS

Bruit dans le Domaine Fréquentiel

Dans le chapitre Domaine fréquentiel, nous avons abordé la correspondance temps-fréquence de la transformée de Fourier, c’est-à-dire ce à quoi ressemble un certain signal dans le domaine temporel dans le domaine fréquentiel. Et bien, à quoi ressemble un bruit gaussien dans le domaine des fréquences? Les graphiques suivants montrent un certain bruit simulé dans le domaine temporel (en haut) et un tracé de la densité spectrale de puissance (DSP) de ce bruit (en bas). Ces graphiques sont tirés de GNU Radio.

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 132)

Cannot scale image! Could not get size from "../_images/noise_freq.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noise_freq.png'

Nous pouvons voir qu’il a à peu près la même apparence sur toutes les fréquences et qu’il est assez plat. Il s’avère que le bruit gaussien dans le domaine temporel est également un bruit gaussien dans le domaine fréquentiel. Alors pourquoi les deux graphiques ci-dessus ne sont-ils pas identiques? C’est parce que le graphique du domaine fréquentiel montre l’amplitude de la FFT, donc il n’y aura que des nombres positifs. Il est important de noter qu’il utilise une échelle logarithmique, c’est-à-dire qu’il indique l’amplitude en dB. Sinon, ces graphiques se ressembleraient. Nous pouvons le prouver par nous-mêmes en générant un peu de bruit (dans le domaine temporel) dans Python, puis en prenant la FFT.

import numpy as np
import matplotlib.pyplot as plt

N = 1024 # nombre d'échantillons à simuler, choisissez le nombre que vous voulez
x = np.random.randn(N)
plt.plot(x, '.-')
plt.show()

X = np.fft.fftshift(np.fft.fft(x))
X = X[N//2:] # ne regardez que les fréquences positives. rappelez-vous que // est juste un diviseur d'entier
plt.plot(np.real(X), '.-')
plt.show()

Notez que la fonction randn() utilise par défaut la moyenne = 0 et la variance = 1. Les deux graphiques ressembleront à ceci :

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 155)

Cannot scale image! Could not get size from "../_images/noise_python.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noise_python.png'

Vous pouvez alors produire le DSP plate que nous avions dans GNU Radio en prenant le log et en faisant la moyenne de plusieurs d’entre eux. Le signal que nous avons généré et dont nous avons fait la FFT était un signal réel (par opposition à un signal complexe), et la FFT de tout signal réel aura des parties négatives et positives correspondantes, c’est pourquoi nous n’avons sauvegardé que la partie positive de la sortie FFT (la seconde moitié). Mais pourquoi n’avons-nous généré que du bruit « réel », et comment les signaux complexes entrent-ils en jeu ?

Bruit Complexe

Le bruit « gaussien complexe » est celui que nous rencontrons lorsque nous avons un signal en bande de base; la puissance du bruit est répartie de manière égale entre les parties réelles et imaginaires. Et surtout, les parties réelle et imaginaire sont indépendantes l’une de l’autre; connaître les valeurs de l’une ne vous donne pas les valeurs de l’autre.

Nous pouvons générer un bruit gaussien complexe en Python en utilisant :

n = np.random.randn() + 1j * np.random.randn()

Mais attendez! L’équation ci-dessus ne génère pas la même « quantité » de bruit que np.random.randn(), en termes de puissance (appelée puissance du bruit). Nous pouvons trouver la puissance moyenne d’un signal (ou d’un bruit) de moyenne nulle en utilisant :

power = np.var(x)

où np.var() est la fonction de variance. Ici, la puissance de notre signal n est 2. Afin de générer un bruit complexe avec une « puissance unitaire », c’est-à-dire une puissance de 1 (ce qui rend les choses plus pratiques), nous devons utiliser:

n = (np.random.randn(N) + 1j*np.random.randn(N))/np.sqrt(2) # AWGN de puissance unitaire

Pour tracer un bruit complexe dans le domaine temporel, comme tout signal complexe, nous avons besoin de deux lignes:

n = (np.random.randn(N) + 1j*np.random.randn(N))/np.sqrt(2)
plt.plot(np.real(n),'.-')
plt.plot(np.imag(n),'.-')
plt.legend(['real','imag'])
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 195)

Cannot scale image! Could not get size from "../_images/noise3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noise3.png'

Vous pouvez voir que les parties réelles et imaginaires sont complètement indépendantes.

À quoi ressemble un bruit gaussien complexe sur un graphique IQ? Rappelez-vous que le graphique IQ montre la partie réelle (axe horizontal) et la partie imaginaire (axe vertical), qui sont toutes deux des gaussiennes aléatoires indépendantes.

plt.plot(np.real(n),np.imag(n),'.')
plt.grid(True, which='both')
plt.axis([-2, 2, -2, 2])
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 210)

Cannot scale image! Could not get size from "../_images/noise_iq.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noise_iq.png'

Il ressemble à ce que l’on pourrait attendre: une tache aléatoire centrée sur 0 + 0j, ou l’origine. Juste pour le plaisir, essayons d’ajouter du bruit à un signal QPSK pour voir à quoi ressemble le graphique IQ :

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 216)

Cannot scale image! Could not get size from "../_images/noisey_qpsk.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noisey_qpsk.png'

Maintenant, que se passe-t-il quand le bruit est plus fort ?

System Message: WARNING/2 (/home/vs/SPHINX/source/noise.rst, line 222)

Cannot scale image! Could not get size from "../_images/noisey_qpsk2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/noisey_qpsk2.png'

Nous commençons à comprendre pourquoi la transmission de données sans fil n’est pas si simple. Nous voulons envoyer autant de bits par symbole que possible, mais si le bruit est trop élevé, nous aurons des bits erronés à la réception.

AWGN

Le bruit blanc additif gaussien ou AWGN (pour Additive White Gaussian Noise en anglais) est une abréviation que vous entendrez souvent dans le monde du DSP et de la SDR. Le GN, qui veut dire bruit gaussien, nous en avons déjà parlé. Additif signifie simplement que le bruit est ajouté à notre signal reçu. Blanc, dans le domaine des fréquences, signifie que le spectre est plat sur toute la bande d’observation. En pratique, il sera presque toujours blanc, ou approximativement blanc. Dans ce manuel, nous utiliserons le bruit AWGN comme seule forme de bruit lorsque nous traiterons des liaisons de communication, des budgets de liaison, etc. Le bruit non-AWGN tend à être un sujet de niche.

SNR et SINR

Le rapport signal à bruit (RSB) ou (SNR) (pour signal to noise ratio en anglais) est la façon dont nous allons mesurer les différences d’intensité entre le signal et le bruit. Il s’agit d’un rapport, donc sans unité. Le SNR est presque toujours exprimé en dB dans la pratique. Souvent, en simulation, nous codons de manière à ce que nos signaux soient d’une puissance unitaire (puissance = 1). Ainsi, nous pouvons créer un rapport signal à bruit de 10 dB en produisant un bruit d’une puissance de -10 dB en ajustant la variance lorsque nous générons le bruit.

\[\mathrm{SNR} = \frac{P_{signal}}{P_{noise}}\]

\[\mathrm{SNR_{dB}} = P_{signal\_dB} - P_{noise\_dB}\]

Si quelqu’un dit « SNR = 0 dB », cela signifie que la puissance du signal et du bruit est la même. Un SNR positif signifie que notre signal est plus puissant que le bruit, tandis qu’un SNR négatif signifie que le bruit est plus puissant. La détection de signaux à un SNR négatif est généralement assez difficile.

Comme nous l’avons mentionné précédemment, la puissance d’un signal est égale à la variance du signal. Nous pouvons donc représenter le SNR comme le rapport entre la variance du signal et la variance du bruit:

\[\mathrm{SNR} = \frac{P_{signal}}{P_{noise}} = \frac{\sigma^2_{signal}}{\sigma^2_{noise}}\]

Le rapport signal à interférence plus bruit (SINR pour signal to interference and noise ration en anglais) est essentiellement le même que le SNR, sauf que vous incluez l’interférence avec le bruit dans le dénominateur.

\[\mathrm{SINR} = \frac{P_{signal}}{P_{interference} + P_{noise}}\]

La définition de l’interférence dépend de l’application/de la situation, mais il s’agit généralement d’un autre signal qui interfère avec le signal d’intérêt, qui le empiète sur lui dans le domaine fréquentiel, et/ou qui ne peut pas être filtré pour une raison quelconque.

Ressources Externes (en anglais)

Autres ressources sur l’AWGN, le SNR et la variance:

https://en.wikipedia.org/wiki/Additive_white_Gaussian_noise

https://en.wikipedia.org/wiki/Signal-to-noise_ratio

https://en.wikipedia.org/wiki/Variance

PlutoSDR en Python

System Message: WARNING/2 (/home/vs/SPHINX/source/pluto.rst, line 7)

Cannot scale image! Could not get size from "../_images/pluto.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pluto.png'

Dans ce chapitre, nous apprenons à utiliser l’API Python pour le PlutoSDR [https://www.analog.com/en/design-center/evaluation-hardware-and-software/evaluation-boards-kits/adalm-pluto.html], qui est une radio SDR à faible coût d’Analog Devices. Nous couvrirons les étapes d’installation du PlutoSDR afin de faire fonctionner les pilotes et le logiciel, puis nous discuterons de la transmission et de la réception avec le PlutoSDR en Python.

Installation de logiciels/pilotes

Configuration de la VM

Bien que le code Python fourni dans ce manuel devrait fonctionner sous Windows, Mac et Linux, les instructions d’installation ci-dessous sont spécifiques à Ubuntu 18. Si vous avez des difficultés à installer le logiciel sur votre OS en suivant les instructions fournies par Analog Devices [https://wiki.analog.com/university/tools/pluto/users/quick_start], je vous recommande d’installer une VM Ubuntu 18 et d’essayer les instructions ci-dessous.

Installez et ouvrez VirtualBox [https://www.virtualbox.org/wiki/Downloads].

Créez une nouvelle VM. Pour la taille de la mémoire, je recommande d’utiliser 50% de la RAM de votre ordinateur.

Créez le disque dur virtuel, choisissez VDI, et allouez dynamiquement la taille. 15 Go devraient suffire. Si vous voulez être vraiment sûr, vous pouvez utiliser plus.

Téléchargez Ubuntu 18 Desktop .iso- http://releases.ubuntu.com/18.04/

Démarrez la VM. Il vous demandera le support d’installation. Choisissez le fichier .iso du bureau Ubuntu 18. Choisissez « installer ubuntu », utilisez les options par défaut, et une fenêtre pop-up vous avertira des changements que vous êtes sur le point d’effectuer. Cliquez sur continuer. Choisissez le nom/mot de passe et attendez que la VM finisse de s’initialiser. Après avoir terminé, la VM va redémarrer, mais vous devez éteindre la VM après le redémarrage.

Allez dans les paramètres de la VM (l’icône de l’engrenage).

Sous système > processeur > choisissez au moins 3 processeurs. Si vous avez une carte vidéo réelle, alors dans affichage > mémoire vidéo > choisissez quelque chose de beaucoup plus élevé.

Démarrez votre VM.

Je recommande d’installer des addons d’invités VM. Dans la VM, allez dans Devices > Insert Guest Additions CD > cliquez sur run quand une boîte apparaît. Suivez les instructions. Redémarrez la VM. Le presse-papiers partagé peut être activé via Dispositifs > Presse-papiers partagé > Bidirectionnel.

Connecter la PlutoSDR

Si vous utilisez OSX, dans OSX, et non dans la VM, dans les préférences système, activez « kernel extensions ». Puis installez HoRNDIS (vous devrez peut-être redémarrer après).

Si vous utilisez Windows, installez ce pilote : https://github.com/analogdevicesinc/plutosdr-m2k-drivers-win/releases/download/v0.7/PlutoSDR-M2k-USB-Drivers.exe

Si vous utilisez Linux, vous ne devriez pas avoir à faire quoi que ce soit de spécial.

Branchez Pluto sur la machine hôte via le port USB. Veillez à utiliser le port USB central de Pluto, car l’autre ne sert qu’à l’alimentation. Le branchement de Pluto devrait créer une carte réseau virtuelle, c’est-à-dire que Pluto apparaît comme un adaptateur Ethernet USB.

Sur la machine hôte (pas la VM), ouvrez un terminal ou votre outil ping préféré et effectuez un ping sur 192.168.2.1. Si cela ne fonctionne pas, arrêtez et déboguez l’interface réseau.

Dans la VM, ouvrez un nouveau terminal

Effectuez un Ping sur 192.168.2.1. Si cela ne fonctionne pas, arrêtez ici et déboguez. Si cela continue, c’est que quelque chose d’autre à cette adresse IP est sur le réseau, et vous devrez changer l’IP du Pluto (ou de l’autre appareil) avant de continuer.

Notez l’adresse IP du Pluto car vous en aurez besoin lorsque nous commencerons à utiliser la Pluto en Python.

Installation du pilote PlutoSDR

Les commandes de terminal ci-dessous devraient construire et installer la dernière version de :

libiio, la bibliothèque « multiplateforme » d’Analog Device pour l’interfaçage du matériel.

libad9361-iio, AD9361 est la puce RF spécifique du PlutoSDR.

pyadi-iio, l’API Python du Pluto, c’est notre objectif final, mais il dépend des deux bibliothèques précédentes.

sudo apt-get install build-essential git libxml2-dev bison flex libcdk5-dev cmake python3-pip libusb-1.0-0-dev libavahi-client-dev libavahi-common-dev libaio-dev
cd ~
git clone --branch v0.23 https://github.com/analogdevicesinc/libiio.git
cd libiio
mkdir build
cd build
cmake -DPYTHON_BINDINGS=ON ..
make -j$(nproc)
sudo make install
sudo ldconfig

cd ~
git clone https://github.com/analogdevicesinc/libad9361-iio.git
cd libad9361-iio
mkdir build
cd build
cmake ..
make -j$(nproc)
sudo make install

cd ~
git clone https://github.com/analogdevicesinc/pyadi-iio.git
cd pyadi-iio
pip3 install --upgrade pip
pip3 install -r requirements.txt
sudo python3 setup.py install

Test des pilotes PlutoSDR

Ouvrez un nouveau terminal (dans votre VM) et tapez les commandes suivantes :

python3
import adi
sdr = adi.Pluto('ip:192.168.2.1') # ou quel que soit l'IP de votre Pluton
sdr.sample_rate = int(2.5e6)
sdr.rx()

Si vous parvenez à ce stade sans erreur, passez aux étapes suivantes.

Changer l’adresse IP de Pluton

Si, pour une raison quelconque, l’IP par défaut de 192.168.2.1 ne fonctionne pas parce que vous avez déjà un sous-réseau 192.168.2.0, ou parce que vous voulez que plusieurs Pluto soient connectés en même temps, vous pouvez changer l’IP en suivant les étapes suivantes :

Modifiez le fichier config.txt sur le périphérique de stockage de masse PlutoSDR (c’est-à-dire le dispositif ressemblant à une clé USB qui apparaît après avoir branché la Pluto). Entrez la nouvelle IP que vous voulez.

Ejectez le périphérique de stockage de masse (ne débranchez pas la Pluto !). Dans Ubuntu 18, il y a un symbole d’éjection à côté du périphérique PlutoSDR, lorsque vous regardez l’explorateur de fichiers.

Attendez quelques secondes, puis faites un cycle d’alimentation en débranchant le Pluto et en le rebranchant. Retournez dans le config.txt pour déterminer si votre ou vos modifications ont été enregistrées.

Notez que cette procédure est également utilisée pour flasher une image de firmware différente sur la Pluto. Pour plus de détails, voir https://wiki.analog.com/university/tools/pluto/users/firmware.

« Hacker » PlutoSDR pour augmenter la plage RF

Les PlutoSDR sont livrés avec une gamme de fréquences centrales et un taux d’échantillonnage limités, mais la puce sous-jacente est capable de fréquences beaucoup plus élevées. Suivez ces étapes pour débloquer la gamme de fréquences complète de la puce. S’il vous plaît garder à l’esprit que ce processus est fourni par Analog Devices, il est donc aussi faible risque que vous pouvez obtenir. La limitation de fréquence du PlutoSDR est due au fait qu’Analog Devices utilisant l’AD9364 sur la base d’exigences strictes de performance à des fréquences plus élevées. …. En tant que passionnés de SDR et expérimentateurs, nous ne sommes pas trop concernés par ces exigences de performance.

Il est temps de pirater ! Ouvrez un terminal (hôte ou VM, peu importe) :

ssh root@192.168.2.1

Le mot de passe par défaut est analogique.

Vous devriez voir l’écran de bienvenue de PlutoSDR. Vous avez maintenant SSH dans le CPU ARM sur la Pluto elle-même ! Si vous avez une Pluto avec la version 0.31 ou inférieure du firmware, tapez les commandes suivantes :

fw_setenv attr_name compatible
fw_setenv attr_val ad9364
reboot

Et pour l’utilisation de 0.32 et plus :

fw_setenv compatible ad9364
reboot

Vous devriez maintenant être en mesure de tuner jusqu’à 6 GHz et de descendre jusqu’à 70 MHz, sans oublier d’utiliser une fréquence d’échantillonnage jusqu’à 56 MHz ! Bravo !

Réception

L’échantillonnage en utilisant l’API Python du PlutoSDR est simple. Avec n’importe quelle application SDR, nous savons que nous devons lui indiquer la fréquence centrale, la fréquence d’échantillonnage et le gain (ou si nous voulons utiliser le contrôle automatique du gain). Il peut y avoir d’autres détails, mais ces trois paramètres sont nécessaires pour que le SDR ait suffisamment d’informations pour recevoir des échantillons. Certains SDR ont une commande pour leur indiquer de commencer à échantillonner, tandis que d’autres, comme la Pluto, commencent à échantillonner dès que vous l’initialisez. Une fois que le tampon interne du SDR se remplit, les échantillons les plus anciens sont abandonnés. Toutes les API SDR ont une sorte de fonction « recevoir des échantillons », et pour la Pluto c’est rx(), qui renvoie un lot d’échantillons. Le nombre spécifique d’échantillons par lot est défini par la taille du tampon définie au préalable.

Le code ci-dessous suppose que vous avez installé l’API Python du Pluto. Ce code initialise le Pluto, fixe la fréquence d’échantillonnage à 1 MHz, fixe la fréquence centrale à 100 MHz et fixe le gain à 70 dB avec le contrôle automatique du gain désactivé. Notez que l’ordre dans lequel vous définissez la fréquence centrale, le gain et la fréquence d’échantillonnage n’a généralement pas d’importance. Dans l’extrait de code ci-dessous, nous indiquons à la Pluto que nous voulons qu’elle nous donne 10 000 échantillons par appel à rx(). Nous affichons les 10 premiers échantillons.

import numpy as np
import adi

sample_rate = 1e6 # Hz
center_freq = 100e6 # Hz
num_samps = 10000 # nombre d'échantillons retournés par appel à rx()

sdr = adi.Pluto()
sdr.gain_control_mode_chan0 = 'manual'
sdr.rx_hardwaregain_chan0 = 70.0 # dB
sdr.rx_lo = int(center_freq)
sdr.sample_rate = int(sample_rate)
sdr.rx_rf_bandwidth = int(sample_rate) # largeur du filtre, il suffit de le mettre au même niveau que la fréquence d'échantillonnage pour l'instant.
sdr.rx_buffer_size = num_samps

samples = sdr.rx() # recevoir des échantillons de la Pluton
print(samples[0:10])

Pour l’instant, nous n’allons rien faire d’intéressant avec ces échantillons, mais le reste de ce manuel est rempli de code Python qui fonctionne sur des échantillons de QI comme ceux que nous avons reçus ci-dessus.

Gain de réception

La Pluto peut être configuré pour avoir un gain de réception fixe ou automatique. Un contrôle automatique de gain (CAG) ajustera automatiquement le gain de réception pour maintenir un niveau de signal fort (-12dBFS pour ceux qui sont curieux). L’AGC ne doit pas être confondu avec le convertisseur analogique-numérique (CAN) qui numérise le signal. Techniquement parlant, l’AGC est un circuit de rétroaction en boucle fermée qui contrôle le gain de l’amplificateur en réponse au signal reçu. Son objectif est de maintenir un niveau de puissance de sortie constant malgré un niveau de puissance d’entrée variable. En général, le CAG ajuste le gain pour éviter de saturer le récepteur (c’est-à-dire d’atteindre la limite supérieure de la plage du CAN) tout en permettant au signal de « remplir » autant de bits CAN que possible.

Le circuit intégré de radiofréquence, ou RFIC, à l’intérieur de la PlutoSDR possède un module CAG avec quelques réglages différents. (Un RFIC est une puce qui fonctionne comme un émetteur-récepteur : il émet et reçoit des ondes radio). Tout d’abord, notez que le gain de réception sur la Pluto a une gamme de 0 à 74.5 dB. En mode CAG « manuel », le CAG est désactivé, et vous devez indiquer au Pluto le gain de réception à utiliser, par exemple :

sdr.gain_control_mode_chan0 = "manual" # désactiver l'AGC
gain = 50.0 # allowable range is 0 to 74.5 dB
sdr.rx_hardwaregain_chan0 = gain # la gamme permise est de 0 à 74.5 dB

Si vous voulez activer le CAG, vous devez choisir l’un des deux modes suivants :

sdr.gain_control_mode_chan0 = "slow_attack"

sdr.gain_control_mode_chan0 = "fast_attack"

Et avec le CAG activé, vous ne devez pas fournir une valeur à rx_hardwaregain_chan0. Elle sera ignorée car le Pluto ajuste lui-même le gain du signal. La Pluto a deux modes pour le CAG : attaque rapide et attaque lente, comme indiqué dans le code ci-dessus. La différence entre les deux est intuitive, si vous y pensez. Le mode d’attaque rapide réagit plus rapidement aux signaux. En d’autres termes, la valeur du gain change plus rapidement lorsque le signal reçu change de niveau. L’ajustement aux niveaux de puissance des signaux peut être important, notamment pour les systèmes avec mutliplexage temporelle (TDD) qui utilisent la même fréquence pour émettre et recevoir. Le réglage de la commande de gain en mode d’attaque rapide pour ce scénario limite l’atténuation du signal. Dans l’un ou l’autre de ces modes, si aucun signal n’est présent et qu’il n’y a que du bruit, la commande automatique de gain maximisera le réglage du gain ; lorsqu’un signal apparaît, il saturera brièvement le récepteur, jusqu’à ce que la commande automatique de gain puisse réagir et réduire le gain. Vous pouvez toujours vérifier le niveau de gain actuel en temps réel avec :

sdr._get_iio_attr('voltage0','hardwaregain', False)

Pour plus de détails sur la commande automatique de gain (CAG) de la Pluto, notamment sur la manière de modifier les paramètres CAG avancés, reportez-vous à la rubrique the « RX Gain Control » section of this page [https://wiki.analog.com/resources/tools-software/linux-drivers/iio-transceiver/ad9361].

Transmettre

Avant de transmettre un signal avec votre Pluto, assurez-vous de connecter un câble SMA entre le port TX de la Pluto et l’appareil qui servira de récepteur. Il est important de toujours commencer par transmettre sur un câble, en particulier lorsque vous apprenez comment transmettre, pour vous assurer que la SDR se comporte comme vous le souhaitez. Maintenez toujours une puissance d’émission extrêmement faible, afin de ne pas surcharger le récepteur, car le câble n’atténue pas le signal comme le fait le canal sans fil. Si vous possédez un atténuateur (par exemple 30 dB), c’est le bon moment pour l’utiliser. Si vous ne disposez pas d’un autre SDR ou d’un analyseur de spectre pour faire office de récepteur, vous pouvez en théorie utiliser le port RX sur la même Pluto, mais cela peut devenir compliqué. Je vous recommanderais de vous procurer un RTL-SDR à 10$ pour faire office de SDR de réception.

Transmettre est très similaire à recevoir, sauf qu’au lieu de dire au SDR de recevoir un certain nombre d’échantillons, nous lui donnerons un certain nombre d’échantillons à transmettre. Au lieu de rx_lo, nous allons définir tx_lo, pour spécifier sur quelle fréquence porteuse émettre. Le taux d’échantillonnage est partagé entre le RX et le TX, donc nous allons le régler comme d’habitude. Un exemple complet de transmission est montré ci-dessous, où nous générons une sinusoïde à +100 kHz, puis transmettons le signal complexe à une fréquence porteuse de 915 MHz, ce qui fait que le récepteur voit une porteuse à 915,1 MHz. Il n’y a pas vraiment de raison pratique de faire cela, nous aurions pu simplement régler la center_freq sur 915.1e6 et transmettre un tableau de 1, mais nous voulions générer des échantillons complexes à des fins de démonstration.

import numpy as np
import adi

sample_rate = 1e6 # Hz
center_freq = 915e6 # Hz

sdr = adi.Pluto("ip:192.168.2.1")
sdr.sample_rate = int(sample_rate)
sdr.tx_rf_bandwidth = int(sample_rate) # la coupure du filtre, il suffit de la régler sur la même fréquence d'échantillonnage.
sdr.tx_lo = int(center_freq)
sdr.tx_hardwaregain_chan0 = -50 # Augmenter pour augmenter la puissance tx, la plage valide est de -90 à 0 dB

N = 10000 # nombre d'échantillons à transmettre en une seule fois
t = np.arange(N)/sample_rate
samples = 0.5*np.exp(2.0j*np.pi*100e3*t) # Simulez une sinusoïde de 100 kHz, qui devrait apparaître à 915,1 MHz au niveau du récepteur.
samples *= 2**14 # Le PlutoSDR s'attend à ce que les échantillons soient compris entre -2^14 et +2^14, et non -1 et +1 comme certaines SDRs.

# Transmettez notre lot d'échantillons 100 fois, ce qui devrait représenter 1 seconde d'échantillons au total, si l'USB peut suivre.
for i in range(100):
    sdr.tx(samples) # transmettre le lot d'échantillons une fois

Voici quelques notes sur ce code. Tout d’abord, vous voulez simuler vos échantillons IQ pour qu’ils soient entre -1 et 1, mais avant de les transmettre, nous devons les mettre à l’échelle par 2^14 à cause de la façon dont Analog Devices a implémenté la fonction tx(). Si vous n’êtes pas sûr des valeurs min/max, imprimez-les simplement avec print(np.min(samples), np.max(samples)) ou écrivez une instruction if pour vous assurer qu’elles ne sont jamais supérieures à 1 ou inférieures à -1 (en supposant que ce code vienne avant la mise à l’échelle de 2^14). En ce qui concerne le gain d’émission, la gamme est de -90 à 0 dB, donc 0 dB est la puissance d’émission la plus élevée. Nous voulons toujours commencer à une faible puissance d’émission, puis augmenter si nécessaire, donc nous avons réglé le gain à -50 dB par défaut, ce qui est vers le bas. Ne vous contentez pas de le régler sur 0 dB simplement parce que votre signal n’apparaît pas; il y a peut-être un autre problème et vous ne voulez pas griller votre récepteur.

Transmettre des échantillons en répétition

Si vous voulez transmettre continuellement le même ensemble d’échantillons de manière répétée, au lieu d’utiliser une boucle for/while dans Python comme nous l’avons fait ci-dessus, vous pouvez dire au Pluto de le faire en utilisant une seule ligne :

sdr.tx_cyclic_buffer = True # Activer les tampons cycliques

Vous transmettez alors vos échantillons comme d’habitude : sdr.tx(samples) une seule fois, et la Pluto continuera à transmettre le signal indéfiniment, jusqu’à ce que le destructeur de l’objet sdr soit appelé. Pour changer les échantillons qui sont transmis en continu, vous ne pouvez pas simplement appeler sdr.tx(samples) à nouveau avec un nouveau jeu d’échantillons, vous devez d’abord appeler sdr.tx_destroy_buffer(), puis appeler sdr.tx(samples).

Transmettre par voie hertzienne en toute légalité

D’innombrables fois, des étudiants m’ont demandé sur quelles fréquences ils étaient autorisés à émettre avec une antenne (aux États-Unis). La réponse courte est aucune, pour autant que je sache. Généralement, lorsque les gens font référence à des réglementations spécifiques qui parlent de limites de puissance d’émission, ils se réfèrent aux fréquences suivantes the FCC’s « Title 47, Part 15 » (47 CFR 15) regulations [https://www.ecfr.gov/cgi-bin/text-idx?SID=7ce538354be86061c7705af3a5e17f26&mc=true&node=pt47.1.15&rgn=div5]. Mais il s’agit de réglementations pour les fabricants qui construisent et vendent des appareils fonctionnant dans les bandes ISM, et ces réglementations traitent de la manière dont ils doivent être testés. Un appareil de la partie 15 est un appareil pour lequel une personne n’a pas besoin de licence pour le faire fonctionner dans le spectre qu’il utilise, mais l’appareil lui-même doit être autorisé/certifié pour montrer qu’il fonctionne conformément aux réglementations de la FCC avant d’être commercialisé et vendu. Les réglementations de la partie 15 spécifient les niveaux de puissance maximum d’émission et de réception pour les différents éléments du spectre, mais rien de tout cela ne s’applique réellement à une personne transmettant un signal avec une radio SDR ou une radio de fabrication artisanale. Les seules réglementations que j’ai pu trouver concernant les radios qui ne sont pas réellement des produits vendus étaient spécifiques à l’exploitation d’une station radio AM ou FM de faible puissance dans les bandes AM/FM. Il y a également une section sur les « appareils de fabrication artisanale », mais il est spécifiquement dit qu’elle ne s’applique pas à tout ce qui est construit à partir d’un kit, et il serait exagéré de dire qu’une plate-forme d’émission utilisant une radio logicielle est un appareil de fabrication artisanale. En résumé, les réglementations de la FCC ne sont pas aussi simples que « vous pouvez transmettre à ces fréquences uniquement sous ces niveaux de puissance », mais il s’agit plutôt d’un énorme ensemble de règles destinées aux tests et à la conformité.

Une autre façon de voir les choses serait de dire « bien, ce ne sont pas des appareils de la Partie 15, mais suivons les règles de la Partie 15 comme s’ils l’étaient ». Pour la bande ISM de 915 MHz, les règles sont les suivantes : « L’intensité de champ de toute émission rayonnée dans la bande de fréquence spécifiée ne doit pas dépasser 500 microvolts/mètre à 30 mètres. La limite d’émission dans ce paragraphe est basée sur un instrument de mesure employant un détecteur moyen. » Donc, comme vous pouvez le voir, ce n’est pas aussi simple qu’une puissance d’émission maximale en watts.

Maintenant, si vous avez votre licence de radio amateur (ham), la FCC vous permet d’utiliser certaines bandes réservées à la radio amateur. Il y a toujours des directives à suivre et des puissances d’émission maximales, mais au moins ces chiffres sont spécifiés en watts de puissance rayonnée effective. Ce graphique [http://www.arrl.org/files/file/Regulatory/Band%20Chart/Band%20Chart%20-%2011X17%20Color.pdf] indique quelles bandes sont disponibles en fonction de votre catégorie de licence (Technicien, Général et Extra). Je recommande à toute personne intéressée par la transmission avec des SDRs d’obtenir sa licence de radioamateur, cf. ARRL’s Getting Licensed page [http://www.arrl.org/getting-licensed] pour plus d’info.

Si quelqu’un a plus de détails sur ce qui est autorisé ou non, veuillez m’envoyer un email.

Transmettre et recevoir simultanément

En utilisant l’astuce tx_cyclic_buffer, vous pouvez facilement recevoir et émettre en même temps, en déclenchant l’émetteur, puis la réception. Le code suivant montre un exemple fonctionnel de transmission d’un signal QPSK dans la bande 915 MHz, de réception et d’affichage de la DSP.

import numpy as np
import adi
import matplotlib.pyplot as plt

sample_rate = 1e6 # Hz
center_freq = 915e6 # Hz
num_samps = 100000 # nombre d'échantillons par appel à rx()

sdr = adi.Pluto("ip:192.168.2.1")
sdr.sample_rate = int(sample_rate)

# Config Tx
sdr.tx_rf_bandwidth = int(sample_rate) # la coupure du filtre, il suffit de la régler sur la même fréquence d'échantillonnage.
sdr.tx_lo = int(center_freq)
sdr.tx_hardwaregain_chan0 = -50 # Augmenter pour augmenter la puissance tx, la plage valide est de -90 à 0 dB

# Configurer Rx
sdr.rx_lo = int(center_freq)
sdr.rx_rf_bandwidth = int(sample_rate)
sdr.rx_buffer_size = num_samps
sdr.gain_control_mode_chan0 = 'manual'
sdr.rx_hardwaregain_chan0 = 0.0 # dB, augmenter pour augmenter le gain de réception, mais attention à ne pas saturer le CAN

# Créer une forme d'onde de transmission (QPSK, 16 échantillons par symbole)
num_symbols = 1000
x_int = np.random.randint(0, 4, num_symbols) # 0 to 3
x_degrees = x_int*360/4.0 + 45 # 45, 135, 225, 315 degrees
x_radians = x_degrees*np.pi/180.0 # sin() et cos() avec des angles en radians
x_symbols = np.cos(x_radians) + 1j*np.sin(x_radians) # ce qui produit nos symboles complexes QPSK
samples = np.repeat(x_symbols, 16) # 16 échantillons par symbole (impulsions rectangulaires)
samples *= 2**14 # Le PlutoSDR s'attend à ce que les échantillons soient compris entre -2^14 et +2^14, et non entre -1 et +1 comme certains SDRs.

# Start the transmitter
sdr.tx_cyclic_buffer = True # Activer les tampons cycliques
sdr.tx(samples) # début de la transmission

# Effacer le tampon juste pour être sûr
for i in range (0, 10):
    raw_data = sdr.rx()

# Recevoir des échantillons
rx_samples = sdr.rx()
print(rx_samples)

# Arrêter la transmission
sdr.tx_destroy_buffer()

# Calculer la densité spectrale de puissance (version du signal dans le domaine de la fréquence)
psd = np.abs(np.fft.fftshift(np.fft.fft(rx_samples)))**2
psd_dB = 10*np.log10(psd)
f = np.linspace(sample_rate/-2, sample_rate/2, len(psd))

# Tracer le domaine temporel
plt.figure(0)
plt.plot(np.real(rx_samples[::100]))
plt.plot(np.imag(rx_samples[::100]))
plt.xlabel("temps")

# Tracer le domaine freq
plt.figure(1)
plt.plot(f/1e6, psd_dB)
plt.xlabel("Frequences [MHz]")
plt.ylabel("DSP")
plt.show()

Vous devriez voir quelque chose qui ressemble à ceci, en supposant que vous avez des antennes appropriées ou un câble connecté :

C’est un bon exercice que de régler lentement sdr.tx_hardwaregain_chan0 et sdr.rx_hardwaregain_chan0 pour s’assurer que le signal reçu devient plus faible/fort comme prévu.

Exercices Python

Au lieu de vous fournir du code à exécuter, j’ai créé plusieurs exercices où 95 % du code est fourni et où le code restant est du Python assez simple à créer. Les exercices ne sont pas censés être difficiles. Il leur manque juste assez de code pour vous faire réfléchir.

Exercice 1 : Déterminer le débit de votre USB

Essayons de recevoir des échantillons du PlutoSDR, et dans le processus, voyons combien d’échantillons par seconde nous pouvons pousser à travers la connexion USB 2.0.

Votre tâche consiste à créer un script Python qui détermine le taux de réception des échantillons en Python, c’est-à-dire compter les échantillons reçus et suivre le temps pour déterminer le taux. Ensuite, essayez d’utiliser différents taux d’échantillonnage et tailles de tampon pour voir comment cela affecte le taux le plus élevé réalisable.

Gardez à l’esprit que si vous recevez moins d’échantillons par seconde que le taux d’échantillonnage spécifié, cela signifie que vous perdez/supprimez une certaine fraction d’échantillons, ce qui se produira probablement à des taux d’échantillonnage élevés. La Pluto utilise uniquement l’USB 2.0.

Le code suivant servira de point de départ mais contient les instructions dont vous avez besoin pour accomplir cette tâche.

import numpy as np
import adi
import matplotlib.pyplot as plt
import time

sample_rate = 10e6 # Hz
center_freq = 100e6 # Hz

sdr = adi.Pluto("ip:192.168.2.1")
sdr.sample_rate = int(sample_rate)
sdr.rx_rf_bandwidth = int(sample_rate) # la fréquence de coupure du filtre, il suffit de la régler sur la même fréquence d'échantillonnage.
sdr.rx_lo = int(center_freq)
sdr.rx_buffer_size = 1024 # c'est le tampon que le Pluto utilise pour mettre en mémoire tampon les échantillons
samples = sdr.rx() # recevoir des échantillons au large de Pluton

En outre, afin de déterminer la durée d’une opération, vous pouvez utiliser le code suivant :

start_time = time.time()
# faire des chodes
end_time = time.time()
print('secondes écoulées:', end_time - start_time)

Voici quelques conseils pour vous aider à démarrer.

Conseil 1 : Vous devrez placer la ligne « samples = sdr.rx() » dans une boucle qui s’exécute plusieurs fois (par exemple, 100 fois). Vous devez compter combien d’échantillons vous obtenez à chaque appel à sdr.rx() tout en suivant le temps qui s’est écoulé.

Astuce 2 : Le fait que vous calculiez des échantillons par seconde ne signifie pas que vous devez effectuer exactement 1 seconde de réception d’échantillons. Vous pouvez diviser le nombre d’échantillons reçus par le temps écoulé.

Conseil 3 : Commencez avec sample_rate = 10e6 comme le montre le code car ce taux est bien plus que ce que l’USB 2.0 peut supporter. Vous serez en mesure de voir combien de données sont transmises. Ensuite vous pouvez modifier rx_buffer_size. Faites-le beaucoup plus grand et voyez ce qui se passe. Une fois que vous avez un script fonctionnel et que vous avez modifié rx_buffer_size, essayez d’ajuster sample_rate. Déterminez jusqu’où vous devez descendre pour être capable de recevoir 100 % des échantillons en Python (c’est-à-dire échantillonner à un cycle de service de 100 %).

Conseil 4 : Dans votre boucle où vous appelez sdr.rx(), essayez d’en faire le moins possible afin de ne pas ajouter de délai supplémentaire dans le temps d’exécution. Ne faites rien d’intensif comme afficher à l’intérieur de la boucle.

Dans le cadre de cet exercice, vous aurez une idée du débit maximal de l’USB 2.0. Vous pouvez vérifier vos résultats en ligne.

En bonus, essayez de changer la fréquence centrale et la largeur de bande rx_rf_bandwidth pour voir si cela a un impact sur la vitesse à laquelle vous pouvez recevoir des échantillons du Pluto.

Exercice 2 : créer un spectrogramme

Pour cet exercice, vous allez créer un spectrogramme, comme nous l’avons appris à la fin du chapitre Domaine fréquentiel. Un spectrogramme est simplement un ensemble de FFT affichées empilées les unes sur les autres. En d’autres termes, c’est une image avec un axe représentant la fréquence et l’autre axe représentant le temps.

Dans le chapitre Domaine fréquentiel nous avons appris le code Python pour effectuer une FFT. Pour cet exercice, vous pouvez utiliser les extraits de code de l’exercice précédent, ainsi qu’un peu de code Python de base.

Indices:

Essayez de définir sdr.rx_buffer_size à la taille de la FFT de sorte que vous exécutez toujours 1 FFT pour chaque appel à sdr.rx().

Construisez un tableau 2d pour contenir tous les résultats de la FFT où chaque ligne représente 1 FFT. Un tableau 2d rempli de zéros peut être créé avec : np.zeros((num_rows, fft_size)). Accédez à la ligne i du tableau avec : waterfall_2darray[i, :].

plt.imshow() est un moyen pratique d’afficher un tableau 2d. La couleur est mise à l’échelle automatiquement.

Comme but ultime, faire la mise à jour du spectrogramme en direct.

Mise en Forme

Ce chapitre traite de la mise en forme, des interférences inter-symboles, du filtrage adapté et des filtres à cosinus surélevé. À la fin, nous utilisons Python pour ajouter la mise en forme aux symboles BPSK. Vous pouvez considérer cette section comme la deuxième partie du chapitre sur les filtres, où nous approfondissons les mises en forme.

Interférence inter-symboles (ISI)

Dans le chapitre Filtres, nous avons appris que les symboles/impulsions en forme de blocs utilisent une quantité excessive de spectre, et que nous pouvons réduire considérablement la quantité de spectre utilisée en «  façonnant «  nos impulsions. Cependant, vous ne pouvez pas utiliser n’importe quel filtre passe-bas, sinon vous risquez d’obtenir des interférences inter-symboles (ISI), où les symboles se mélangent et interfèrent les uns avec les autres.

Lorsque nous transmettons des symboles numériques, nous les transmettons dos à dos (au lieu d’attendre un certain temps entre eux). Lorsque vous appliquez un filtre de mise en forme, il allonge l’impulsion dans le domaine temporel (afin de la condenser en fréquence), ce qui entraîne un chevauchement des symboles adjacents. Ce chevauchement n’est pas un problème, à condition que votre filtre de mise en forme réponde à un critère: la somme de toutes les impulsions doit être égale à zéro à chaque multiple de notre période de symbole \(T\), sauf pour l’une des impulsions. L’idée est mieux comprise grâce à la visualisation suivante:

Comme vous pouvez le voir, à chaque intervalle de \(T\), il y a un pic d’une impulsion tandis que le reste des impulsions sont à 0 (elles traversent l’axe des x). Lorsque le récepteur échantillonne le signal, il le fait au moment parfait (au pic des impulsions), ce qui signifie que c’est le seul point dans le temps qui compte. Il existe généralement un bloc de synchronisation des symboles au niveau du récepteur qui garantit que les symboles sont échantillonnés aux pics.

Filtre Adapté

Une astuce que nous utilisons dans les communications sans fil s’appelle le filtrage adapté. Pour comprendre le filtrage adapté, vous devez d’abord comprendre ces deux points:

Les impulsions dont nous avons parlé ci-dessus doivent seulement être parfaitement alignées au niveau du récepteur avant l’échantillonnage. Jusqu’à ce moment-là, l’existence d’une ISI n’a pas vraiment d’importance, c’est-à-dire que les signaux peuvent être envoyés dans l’air avec une ISI et tout va bien.

Nous voulons un filtre passe-bas dans notre émetteur pour réduire la quantité de spectre que notre signal utilise. Mais le récepteur a également besoin d’un filtre passe-bas pour éliminer autant de bruit/interférences que possible à côté du signal. Par conséquent, nous avons un filtre passe-bas à l’émetteur (Tx) et un autre au récepteur (Rx), puis l’échantillonnage se produit après les deux filtres (et les effets du canal sans fil).

Ce que nous faisons dans les communications modernes est de diviser le filtre de mise en forme des impulsions de manière égale entre la Tx et la Rx. Il n’est pas nécessaire qu’il s’agisse de filtres identiques, mais, en théorie, le filtre linéaire optimal pour maximiser le rapport signal/bruit en présence d’un bruit blanc gaussien (AWGN) est d’utiliser le même filtre à la fois à la Tx et à la Rx. Cette stratégie est appelée le concept de « filtre adapté ».

Une autre façon d’envisager les filtres appariés est que le récepteur corrèle le signal reçu avec le signal modèle connu. Le signal modèle est essentiellement constitué des impulsions envoyées par l’émetteur, indépendamment des déphasages/amplitudes qui leur sont appliqués. Rappelez-vous que le filtrage est effectué par convolution, qui est en fait une corrélation (en fait, elles sont mathématiquement identiques lorsque le modèle est symétrique). Ce processus de corrélation du signal reçu avec le modèle nous donne notre meilleure chance de récupérer ce qui a été envoyé, et c’est pourquoi il est théoriquement optimal. Par analogie, pensez à un système de reconnaissance d’images qui recherche des visages à l’aide d’un modèle de visage et d’une corrélation 2D :

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 37)

Cannot scale image! Could not get size from "../_images/face_template.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/face_template.png'

Diviser un Filtre en Deux

Comment fait-on pour diviser un filtre en deux? La convolution est associative, ce qui signifie:

\[(f * g) * h = f * (g * h)\]

Imaginons que \(f\) soit notre signal d’entrée, et que \(g\) et \(h\) soient des filtres. Filtrer \(f\) avec \(g\), puis \(h\) revient à filtrer avec un filtre égal à \(g * h\).

Rappelons également que la convolution dans le domaine temporel est une multiplication dans le domaine fréquentiel:

\[g(t) * h(t) \leftrightarrow G(f)H(f)\]

Pour diviser un filtre en deux, vous pouvez prendre la racine carrée de la réponse en fréquence.

\[X(f) = X_H(f) X_H(f) \quad \mathrm{where} \quad X_H(f) = \sqrt{X(f)}\]

Filtres de Mise en Forme Spécifiques

Nous savons que nous voulons :

Concevoir un filtre qui réduit la largeur de bande de notre signal (pour utiliser moins de spectre) et toutes les impulsions sauf une doivent s’additionner à zéro à chaque intervalle de symbole.

Diviser le filtre en deux, en plaçant une moitié dans la Tx et l’autre dans la Rx.

Examinons quelques filtres spécifiques qui sont couramment utilisés pour la mise en forme.

Filtre Cosinus Surélevé

Le filtre de mise en forme le plus populaire semble être le filtre « cosinus surélevé ». C’est un bon filtre passe-bas pour limiter la largeur de bande que notre signal occupera, et il a aussi la propriété de ramener la somme à zéro à des intervalles de \(T\):

Notez que le graphique ci-dessus est dans le domaine temporel. Il représente la réponse impulsionnelle du filtre. Le paramètre \(beta\) est le seul paramètre du filtre à cosinus surélevé. Il détermine la vitesse à laquelle le filtre se réduit dans le domaine temporel, ce qui est inversement proportionnel à la vitesse à laquelle il se réduit en fréquence:

La raison pour laquelle on l’appelle le filtre à cosinus surélevé est que le domaine de fréquence lorsque \(\beta = 1\) est un demi-cycle d’une onde cosinusoïdale, surélevée pour s’asseoir sur l’axe des abscisses.

L’équation qui définit la réponse impulsionnelle du filtre à cosinus surélevé est la suivante:

\[h(t) = \frac{1}{T} \mathrm{sinc}\left( \frac{t}{T} \right) \frac{\cos\left(\frac{\pi\beta t}{T}\right)}{1 - \left( \frac{2 \beta t}{T} \right)^2}\]

Vous trouverez de plus amples informations sur la fonction \(\mathrm{sinc}()\) ici [https://fr.wikipedia.org/wiki/Sinus_cardinal].

Rappelez-vous: nous partageons ce filtre entre la Tx et la Rx de manière égale. Entrez dans le filtre racine cosinus surélevé (RRC en anglais pour Root Raised Cosine)!

Filtre Racine Cosinus Surélevé

Le filtre racine cosinus surélevé (RRC) est ce que nous mettons réellement en œuvre dans nos Tx et Rx. Combinés, ils forment un filtre normal à cosinus surélevé, comme nous l’avons vu. Comme la division d’un filtre en deux implique une racine carrée dans le domaine de la fréquence,la réponse impulsionnelle devient un peu désordonnée :

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 109)

Cannot scale image! Could not get size from "../_images/rrc_filter.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/rrc_filter.png'

Heureusement, il s’agit d’un filtre très utilisé et il existe de nombreuses implémentations, dont les suivantes in Python [https://commpy.readthedocs.io/en/latest/generated/commpy.filters.rrcosfilter.html].

Autres Filtres de Mise en Forme des Impulsions

Parmi les autres filtres, citons le filtre gaussien, dont la réponse impulsionnelle ressemble à une fonction gaussienne. Il existe également un filtre sinc qui est équivalent au filtre à cosinus surélevé lorsque \(\beta = 0\). Le filtre sinc est plutôt un filtre idéal, c’est-à-dire qu’il élimine les fréquences nécessaires sans grande région de transition.

Facteur Roll-Off

Examinons le paramètre \(\beta\). Il s’agit d’un nombre compris entre 0 et 1, appelé facteur de « roll-off » ou parfois « excès de bande passante ». Il détermine à quelle vitesse, dans le domaine temporel, le filtre se réduit à zéro. Rappelez-vous que, pour être utilisée comme un filtre, la réponse impulsionnelle doit décroître jusqu’à zéro des deux côtés :

Plus le nombre de taps du filtre requis est élevé, plus \(\beta\) est faible. Lorsque \(\beta = 0\), la réponse impulsionnelle n’atteint jamais complètement zéro, nous essayons donc d’obtenir \(\beta\) aussi bas que possible sans causer d’autres problèmes. Plus le roll-off est faible, plus nous pouvons créer un signal compact en fréquence pour un débit de symboles donné, ce qui est toujours important.

Une équation courante utilisée pour calculer approximativement la largeur de bande, en Hz, pour un débit de symboles et un facteur Roll-Off donnés est la suivante :

\[\mathrm{BW} = R_S(\beta + 1)\]

\(R_S\) est le débit de symboles en Hz. Pour les communications sans fil, nous aimons généralement un roll-off entre 0,2 et 0,5. En règle générale, un signal numérique qui utilise le rythme symbole \(R_S\) va occuper un peu plus de \(R_S\) de spectre, y compris les parties positives et négatives du spectre. Une fois que nous convertissons et transmettons notre signal, les deux côtés sont certainement importants. Si nous transmettons une QPSK à 1 million de symboles par seconde (MSps), elle occupera environ 1,3 MHz. Le débit de données sera de 2 Mbps (rappelons que la QPSK utilise 2 bits par symbole), y compris les redondances du codage de canal et les en-têtes de trame.

Exercise Python

En guise d’exercice Python, filtrons et façonnons quelques impulsions. Nous utiliserons des symboles BPSK afin de faciliter la visualisation. Avant l’étape de mise en forme des impulsions, la BPSK consiste à transmettre des 1 ou des -1 avec la partie « Q » égale à zéro. Avec Q égal à zéro, nous pouvons tracer la partie I uniquement, et c’est plus facile à regarder.

Dans cette simulation, nous utiliserons 8 échantillons par symbole et, au lieu d’utiliser un signal de type onde carrée composé de 1 et de -1, nous utiliserons un train d’impulsions. Lorsque vous faites passer une impulsion dans un filtre, la sortie est la réponse impulsionnelle (d’où le nom). Par conséquent, si vous voulez une série d’impulsions, vous devez utiliser des impulsions entrecoupées de zéros pour éviter les impulsions carrées.

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

num_symbols = 10
sps = 8

bits = np.random.randint(0, 2, num_symbols) # les données a envoyées de 1's and 0's

x = np.array([])
for bit in bits:
    pulse = np.zeros(sps)
    pulse[0] = bit*2-1 # définir la première valeur à 1 ou -1
    x = np.concatenate((x, pulse)) # ajouter les 8 échantillons au signal
plt.figure(0)
plt.plot(x, '.-')
plt.grid(True)
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 170)

Cannot scale image! Could not get size from "../_images/pulse_shaping_python1.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pulse_shaping_python1.png'

A ce stade, nos symboles sont toujours des 1 et des -1. Ne vous laissez pas emporter par le fait que nous avons utilisé des impulsions. En fait, il est peut-être plus facile de ne pas visualiser la réponse des impulsions, mais plutôt d’y penser comme à un tableau :

bits: [0, 1, 1, 1, 1, 0, 0, 0, 1, 1]
Symboles BPSK: [-1, 1, 1, 1, 1, -1, -1, -1, 1, 1]
Application de 8 échantillons par symbole: [-1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, ...]

Nous allons créer un filtre en cosinus surélevé en utilisant un \(\beta\) de 0.35, et nous allons le faire durer 101 taps pour donner au signal suffisamment de temps pour décroître jusqu’à zéro. Bien que l’équation du cosinus surélevé a besoin de la période du symbole et d’un vecteur temporel \(t\), nous pouvons supposer une période d’échantillon** de 1 seconde pour « normaliser » notre simulation. Cela signifie que notre période de symbole \(Ts\) est de 8 car nous avons 8 échantillons par symbole. Notre vecteur temps sera donc une liste d’entiers. Avec la façon dont l’équation du cosinus surélevé fonctionne, nous voulons que \(t=0\) soit au centre. Nous allons générer un vecteur temporel de 101 longueurs, commençant à -51 et finissant à +51.

# Créer notre filtre à base de cosinus surélevé
num_taps = 101
beta = 0.35
Ts = sps # Supposons que la fréquence d'échantillonnage soit de 1 Hz, donc que la période d'échantillonnage soit de 1, donc que la période du symbole soit de 8.
t = np.arange(-50, 51) # n'oubliez pas que le nombre final n'est pas inclus
h = 1/Ts*np.sinc(t/Ts) * np.cos(np.pi*beta*t/Ts) / (1 - (2*beta*t/Ts)**2)
plt.figure(1)
plt.plot(t, h, '.')
plt.grid(True)
plt.show()

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 198)

Cannot scale image! Could not get size from "../_images/pulse_shaping_python2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pulse_shaping_python2.png'

Notez comment la sortie décroît définitivement vers zéro. Le fait que nous utilisions 8 échantillons par symbole détermine l’étroitesse de ce filtre et la vitesse à laquelle il décroît vers zéro. La réponse impulsionnelle ci-dessus ressemble à un filtre passe-bas typique, et il n’y a vraiment aucun moyen pour nous de savoir qu’il s’agit d’un filtre spécifique de mise en forme d’impulsion par rapport à n’importe quel autre filtre passe-bas.

Enfin, nous pouvons filtrer notre signal \(x\) et examiner le résultat. Ne vous focalisez pas trop sur l’introduction d’une boucle for dans le code fourni. Nous verrons pourquoi elle est là après le bloc de code.

# Filtrer notre signal, afin d'appliquer la mise en forme
x_shaped = np.convolve(x, h)
plt.figure(2)
plt.plot(x_shaped, '.-')
for i in range(num_symbols):
    plt.plot([i*sps+num_taps//2+1,i*sps+num_taps//2+1], [0, x_shaped[i*sps+num_taps//2+1]])
plt.grid(True)
plt.show()

Le signal résultant est additionné à partir d’un grand nombre de nos réponses impulsionnelles, dont la moitié environ est d’abord multipliée par -1. Cela peut sembler compliqué, mais nous allons le faire ensemble.

Tout d’abord, il y a des échantillons transitoires avant et après les données à cause du filtre et de la façon dont la convolution fonctionne. Ces échantillons supplémentaires sont inclus dans notre transmission, mais ils ne contiennent pas réellement de « pics » d’impulsions.

Deuxièmement, les lignes verticales ont été créées dans la boucle for pour des raisons de visualisation. Elles sont destinées à montrer où se trouvent les intervalles de \(Ts\). Ces intervalles représentent l’endroit où ce signal sera échantillonné par le récepteur. Observez que pour les intervalles \(Ts\) la courbe a la valeur exacte de 1.0 ou -1.0, ce qui en fait les points idéaux dans le temps pour l’échantillonnage.

Si nous devions convertir et transmettre ce signal, le récepteur devrait déterminer quand se trouvent les positions de \(Ts\), par exemple, en utilisant un algorithme de synchronisation des symboles. De cette façon, le récepteur sait exactement quand il doit échantillonner pour obtenir les bonnes données. Si le récepteur échantillonne un peu trop tôt ou trop tard, il obtiendra des valeurs légèrement faussées à cause de l’ISI, et s’il se trompe, il obtiendra un tas de nombres bizarres.

Voici un exemple, créé à l’aide de GNU Radio, qui illustre ce à quoi ressemble le tracé IQ (ou constellation) lorsque nous échantillonnons au bon et au mauvais moment. Les impulsions originales ont leurs valeurs binaires annotées.

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 231)

Cannot scale image! Could not get size from "../_images/symbol_sync1.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/symbol_sync1.png'

Le graphique ci-dessous représente la position idéale dans le temps pour échantillonner, ainsi que le tracé du QI :

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 237)

Cannot scale image! Could not get size from "../_images/symbol_sync2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/symbol_sync2.png'

Comparez cela au pire temps d’échantillonnage. Remarquez les trois clusters dans la constellation. Nous échantillonnons directement entre chaque symbole; nos échantillons vont être très différents.

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 243)

Cannot scale image! Could not get size from "../_images/symbol_sync3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/symbol_sync3.png'

Voici un autre exemple d’un mauvais temps d’échantillonnage, quelque part entre notre cas idéal et le pire. Tenez compte des quatre groupes. Avec un SNR élevé, nous pourrions nous en sortir avec cet intervalle de temps d’échantillonnage, mais ce n’est pas conseillé.

System Message: WARNING/2 (/home/vs/SPHINX/source/pulse_shaping.rst, line 249)

Cannot scale image! Could not get size from "../_images/symbol_sync4.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/symbol_sync4.png'

Rappelez-vous que nos valeurs Q n’apparaissent pas sur le tracé du domaine temporel parce qu’elles sont à peu près nulles, ce qui permet aux courbes IQ de s’étendre horizontalement seulement.

Interfaces Homme Machine temps-réel avec PyQt

Dans ce chapitre, nous apprenons à créer des interfaces graphiques utilisateur (GUI) en temps réel avec Python grâce à PyQt, l’interface Python pour Qt. Nous y construirons un analyseur de spectre avec affichage du temps, de la fréquence et d’un spectrogramme/diagramme en cascade, ainsi que des widgets de saisie pour ajuster les différents paramètres SDR. Cet exemple est compatible avec PlutoSDR, USRP et le mode simulation uniquement.

Introduction

Qt (prononcé « cute ») est un framework permettant de créer des applications GUI compatibles avec Linux, Windows, macOS et Android. Ce framework puissant, utilisé dans de nombreuses applications commerciales, est écrit en C++ pour des performances optimales. PyQt est l’interface Python de Qt, offrant la possibilité de créer des applications GUI en Python tout en bénéficiant des performances d’un framework C++ performant. Dans ce chapitre, nous apprendrons à utiliser PyQt pour créer un analyseur de spectre en temps réel, utilisable avec un SDR (ou un signal simulé). Cet analyseur affichera le temps, la fréquence et un spectrogramme/diagramme en cascade, ainsi que des widgets de saisie pour ajuster les différents paramètres du SDR. Nous utiliserons PyQtGraph <https://www.pyqtgraph.org/>, une bibliothèque distincte basée sur PyQt, pour la visualisation des données. Côté saisie, nous utiliserons des curseurs, des listes déroulantes et des boutons. Cet exemple est compatible avec PlutoSDR, USRP et le mode simulation uniquement. Bien que le code d’exemple utilise PyQt6, chaque ligne est identique à celle de PyQt5 (à l’exception de import), les différences entre les deux versions étant minimes du point de vue de l’API. Ce chapitre fait naturellement la part belle au code Python, comme nous l’illustrons par des exemples. À la fin de ce chapitre, vous maîtriserez les éléments de base nécessaires à la création de votre propre application SDR interactive personnalisée !

Aperçu de Qt

Qt est un framework très complet, et nous n’aborderons ici que quelques notions de base. Cependant, il est important de comprendre certains concepts clés pour travailler avec Qt/PyQt :

Widgets : Les widgets sont les éléments constitutifs d’une application Qt et servent à créer l’interface graphique. Il existe différents types de widgets, comme les boutons, les curseurs, les étiquettes et les graphiques. Les widgets peuvent être organisés en mises en page, qui déterminent leur position à l’écran.

Mises en page : Les mises en page permettent d’organiser les widgets dans une fenêtre. Il existe plusieurs types de mises en page, notamment horizontales, verticales, en grille et en formulaire. Les mises en page permettent de créer des interfaces graphiques complexes qui s’adaptent aux changements de taille de la fenêtre.

Signaux et slots : Les signaux et les slots permettent la communication entre les différentes parties d’une application Qt. Un signal est émis par un objet lorsqu’un événement particulier se produit et est associé à un slot, une fonction de rappel appelée lors de l’émission du signal. Les signaux et les slots permettent de créer une structure événementielle dans une application Qt et de garantir la réactivité de l’interface graphique.

Feuilles de style : Les feuilles de style servent à personnaliser l’apparence des widgets dans une application Qt. Écrites dans un langage similaire à CSS, elles permettent de modifier la couleur, la police et la taille des widgets.

Graphismes : Qt dispose d’un puissant framework graphique permettant de créer des graphismes personnalisés dans une application Qt. Ce framework inclut des classes pour dessiner des lignes, des rectangles, des ellipses et du texte, ainsi que des classes pour gérer les événements de la souris et du clavier.

Multithreading : Qt prend en charge nativement le multithreading et fournit des classes pour créer des threads de travail s’exécutant en arrière-plan. Le multithreading permet d’exécuter des opérations longues dans une application Qt sans bloquer le thread principal de l’interface graphique.

OpenGL : Qt intègre la prise en charge d’OpenGL et fournit des classes pour la création de graphismes 3D dans une application Qt. OpenGL est utilisé pour créer des applications exigeant des performances graphiques 3D élevées. Dans ce chapitre, nous nous concentrerons uniquement sur les applications 2D.

Structure de base d’une application

Avant d’explorer les différents widgets Qt, examinons la structure d’une application Qt typique. Une application Qt se compose d’une fenêtre principale contenant un widget central, lequel contient le contenu principal de l’application. Avec PyQt, nous pouvons créer une application Qt minimale, ne contenant qu’un seul QPushButton, comme suit :

from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton

# Sous-classe QMainWindow pour paramétrer la fenêtre principale de
l'application
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        # Example de composant IHM
        example_button = QPushButton('Push Me')
        def on_button_click():
            print("beep")
        example_button.clicked.connect(on_button_click)

        self.setCentralWidget(example_button)

app = QApplication([])
window = MainWindow()
window.show() # les fenêtres sont cachées par défaut
app.exec() # Démarrage de la boucle d'événements

Essayez d’exécuter le code vous-même ; vous devrez probablement installer PyQt6 avec pip install PyQt6. Notez que la dernière ligne est bloquante : tout ce que vous ajouterez après ne s’exécutera pas tant que vous n’aurez pas fermé la fenêtre. Le bouton QPushButton que nous créons a son signal clicked connecté à une fonction de rappel qui affiche « beep » dans la console.

Application avec thread de worker

L’exemple minimal présenté ci-dessus pose problème : il ne laisse aucune place pour le code SDR/DSP. La méthode __init__ de la classe MainWindow est configurée et les fonctions de rappel sont définies, mais il est absolument impératif de ne pas y ajouter d’autre code (SDR ou DSP, par exemple). En effet, l’interface graphique étant monothread, bloquer ce thread avec du code long entraînerait des blocages ou des saccades, or nous recherchons une interface aussi fluide que possible. Pour contourner ce problème, nous pouvons utiliser un thread de travail pour exécuter le code SDR/DSP en arrière-plan.

L’exemple ci-dessous étend l’exemple minimal précédent en incluant un thread de worker qui exécute du code (dans la fonction run) en continu. Nous n’utilisons pas de boucle while True, car le fonctionnement interne de PyQt exige que la fonction run se termine et redémarre périodiquement. Pour ce faire, le signal end_of_run du thread de worker (que nous détaillerons dans la section suivante) est associé à une fonction de rappel qui relance la fonction run de ce même thread. Il est également nécessaire d’initialiser le thread de worker dans le code de MainWindow, ce qui implique la création d’un nouveau QThread et l’affectation de notre thread de worker personnalisé. Ce code peut paraître complexe, mais il s’agit d’une pratique courante dans les applications PyQt. L’essentiel à retenir est que le code orienté interface graphique se trouve dans MainWindow, tandis que le code orienté SDR/DSP se trouve dans la fonction run du thread de travail.

from PyQt6.QtCore import QThread, pyqtSignal, QObject, QTimer
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton
import time

# opérations Non-IHM  (notamment SDR) néccessitant d'être lancées dans un thread spéaré.
class SDRWorker(QObject):
    end_of_run = pyqtSignal()

    # Boucle principale
    def run(self):
        print("Starting run()")
        time.sleep(1)
        self.end_of_run.emit() # let MainWindow know we're done

# Sous-classe QMainWindow pour personnaliser la fenêtre principale de votre application
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        # Initialisation du worker et du thread
        self.sdr_thread = QThread()
        worker = SDRWorker()
        worker.moveToThread(self.sdr_thread)

        # Exemple de composant IHM
        example_button = QPushButton('Push Me')
        def on_button_click():
            print("beep")
        example_button.clicked.connect(on_button_click)
        self.setCentralWidget(example_button)

        # C'est ce qui permet à la fonction run() de se répéter en continu
        def end_of_run_callback():
            QTimer.singleShot(0, worker.run) # Run worker again immediately
        worker.end_of_run.connect(end_of_run_callback)

        self.sdr_thread.started.connect(worker.run) # kicks off the first run() when the thread starts
        self.sdr_thread.start() # start thread

app = QApplication([])
window = MainWindow()
window.show() # Les fenêtres sont cachées par défaut
app.exec() # Démarrer l'évenèment boucle

Essayez d’exécuter le code ci-dessus ; vous devriez voir « Starting run()» s’afficher dans la console toutes les secondes, et le bouton-poussoir devrait toujours fonctionner (sans délai). Dans le thread de travail, nous effectuons pour l’instant uniquement un affichage et une pause, mais nous y ajouterons prochainement la gestion du signal SDR et le code de traitement du signal numérique.

Signaux et slots

Dans l’exemple précédent, nous avons utilisé le signal end_of_run pour la communication entre le thread de travail et le thread d’interface graphique. Ce modèle, courant dans les applications PyQt, est connu sous le nom de mécanisme « signaux et emplacements ». Un signal est émis par un objet (ici, le thread de travail) et est associé à un slot (/NDLR : emplacement en français/) (ici, la fonction de rappel end_of_run_callback du thread d’interface graphique). Un signal peut être associé à plusieurs slots, et un slot peut être associé à plusieurs signaux. Le signal peut également transporter des arguments, qui sont transmis à l’emplacement lors de son émission. Notez que l’opération est réversible : le thread d’interface graphique peut envoyer un signal à l’emplacement du thread de travail. Le mécanisme de signaux/emplacements est un moyen puissant de communiquer entre les différentes parties d’une application PyQt, créant une structure événementielle. Il est largement utilisé dans l’exemple de code suivant. Retenez simplement qu’un slot est une fonction de rappel, et qu’un signal est un moyen de signaler cette fonction de rappel.

PyQtGraph

PyQtGraph est une bibliothèque basée sur PyQt et NumPy qui offre des capacités de traçage rapides et efficaces, PyQt étant trop généraliste pour intégrer des fonctionnalités de traçage. Conçue pour les applications temps réel, elle est optimisée pour la vitesse. Elle est similaire à Matplotlib à bien des égards, mais destinée aux applications temps réel plutôt qu’aux graphiques individuels. L’exemple simple ci-dessous permet de comparer les performances de PyQtGraph et de Matplotlib : il suffit de remplacer if True par False. Sur un processeur Intel Core i9-10900K à 3,70 GHz, le code PyQtGraph s’est mis à jour à plus de 1 000 images par seconde, tandis que le code Matplotlib s’est mis à jour à 40 images par seconde. Cela étant dit, si vous constatez que l’utilisation de Matplotlib vous est utile (par exemple, pour gagner du temps de développement ou parce que vous souhaitez une fonctionnalité spécifique que PyQtGraph ne prend pas en charge), vous pouvez intégrer des graphiques Matplotlib dans une application PyQt, en utilisant le code ci-dessous comme point de départ.

Développez pour afficher le code

import numpy as np
import time
import matplotlib
matplotlib.use('Qt5Agg')
from PyQt6 import QtCore, QtWidgets
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import pyqtgraph as pg # tested with pyqtgraph==0.13.7

n_data = 1024

if True:
    class MplCanvas(FigureCanvas):
        def __init__(self):
            fig = Figure(figsize=(13, 8), dpi=100)
            self.axes = fig.add_subplot(111)
            super(MplCanvas, self).__init__(fig)

    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self):
            super(MainWindow, self).__init__()

            self.canvas = MplCanvas()
            self._plot_ref = self.canvas.axes.plot(np.arange(n_data), '.-r')[0]
            self.canvas.axes.set_xlim(0, n_data)
            self.canvas.axes.set_ylim(-5, 5)
            self.canvas.axes.grid(True)
            self.setCentralWidget(self.canvas)

            # Configurez une minuterie pour déclencher le redessin en appelant update_plot
            self.timer = QtCore.QTimer()
            self.timer.setInterval(0) # provoque le démarrage immédiat du minuteur
            self.timer.timeout.connect(self.update_plot) # provoque le redémarrage automatique du minuteur
            self.timer.start()
            self.start_t = time.time() # utilisé pour l'analyse comparative

            self.show()

        def update_plot(self):
            self._plot_ref.set_ydata(np.random.randn(n_data))
            self.canvas.draw() # Déclenchez la mise à jour et le redessin du canevas.
            print('FPS:', 1/(time.time()-self.start_t)) # on a obtenu environ 42 FPS sur un i9-10900K
            self.start_t = time.time()

else:
    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self):
            super(MainWindow, self).__init__()

            self.time_plot = pg.PlotWidget()
            self.time_plot.setYRange(-5, 5)
            self.time_plot_curve = self.time_plot.plot([])
            self.setCentralWidget(self.time_plot)

            # Configurez une minuterie pour déclencher le redessin en appelant update_plot.
            self.timer = QtCore.QTimer()
            self.timer.setInterval(0) # provoque le démarrage immédiat du timer
            self.timer.timeout.connect(self.update_plot) # provoque le redémarrage automatique du minuteur
            self.timer.start()
            self.start_t = time.time() # utilisé pour l'évaluation des performances.

            self.show()

        def update_plot(self):
            self.time_plot_curve.setData(np.random.randn(n_data))
            print('FPS:', 1/(time.time()-self.start_t)) # on a obtenu environ 42 FPS sur un i9-10900K
            self.start_t = time.time()

app = QtWidgets.QApplication([])
w = MainWindow()
app.exec()

Pour ce qui est d’utiliser PyQtGraph, nous l’importons avec import pyqtgraph as pg et nous pouvons ensuite créer un widget Qt qui représente un graphique 1D comme suit (ce code va dans la méthode __init__ de MainWindow).

# Exemple de graphique PyQtGraph
time_plot = pg.PlotWidget(labels={'left': 'Amplitude', 'bottom': 'Time'})
time_plot_curve        =       time_plot.plot(np.arange(1000),
np.random.randn(1000)) # x et y
time_plot.setYRange(-5, 5)

self.setCentralWidget(time_plot)

System Message: WARNING/2 (/home/vs/SPHINX/source/pyqt.rst, line 237)

Cannot scale image! Could not get size from "../_images/pyqtgraph_example.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pyqtgraph_example.png'

Vous pouvez constater qu’il est relativement simple de configurer un graphique, et le résultat est simplement un widget supplémentaire à ajouter à votre interface graphique. Outre les graphiques 1D, PyQtGraph possède également un équivalent de la fonction imshow() de Matplotlib, qui permet de tracer des graphiques 2D à l’aide d’une palette de couleurs, que nous utiliserons pour notre spectrogramme/waterfall en temps réel. L’un des avantages de PyQtGraph est que les graphiques qu’il crée sont de simples widgets Qt, et que nous ajoutons d’autres éléments Qt (par exemple, un rectangle d’une certaine taille à une certaine coordonnée) en utilisant uniquement PyQt. En effet, PyQtGraph utilise la classe QGraphicsScene de PyQt, qui fournit une interface pour gérer un grand nombre d’éléments graphiques 2D. Rien ne nous empêche donc d’ajouter des lignes, des rectangles, du texte, des ellipses, des polygones et des bitmaps, directement en utilisant PyQt.

Dispositions

Dans les exemples précédents, nous avons utilisé self.setCentralWidget() pour définir le widget principal de la fenêtre. Cette méthode simple ne permet pas de créer des dispositions plus complexes. Pour cela, nous pouvons utiliser des dispositions, qui permettent d’organiser les widgets dans une fenêtre. Il existe plusieurs types de dispositions, notamment QHBoxLayout, QVBoxLayout, QGridLayout et QFormLayout. QHBoxLayout et QVBoxLayout disposent les widgets horizontalement et verticalement, respectivement. QGridLayout les dispose sous forme de grille, et QFormLayout les dispose sur deux colonnes : la première colonne contient les étiquettes et la seconde, les champs de saisie.

Pour créer une nouvelle mise en page et y ajouter des widgets, essayez d’ajouter ce qui suit dans la méthode __init__ de votre MainWindow :

layout = QHBoxLayout()
layout.addWidget(QPushButton("Left-Most"))
layout.addWidget(QPushButton("Center"), 1)
layout.addWidget(QPushButton("Right-Most"), 2)
self.setLayout(layout)

Dans cet exemple, les widgets sont empilés horizontalement. Cependant, en remplaçant QHBoxLayout par QVBoxLayout, il est possible de les empiler verticalement. La fonction addWidget permet d’ajouter des widgets à la mise en page. Son deuxième argument, optionnel, est un facteur d’étirement qui détermine l’espace occupé par le widget par rapport aux autres.

QGridLayout possède des paramètres supplémentaires : il est nécessaire de spécifier la ligne et la colonne du widget, ainsi que le nombre de lignes et de colonnes qu’il doit occuper (par défaut : 1 et 1). Voici un exemple de QGridLayout :

layout = QGridLayout()
layout.addWidget(QPushButton("Button at (0, 0)"), 0, 0)
layout.addWidget(QPushButton("Button at (0, 1)"), 0, 1)
layout.addWidget(QPushButton("Button at (0, 2)"), 0, 2)
layout.addWidget(QPushButton("Button at (1, 0)"), 1, 0)
layout.addWidget(QPushButton("Button at (1, 1)"), 1, 1)
layout.addWidget(QPushButton("Button at (1, 2)"), 1, 2)
layout.addWidget(QPushButton("Button at (2, 0) spanning 2 columns"), 2, 0, 1, 2)
self.setLayout(layout)

Pour notre analyseur de spectre, nous utiliserons QGridLayout pour la mise en page générale, mais nous ajouterons également QHBoxLayout pour empiler les widgets horizontalement dans un espace de la grille. Vous pouvez imbriquer des mises en page simplement en créant une nouvelle mise en page et en l’ajoutant à la mise en page de niveau supérieur (ou parente), par exemple :

layout = QGridLayout()
self.setLayout(layout)
inner_layout = QHBoxLayout()
layout.addLayout(inner_layout)

QPushButton

Le premier widget que nous allons aborder est le QPushButton, un simple bouton cliquable. Nous avons déjà vu comment créer un QPushButton et associer son signal clicked à une fonction de rappel. Le QPushButton possède d’autres signaux, notamment pressed, released et toggled. Le signal toggled est émis lorsque le bouton est activé ou désactivé, et est utile pour créer des boutons à bascule. Le QPushButton possède également plusieurs propriétés, dont text, icon et checkable. Enfin, le QPushButton possède une méthode appelée click() qui simule un clic sur le bouton. Pour notre application d’analyseur de spectre SDR, nous utiliserons des boutons pour déclencher un réglage automatique de la plage des graphiques, en utilisant les données actuelles pour calculer les limites de l’axe des y. Comme nous avons déjà utilisé le composant QPushButton, nous n’entrerons pas dans les détails ici. Vous trouverez plus d’informations dans la documentation de QPushButton : [https://doc.qt.io/qtforpython/PySide6/QtWidgets/QPushButton.html].

QSlider

Le QSlider est un widget qui permet à l’utilisateur de sélectionner une valeur dans une plage de valeurs. Le QSlider possède plusieurs propriétés, notamment minimum, maximum, value et orientation. Le composant QSlider possède également plusieurs signaux, notamment valueChanged, sliderPressed et sliderReleased. Il dispose aussi d’une méthode setValue() qui permet de définir la valeur du curseur ; nous l’utiliserons fréquemment. La documentation de QSlider est disponible ici : https://doc.qt.io/qtforpython/PySide6/QtWidgets/QSlider.html.

Pour notre application d’analyseur de spectre, nous utiliserons des curseurs QSlider pour ajuster la fréquence centrale et le gain du récepteur SDR. Voici un extrait du code final de l’application qui crée le curseur de gain :

# Slider de gain avec étiquette
gain_slider = QSlider(Qt.Orientation.Horizontal)
gain_slider.setRange(0, 73) # min et max inclus. L'intervalle est toujours de 1
gain_slider.setValue(50) # valeur initiale
gain_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
gain_slider.setTickInterval(2) # à des fins visuelles uniquement
gain_slider.sliderMoved.connect(worker.update_gain)
gain_label = QLabel()
def update_gain_label(val):
    gain_label.setText("Gain: " + str(val))
gain_slider.sliderMoved.connect(update_gain_label)
update_gain_label(gain_slider.value()) # initialisation du label
layout.addWidget(gain_slider, 5, 0)
layout.addWidget(gain_label, 5, 1)

Il est très important de savoir que QSlider utilise des entiers. En définissant la plage de 0 à 73, on permet au curseur de choisir des valeurs entières comprises entre ces nombres (début et fin inclus). La fonction setTickInterval(2) est purement visuelle. C’est pourquoi nous utiliserons le kHz comme unité pour le curseur de fréquence, afin d’obtenir une granularité jusqu’à 1 kHz.

Au milieu du code ci-dessus, vous remarquerez la création d’un QLabel, une simple étiquette de texte. Pour afficher la valeur actuelle du curseur, nous devons créer un slot (c’est-à-dire une fonction de rappel) qui met à jour l’étiquette. Nous connectons cette fonction de rappel au signal sliderMoved, émis automatiquement à chaque déplacement du curseur. Nous appelons également cette fonction une première fois pour initialiser l’étiquette avec la valeur actuelle du curseur (50 dans notre cas). Il faut également connecter le signal sliderMoved à un slot situé dans le thread de travail, qui mettra à jour le gain du SDR (rappelons que nous préférons ne pas gérer le SDR ni effectuer de traitement du signal numérique dans le thread principal de l’interface graphique). La fonction de rappel définissant ce slot sera abordée ultérieurement.

QComboBox

Le QComboBox est un widget de type liste déroulante permettant à l’utilisateur de sélectionner un élément dans une liste. Il possède plusieurs propriétés, notamment currentText, currentIndex et count. Il dispose également de signaux tels que currentTextChanged, currentIndexChanged et activated. Enfin, il possède une méthode addItem() pour ajouter un élément à la liste et une méthode insertItem() pour insérer un élément à un index spécifique, bien que nous ne les utilisions pas dans notre exemple d’analyseur de spectre. La documentation de QComboBox est disponible ici : https://doc.qt.io/qtforpython/PySide6/QtWidgets/QComboBox.html.

Pour notre application d’analyseur de spectre, nous utiliserons un QComboBox afin de sélectionner la fréquence d’échantillonnage dans une liste prédéfinie. Au début de notre code, nous définissons les fréquences d’échantillonnage possibles avec sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]. Dans la méthode __init__ de la fenêtre principale, nous créons le QComboBox comme suit :

# Liste déroulante de fréquence d'échantillonnage utilisant QComboBox
sample_rate_combobox = QComboBox()
sample_rate_combobox.addItems([str(x) + ' MHz' for x in sample_rates])
sample_rate_combobox.setCurrentIndex(0) # Il faut lui fournir l'index, et non une chaîne de caractères.
sample_rate_combobox.currentIndexChanged.connect(worker.update_sample_rate)
sample_rate_label = QLabel()
def update_sample_rate_label(val):
    sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz")
sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label)
update_sample_rate_label(sample_rate_combobox.currentIndex()) # initialisation du label
layout.addWidget(sample_rate_combobox, 6, 0)
layout.addWidget(sample_rate_label, 6, 1)

La seule véritable différence entre ceci et le curseur est le addItems() où vous lui donnez la liste des chaînes à utiliser comme options, et setCurrentIndex() qui définit la valeur de départ.

Lonctions lambda

Rappelez-vous dans le code ci-dessus où nous avons fait :

def update_sample_rate_label(val):
    sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz")
sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label)

Nous créons une fonction ne contenant qu’une seule ligne de code, puis nous passons cette fonction (les fonctions sont aussi des objets !) à connect(). Pour simplifier, réécrivons ce modèle de code en utilisant du Python de base :

def my_function(x):
    print(x)
y.call_that_takes_in_function_obj(my_function)

Dans ce cas précis, nous avons une fonction ne contenant qu’une seule ligne de code, et nous n’y faisons référence qu’une seule fois : lors de la définition du rappel connect. Dans ce genre de situation, nous pouvons utiliser une fonction lambda, qui permet de définir une fonction sur une seule ligne. Voici le code ci-dessus réécrit à l’aide d’une fonction lambda :

y.call_that_takes_in_function_obj(lambda x: print(x))

Si vous n’avez jamais utilisé de fonction lambda, cela peut paraître étrange, et vous n’êtes d’ailleurs pas obligé de les utiliser, mais cela permet de gagner deux lignes de code et de le rendre plus concis. Le principe est le suivant : le nom de l’argument temporaire est indiqué après « lambda », et tout ce qui suit les deux-points correspond au code qui agira sur cette variable. Il est possible d’utiliser plusieurs arguments, séparés par des virgules, ou même aucun argument avec : lambda : <code>. À titre d’exercice, essayez de réécrire la fonction update_sample_rate_label ci-dessus en utilisant une fonction lambda.

Le widget de tracé de PyQtGraph

Le widget PlotWidget de PyQtGraph permet de générer des graphiques 1D, à l’instar de plt.plot(x,y) de Matplotlib. Nous l’utiliserons pour les graphiques dans le domaine temporel et fréquentiel (PSD), bien qu’il convienne également aux graphiques IQ (que notre analyseur de spectre ne prend pas en charge). Pour les curieux, PlotWidget est une sous-classe de QGraphicsView [https://doc.qt.io/qtforpython-5/PySide2/QtWidgets/QGraphicsView.html] de PyQt, qui est un widget permettant d’afficher le contenu d’une QGraphicsScene [https://doc.qt.io/qtforpython-5/PySide2/QtWidgets/QGraphicsScene.html#PySide2.QtWidgets.PySide2.QtWidgets.QGraphicsScene], qui est une surface permettant de gérer un grand nombre d’éléments graphiques 2D dans Qt. L’important à retenir concernant PlotWidget est qu’il s’agit simplement d’un widget contenant un unique PlotItem [https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/plotitem.html#pyqtgraph.PlotItem]. Du point de vue de la documentation, il est donc préférable de se référer directement à la documentation de PlotItem : https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/plotitem.html. Un PlotItem contient une ViewBox pour afficher les données à représenter graphiquement, ainsi que des AxisItems et des labels pour afficher les axes et le titre, comme on peut s’y attendre.

Voici un exemple simple d’utilisation d’un PlotWidget (à ajouter dans la méthode __init__ de MainWindow) :

import pyqtgraph as pg
plotWidget = pg.plot(title="My Title")
plotWidget.plot(x, y)

où x et y sont généralement des tableaux NumPy, comme avec la fonction plt.plot() de Matplotlib. Cependant, cela représente un graphique statique où les données ne changent jamais. Pour notre analyseur de spectre, nous souhaitons mettre à jour les données dans notre thread de travail. Par conséquent, lors de l’initialisation du graphique, nous n’avons même pas besoin de lui fournir de données ; il suffit de le configurer. Voici comment nous initialisons le graphique temporel dans notre application d’analyseur de spectre :

# Time plot
time_plot = pg.PlotWidget(labels={'left': 'Amplitude', 'bottom': 'Time [microseconds]'})
time_plot.setMouseEnabled(x=False, y=True)
time_plot.setYRange(-1.1, 1.1)
time_plot_curve_i = time_plot.plot([])
time_plot_curve_q = time_plot.plot([])
layout.addWidget(time_plot, 1, 0)

Vous pouvez constater que nous créons deux graphiques/courbes différents, un pour I et un pour Q. Le reste du code devrait être explicite. Pour pouvoir mettre à jour le graphique, nous devons créer un emplacement (c’est-à-dire une fonction de rappel) dans la méthode __init__ de la fenêtre principale.

def time_plot_callback(samples):
    time_plot_curve_i.setData(samples.real)
    time_plot_curve_q.setData(samples.imag)

Nous connecterons ce slot au signal du thread de travail émis lors de la disponibilité de nouveaux échantillons, comme indiqué plus loin.

La dernière étape dans la méthode __init__ de MainWindow consiste à ajouter deux boutons à droite du graphique. Ces boutons activeront un réglage automatique de la plage. L’un utilisera les valeurs minimales et maximales actuelles, tandis que l’autre définira la plage entre -1,1 et 1,1 (correspondant aux limites de conversion analogique-numérique de nombreux SDR, plus une marge de 10 %). Nous créerons une mise en page interne, plus précisément un QVBoxLayout, pour empiler verticalement ces deux boutons. Voici le code permettant d’ajouter les boutons :

# Boutons de plage automatique du graphique temporel
time_plot_auto_range_layout = QVBoxLayout()
layout.addLayout(time_plot_auto_range_layout, 1, 1)
auto_range_button = QPushButton('Auto Range')
auto_range_button.clicked.connect(lambda : time_plot.autoRange()) # lambda signifie simplement qu'il s'agit d'une fonction sans nom
time_plot_auto_range_layout.addWidget(auto_range_button)
auto_range_button2 = QPushButton('-1 to +1\n(ADC limits)')
auto_range_button2.clicked.connect(lambda : time_plot.setYRange(-1.1, 1.1))
time_plot_auto_range_layout.addWidget(auto_range_button2)

Et voici à quoi cela ressemble au final :

System Message: WARNING/2 (/home/vs/SPHINX/source/pyqt.rst, line 442)

Cannot scale image! Could not get size from "../_images/pyqt_time_plot.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pyqt_time_plot.png'

Nous utiliserons un modèle similaire pour le graphique du domaine fréquentiel (PSD).

ImageItem de PyQtGraph

Un analyseur de spectre se doit d’afficher un spectrogramme en cascade (ou spectrogramme en temps réel). Pour cela, nous utiliserons l’objet ImageItem de PyQtGraph, qui génère des images à 1, 3 ou 4 canaux. Un canal correspond à un tableau 2D de nombres flottants ou entiers, qui utilise ensuite une table de correspondance (LUT) pour appliquer une palette de couleurs et créer l’image. On peut également utiliser les formats RGB (3 canaux) ou RGBA (4 canaux). Nous calculerons notre spectrogramme sous forme d’un tableau NumPy 2D de nombres flottants et le transmettrons directement à l’objet ImageItem. Nous choisirons une palette de couleurs et exploiterons la fonctionnalité intégrée d’affichage d’une LUT graphique permettant de visualiser la distribution des valeurs de nos données et l’application de la palette.

L’initialisation du spectrogramme watefall est assez simple : nous utilisons un PlotWidget comme conteneur (afin de conserver l’affichage des axes x et y) et y ajoutons un ImageItem.

# Waterfall plot
waterfall = pg.PlotWidget(labels={'left': 'Time [s]', 'bottom': 'Frequency [MHz]'})
imageitem = pg.ImageItem(axisOrder='col-major') # cet argument est simplement pour la performance
waterfall.addItem(imageitem)
waterfall.setMouseEnabled(x=False, y=False)
waterfall_layout.addWidget(waterfall)

Le slot/callback associé à la mise à jour des données en cascade, qui se trouve dans MainWindow’s __init__, est le suivant :

def waterfall_plot_callback(spectrogram):
    imageitem.setImage(spectrogram, autoLevels=False)
    sigma = np.std(spectrogram)
    mean = np.mean(spectrogram)
    self.spectrogram_min = mean - 2*sigma # save to window state
    self.spectrogram_max = mean + 2*sigma

Le spectrogramme sera un tableau NumPy 2D de nombres flottants. Outre la définition des données de l’image, nous calculerons les valeurs minimale et maximale de la palette de couleurs, en fonction de la moyenne et de la variance des données, que nous utiliserons ultérieurement. La dernière partie du code de l’interface graphique du spectrogramme consiste à créer la barre de couleurs, qui définit également la palette de couleurs utilisée.

# Colorbar for waterfall
colorbar = pg.HistogramLUTWidget()
colorbar.setImageItem(imageitem) # Connecte la barre à l'élément image du spectrogramme
colorbar.item.gradient.loadPreset('viridis') #  définit la palette de couleurs, et définit également l'élément image
imageitem.setLevels((-30, 20)) # doit être placé après la création de la barre de couleur (pour une raison inconnue)
waterfall_layout.addWidget(colorbar)

La deuxième ligne est importante ; c’est elle qui relie la barre de couleurs à l’élément ImageItem. C’est également dans ce code que l’on choisit la palette de couleurs et que l’on définit les niveaux de départ (de -30 dB à +20 dB dans notre cas). Le code du thread de travail illustre le calcul et le stockage du tableau 2D du spectrogramme. Ci-dessous, une capture d’écran de cette partie de l’interface graphique montre l’incroyable fonctionnalité intégrée de la barre de couleurs et de l’affichage de la LUT. Notez que la courbe en cloche horizontale représente la distribution des valeurs du spectrogramme, une information très utile.

System Message: WARNING/2 (/home/vs/SPHINX/source/pyqt.rst, line 491)

Cannot scale image! Could not get size from "../_images/pyqt_spectrogram.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pyqt_spectrogram.png'

Worker Thread

Rappelez-vous, au début de ce chapitre, nous avons appris à créer un thread séparé à l’aide d’une classe nommée SDRWorker et de sa fonction run(). C’est dans ce thread que nous placerons tout notre code SDR et DSP, à l’exception de l’initialisation du SDR, que nous effectuerons globalement pour le moment. Ce thread de travail sera également chargé de mettre à jour les trois graphiques en émettant des signaux lorsque de nouveaux échantillons sont disponibles, afin de déclencher les fonctions de rappel que nous avons déjà créées dans MainWindow, qui mettent finalement à jour les graphiques. La classe SDRWorker se divise en trois sections :

init() - utilisée pour initialiser un état, comme le tableau 2D du spectrogramme.

PyQt Signals - nous devons définir les signaux personnalisés qui seront émis

PyQt Slots - les fonctions de rappel déclenchées par des événements d’interface graphique, comme le déplacement d’un curseur

run() - la boucle principale qui s’exécute en continu

Signaux PyQt

Dans le code de l’interface graphique, nous n’avions pas besoin de définir de signaux, car ils étaient intégrés aux widgets utilisés, comme le signal valueChanged de QSlider. Notre classe SDRWorker est personnalisée, et tous les signaux que nous souhaitons émettre doivent être définis avant d’appeler run(). Voici le code de la classe SDRWorker, qui définit quatre signaux que nous utiliserons, ainsi que leurs types de données correspondants :

# Signaux PyQt
time_plot_update = pyqtSignal(np.ndarray)
freq_plot_update = pyqtSignal(np.ndarray)
waterfall_plot_update = pyqtSignal(np.ndarray)
end_of_run = pyqtSignal() # se produit plusieurs fois par seconde

Les trois premiers signaux envoient un seul objet : un tableau NumPy. Le dernier signal n’envoie aucun objet. Il est également possible d’envoyer plusieurs objets simultanément, en séparant les types de données par des virgules, mais cela n’est pas nécessaire pour notre application. À n’importe quel endroit de la fonction run(), nous pouvons émettre un signal vers le thread d’interface graphique en une seule ligne de code, par exemple :

self.time_plot_update.emit(samples)

Il reste une dernière étape pour établir toutes les connexions signaux/slots : dans le code de l’interface graphique (qui se trouve à la toute fin de la méthode __init__ de MainWindow), nous devons connecter les signaux du thread de travail aux slots de l’interface graphique, par exemple :

worker.time_plot_update.connect(time_plot_callback) # connection du signal à la fonction d'appel (callback)

Rappelez-vous que worker est l’instance de la classe SDRWorker créée dans le code de l’interface graphique. Nous connectons ici le signal du thread de travail, time_plot_update, à l’emplacement de l’interface graphique, time_plot_callback, défini précédemment. Revoyez les extraits de code présentés jusqu’ici et observez leur fonctionnement. Cela vous permettra de bien comprendre la communication entre l’interface graphique et le thread de travail, un aspect fondamental de la programmation PyQt.

Slots des Worker Threads

Les slots des worker threads sont les fonctions de rappel déclenchées par des événements d’interface graphique, comme le déplacement du curseur de gain. Leur fonctionnement est assez simple ; par exemple, cet emplacement met à jour la valeur de gain du SDR avec la nouvelle valeur sélectionnée par le curseur :

def update_gain(self, val):
    print("Updated gain to:", val, 'dB')
    sdr.set_rx_gain(val)

Worker Thread Run()

La fonction run() est l’endroit où se déroule toute la partie DSP intéressante ! Dans notre application, chaque fonction run() commencera par la réception d’un ensemble d’échantillons provenant du SDR (ou par la simulation d’échantillons si vous n’avez pas de SDR).

# Main loop
def run(self):
    if sdr_type == "pluto":
        samples = sdr.rx()/2**11 # Receive samples
    elif sdr_type == "usrp":
        streamer.recv(recv_buffer, metadata)
        samples = recv_buffer[0] # will be np.complex64
    elif sdr_type == "sim":
        tone = np.exp(2j*np.pi*self.sample_rate*0.1*np.arange(fft_size)/self.sample_rate)
        noise = np.random.randn(fft_size) + 1j*np.random.randn(fft_size)
        samples = self.gain*tone*0.02 + 0.1*noise
        # Truncate to -1 to +1 to simulate ADC bit limits
        np.clip(samples.real, -1, 1, out=samples.real)
        np.clip(samples.imag, -1, 1, out=samples.imag)

    ...

Comme vous pouvez le constater, pour l’exemple simulé, nous générons une tonalité avec du bruit blanc, puis nous tronquons les échantillons de -1 à +1.

Passons maintenant au traitement numérique du signal (DSP) ! Nous savons qu’il nous faudra effectuer la transformée de Fourier rapide (FFT) pour obtenir le graphique dans le domaine fréquentiel et le spectrogramme. Il s’avère que nous pouvons simplement utiliser la densité spectrale de puissance (DSP) de cet ensemble d’échantillons comme une ligne du spectrogramme. Il nous suffit donc de décaler notre spectrogramme/diagramme en cascade d’une ligne vers le haut et d’ajouter cette nouvelle ligne en bas (ou en haut, peu importe). À chaque mise à jour du graphique, nous émettons le signal contenant les données mises à jour. Nous signalons également la fin de la fonction run() afin que le thread de l’interface graphique lance immédiatement un nouvel appel à run(). Au final, le code est plutôt court.

...

self.time_plot_update.emit(samples[0:time_plot_samples])

PSD = 10.0*np.log10(np.abs(np.fft.fftshift(np.fft.fft(samples)))**2/fft_size)
self.PSD_avg = self.PSD_avg * 0.99 + PSD * 0.01
self.freq_plot_update.emit(self.PSD_avg)

self.spectrogram[:] = np.roll(self.spectrogram, 1, axis=1) # shifts waterfall 1 row
self.spectrogram[:,0] = PSD # fill last row with new fft results
self.waterfall_plot_update.emit(self.spectrogram)

self.end_of_run.emit() # emit the signal to keep the loop going
# end of run()

Notez que nous n’envoyons pas l’intégralité des échantillons au graphique temporel, car cela représenterait un nombre excessif de points. Seuls les 500 premiers échantillons sont envoyés (paramétrable en début de script, non affiché ici). Pour le graphique de la densité spectrale de puissance (DSP), nous utilisons une moyenne mobile de la DSP, obtenue en stockant la DSP précédente et en y ajoutant 1 % de la nouvelle DSP. Cette méthode simple permet de lisser le graphique de la DSP. Notez que l’ordre d’appel de la fonction emit() pour les signaux est indifférent ; ils auraient tout aussi bien pu être tous placés à la fin de la fonction run().

Exemple final : Code complet

Jusqu’à présent, nous avons examiné des extraits de code de l’application d’analyse de spectre. Nous allons maintenant étudier le code complet et l’exécuter. Il est compatible avec PlutoSDR, USRP et le mode simulation. Si vous ne possédez ni PlutoSDR ni USRP, laissez le code tel quel ; il utilisera alors le mode simulation. Sinon, modifiez sdr_type. En mode simulation, si vous augmentez le gain au maximum, vous constaterez que le signal est tronqué dans le domaine temporel, ce qui provoque l’apparition de signaux parasites dans le domaine fréquentiel.

N’hésitez pas à utiliser ce code comme point de départ pour votre propre application SDR en temps réel ! Vous trouverez ci-dessous une animation de l’application en action, utilisant un PlutoSDR pour analyser la bande cellulaire 750 MHz, puis la bande Wi-Fi 2,4 GHz. Une version de meilleure qualité est disponible sur YouTube ici here [https://youtu.be/hvofiY3Q_yo].

System Message: WARNING/2 (/home/vs/SPHINX/source/pyqt.rst, line 605)

Cannot scale image! Could not get size from "../_images/pyqt_animation.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/pyqt_animation.gif'

Bogues connus (pour aider à les corriger, modifiez ce fichier edit this [https://github.com/777arc/PySDR/edit/master/figure-generating-scripts/pyqt_example.py]) :

L’axe des x du spectrogramme ne se met pas à jour lorsque l’on modifie la fréquence centrale (contrairement au graphique PSD)

Code complet :

from PyQt6.QtCore import QSize, Qt, QThread, pyqtSignal, QObject, QTimer
from PyQt6.QtWidgets import QApplication, QMainWindow, QGridLayout, QWidget, QSlider, QLabel, QHBoxLayout, QVBoxLayout, QPushButton, QComboBox  # tested with PyQt6==6.7.0
import pyqtgraph as pg # tested with pyqtgraph==0.13.7
import numpy as np
import time
import signal # lets control-C actually close the app

# Valeurs par défaut
fft_size = 4096 # determines buffer size
num_rows = 200
center_freq = 750e6
sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5] # MHz
sample_rate = sample_rates[0] * 1e6
time_plot_samples = 500
gain = 50 # 0 to 73 dB. int

sdr_type = "sim" # or "usrp" or "pluto"

# Initialisation du SDR
if sdr_type == "pluto":
    import adi
    sdr = adi.Pluto("ip:192.168.1.10")
    sdr.rx_lo = int(center_freq)
    sdr.sample_rate = int(sample_rate)
    sdr.rx_rf_bandwidth = int(sample_rate*0.8) # bande-passante du filtre anti-repliement
    sdr.rx_buffer_size = int(fft_size)
    sdr.gain_control_mode_chan0 = 'manual'
    sdr.rx_hardwaregain_chan0 = gain # dB
elif sdr_type == "usrp":
    import uhd
    #usrp = uhd.usrp.MultiUSRP(args="addr=192.168.1.10")
    usrp = uhd.usrp.MultiUSRP(args="addr=192.168.1.201")
    usrp.set_rx_rate(sample_rate, 0)
    usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(center_freq), 0)
    usrp.set_rx_gain(gain, 0)

    # Configuration du flux (stream) et du buiffer de réception
    st_args = uhd.usrp.StreamArgs("fc32", "sc16")
    st_args.channels = [0]
    metadata = uhd.types.RXMetadata()
    streamer = usrp.get_rx_stream(st_args)
    recv_buffer = np.zeros((1, fft_size), dtype=np.complex64)

    # Démarrage du flux
    stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont)
    stream_cmd.stream_now = True
    streamer.issue_stream_cmd(stream_cmd)

    def flush_buffer():
        for _ in range(10):
            streamer.recv(recv_buffer, metadata)

class SDRWorker(QObject):
    def __init__(self):
        super().__init__()
        self.gain = gain
        self.sample_rate = sample_rate
        self.freq = 0 # in kHz, to deal with QSlider being ints and with a max of 2 billion
        self.spectrogram = -50*np.ones((fft_size, num_rows))
        self.PSD_avg = -50*np.ones(fft_size)

    # Signaux PyQt
    time_plot_update = pyqtSignal(np.ndarray)
    freq_plot_update = pyqtSignal(np.ndarray)
    waterfall_plot_update = pyqtSignal(np.ndarray)
    end_of_run = pyqtSignal() # happens many times a second

    # Slots PyQt
    def update_freq(self, val): # TODO: WE COULD JUST MODIFY THE SDR IN THE GUI THREAD
        print("Updated freq to:", val, 'kHz')
        if sdr_type == "pluto":
            sdr.rx_lo = int(val*1e3)
        elif sdr_type == "usrp":
            usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(val*1e3), 0)
            flush_buffer()

    def update_gain(self, val):
        print("Updated gain to:", val, 'dB')
        self.gain = val
        if sdr_type == "pluto":
            sdr.rx_hardwaregain_chan0 = val
        elif sdr_type == "usrp":
            usrp.set_rx_gain(val, 0)
            flush_buffer()

    def update_sample_rate(self, val):
        print("Updated sample rate to:", sample_rates[val], 'MHz')
        if sdr_type == "pluto":
            sdr.sample_rate = int(sample_rates[val] * 1e6)
            sdr.rx_rf_bandwidth = int(sample_rates[val] * 1e6 * 0.8)
        elif sdr_type == "usrp":
            usrp.set_rx_rate(sample_rates[val] * 1e6, 0)
            flush_buffer()

    # Boucle principale
    def run(self):
        start_t = time.time()

        if sdr_type == "pluto":
            samples = sdr.rx()/2**11 # Receive samples
        elif sdr_type == "usrp":
            streamer.recv(recv_buffer, metadata)
            samples = recv_buffer[0] # will be np.complex64
        elif sdr_type == "sim":
            tone = np.exp(2j*np.pi*self.sample_rate*0.1*np.arange(fft_size)/self.sample_rate)
            noise = np.random.randn(fft_size) + 1j*np.random.randn(fft_size)
            samples = self.gain*tone*0.02 + 0.1*noise
            # Truncate to -1 to +1 to simulate ADC bit limits
            np.clip(samples.real, -1, 1, out=samples.real)
            np.clip(samples.imag, -1, 1, out=samples.imag)

        self.time_plot_update.emit(samples[0:time_plot_samples])

        PSD = 10.0*np.log10(np.abs(np.fft.fftshift(np.fft.fft(samples)))**2/fft_size)
        self.PSD_avg = self.PSD_avg * 0.99 + PSD * 0.01
        self.freq_plot_update.emit(self.PSD_avg)

        self.spectrogram[:] = np.roll(self.spectrogram, 1, axis=1) # shifts waterfall 1 row
        self.spectrogram[:,0] = PSD # fill last row with new fft results
        self.waterfall_plot_update.emit(self.spectrogram)

        print("Frames per second:", 1/(time.time() - start_t))
        self.end_of_run.emit() # emit the signal to keep the loop going

# Sous-classe QMainWindow pour configurer la fenêtre principale de
la fenêtre application
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("The PySDR Spectrum Analyzer")
        self.setFixedSize(QSize(1500, 1000)) # window size, starting size should fit on 1920 x 1080

        self.spectrogram_min = 0
        self.spectrogram_max = 0

        layout = QGridLayout() # overall layout

        # Initialisation du worker et du thread
        self.sdr_thread = QThread()
        self.sdr_thread.setObjectName('SDR_Thread') # so we can see it in htop, note you have to hit F2 -> Display options -> Show custom thread names
        worker = SDRWorker()
        worker.moveToThread(self.sdr_thread)

        # Affichage temporel
        time_plot = pg.PlotWidget(labels={'left': 'Amplitude', 'bottom': 'Time [microseconds]'})
        time_plot.setMouseEnabled(x=False, y=True)
        time_plot.setYRange(-1.1, 1.1)
        time_plot_curve_i = time_plot.plot([])
        time_plot_curve_q = time_plot.plot([])
        layout.addWidget(time_plot, 1, 0)

        # Boutons de plage automatique du graphique temporel
        time_plot_auto_range_layout = QVBoxLayout()
        layout.addLayout(time_plot_auto_range_layout, 1, 1)
        auto_range_button = QPushButton('Auto Range')
        auto_range_button.clicked.connect(lambda : time_plot.autoRange()) # lambda just means its an unnamed function
        time_plot_auto_range_layout.addWidget(auto_range_button)
        auto_range_button2 = QPushButton('-1 to +1\n(ADC limits)')
        auto_range_button2.clicked.connect(lambda : time_plot.setYRange(-1.1, 1.1))
        time_plot_auto_range_layout.addWidget(auto_range_button2)

        # Graohique fréquentiel
        freq_plot = pg.PlotWidget(labels={'left': 'PSD', 'bottom': 'Frequency [MHz]'})
        freq_plot.setMouseEnabled(x=False, y=True)
        freq_plot_curve = freq_plot.plot([])
        freq_plot.setXRange(center_freq/1e6 - sample_rate/2e6, center_freq/1e6 + sample_rate/2e6)
        freq_plot.setYRange(-30, 20)
        layout.addWidget(freq_plot, 2, 0)

        # Bouton de sélection automatique de la plage de fréquence
        auto_range_button = QPushButton('Auto Range')
        auto_range_button.clicked.connect(lambda : freq_plot.autoRange()) # lambda just means its an unnamed function
        layout.addWidget(auto_range_button, 2, 1)

        # Conteneur pour les éléments liés au flux vidéo
        waterfall_layout = QHBoxLayout()
        layout.addLayout(waterfall_layout, 3, 0)

        # Affichage graphique du spectrogramme
        waterfall = pg.PlotWidget(labels={'left': 'Time [s]', 'bottom': 'Frequency [MHz]'})
        imageitem = pg.ImageItem(axisOrder='col-major') # this arg is purely for performance
        waterfall.addItem(imageitem)
        waterfall.setMouseEnabled(x=False, y=False)
        waterfall_layout.addWidget(waterfall)

        # Colorbar for waterfall
        colorbar = pg.HistogramLUTWidget()
        colorbar.setImageItem(imageitem) # connects the bar to the waterfall imageitem
        colorbar.item.gradient.loadPreset('viridis') # set the color map, also sets the imageitem
        imageitem.setLevels((-30, 20)) # needs to come after colorbar is created for some reason
        waterfall_layout.addWidget(colorbar)

        # Waterfall auto range button
        auto_range_button = QPushButton('Auto Range\n(-2σ to +2σ)')
        def update_colormap():
            imageitem.setLevels((self.spectrogram_min, self.spectrogram_max))
            colorbar.setLevels(self.spectrogram_min, self.spectrogram_max)
        auto_range_button.clicked.connect(update_colormap)
        layout.addWidget(auto_range_button, 3, 1)

        # Freq slider with label, all units in kHz
        freq_slider = QSlider(Qt.Orientation.Horizontal)
        freq_slider.setRange(0, int(6e6))
        freq_slider.setValue(int(center_freq/1e3))
        freq_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
        freq_slider.setTickInterval(int(1e6))
        freq_slider.sliderMoved.connect(worker.update_freq) # there's also a valueChanged option
        freq_label = QLabel()
        def update_freq_label(val):
            freq_label.setText("Frequency [MHz]: " + str(val/1e3))
            freq_plot.autoRange()
        freq_slider.sliderMoved.connect(update_freq_label)
        update_freq_label(freq_slider.value()) # initialize the label
        layout.addWidget(freq_slider, 4, 0)
        layout.addWidget(freq_label, 4, 1)

        # Gain slider with label
        gain_slider = QSlider(Qt.Orientation.Horizontal)
        gain_slider.setRange(0, 73)
        gain_slider.setValue(gain)
        gain_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
        gain_slider.setTickInterval(2)
        gain_slider.sliderMoved.connect(worker.update_gain)
        gain_label = QLabel()
        def update_gain_label(val):
            gain_label.setText("Gain: " + str(val))
        gain_slider.sliderMoved.connect(update_gain_label)
        update_gain_label(gain_slider.value()) # initialize the label
        layout.addWidget(gain_slider, 5, 0)
        layout.addWidget(gain_label, 5, 1)

        # Sample rate dropdown using QComboBox
        sample_rate_combobox = QComboBox()
        sample_rate_combobox.addItems([str(x) + ' MHz' for x in sample_rates])
        sample_rate_combobox.setCurrentIndex(0) # should match the default at the top
        sample_rate_combobox.currentIndexChanged.connect(worker.update_sample_rate)
        sample_rate_label = QLabel()
        def update_sample_rate_label(val):
            sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz")
        sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label)
        update_sample_rate_label(sample_rate_combobox.currentIndex()) # initialize the label
        layout.addWidget(sample_rate_combobox, 6, 0)
        layout.addWidget(sample_rate_label, 6, 1)

        central_widget = QWidget()
        central_widget.setLayout(layout)
        self.setCentralWidget(central_widget)

        # Signals and slots stuff
        def time_plot_callback(samples):
            time_plot_curve_i.setData(samples.real)
            time_plot_curve_q.setData(samples.imag)

        def freq_plot_callback(PSD_avg):
            # TODO figure out if there's a way to just change the visual ticks instead of the actual x vals
            f = np.linspace(freq_slider.value()*1e3 - worker.sample_rate/2.0, freq_slider.value()*1e3 + worker.sample_rate/2.0, fft_size) / 1e6
            freq_plot_curve.setData(f, PSD_avg)
            freq_plot.setXRange(freq_slider.value()*1e3/1e6 - worker.sample_rate/2e6, freq_slider.value()*1e3/1e6 + worker.sample_rate/2e6)

        def waterfall_plot_callback(spectrogram):
            imageitem.setImage(spectrogram, autoLevels=False)
            sigma = np.std(spectrogram)
            mean = np.mean(spectrogram)
            self.spectrogram_min = mean - 2*sigma # save to window state
            self.spectrogram_max = mean + 2*sigma

        def end_of_run_callback():
            QTimer.singleShot(0, worker.run) # Run worker again immediately

        worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback
        worker.freq_plot_update.connect(freq_plot_callback)
        worker.waterfall_plot_update.connect(waterfall_plot_callback)
        worker.end_of_run.connect(end_of_run_callback)

        self.sdr_thread.started.connect(worker.run) # kicks off the worker when the thread starts
        self.sdr_thread.start()

app = QApplication([])
window = MainWindow()
window.show() # Windows are hidden by default
signal.signal(signal.SIGINT, signal.SIG_DFL) # this lets control-C actually close the app
app.exec() # Start the event loop

if sdr_type == "usrp":
    stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)
    streamer.issue_stream_cmd(stream_cmd)

Exemple bout en bout

Dans ce chapitre, nous regroupons un grand nombre des concepts que nous avons appris précédemment et nous présentons un exemple complet de réception et de décodage d’un signal numérique réel. Nous allons étudier le système de données radio (RDS pour Radio Data System en anglais), qui est un protocole de communication permettant d’intégrer de petites quantités d’informations dans les émissions de radio FM, comme le nom de la station et de la chanson. Nous devrons démoduler la FM, décaler la fréquence, filtrer, décimer, rééchantillonner, synchroniser, décoder et analyser les octets. Un exemple de fichier IQ est fourni à des fins de test ou si vous n’avez pas de SDR sous la main.

Introduction à la radio FM et au RDS

Pour comprendre le RDS, nous devons d’abord examiner les émissions de radio FM et la façon dont leurs signaux sont structurés. Vous connaissez probablement la partie audio des signaux FM, qui sont simplement des signaux audio modulés en fréquence et transmis à des fréquences centrales correspondant au nom de la station, par exemple, « Sud Radio » est centré à exactement 101.8 MHz à Toulouse. En plus de la partie audio, chaque émission FM contient d’autres composants qui sont modulés en fréquence en même temps que l’audio. Au lieu de rechercher la structure du signal sur Google, examinons la densité spectrale de puissance (DSP) d’un exemple de signal FM, après la démodulation FM. Nous ne voyons que la partie positive car la sortie de la démodulation FM est un signal réel, même si l’entrée est complexe (nous verrons bientôt le code pour effectuer cette démodulation).

En regardant le signal dans le domaine de la fréquence, nous remarquons les signaux individuels suivants :

Un signal de forte puissance entre 0 - 17 kHz

Un signal sonore à 19 kHz

Centré à 38 kHz et d’une largeur d’environ 30 kHz, nous voyons un signal symétrique intéressant.

Signal en forme de double lobe centré à 57 kHz.

Signal en forme de lobe unique centré à 67 kHz.

C’est essentiellement tout ce que nous sommes en mesure de déterminer en regardant la DSP, et rappelez-vous que c’est après la démodulation FM. La DSP avant la démodulation FM ressemble à ce qui suit, ce qui ne nous dit pas grand-chose.

Ceci étant dit, il est important de comprendre que lorsque vous modulez un signal en FM, une fréquence plus élevée dans le signal de données entraînera une fréquence plus élevée dans le signal FM résultant. Donc, ce signal centré à 67 kHz qui est présent augmente la largeur de bande totale occupée par le signal FM transmis, car la composante de fréquence maximale est maintenant autour de 75 kHz comme le montre le premier PSD ci-dessus. La règle de la largeur de bande de Carson [https://fr.wikipedia.org/wiki/Règle_de_Carson] appliquée à la FM nous indique que les stations FM occupent environ 250 kHz du spectre, ce qui explique pourquoi nous échantillonnons généralement à 250 kHz (rappelez-vous que lorsque vous utilisez un échantillonnage en quadrature/IQ, votre largeur de bande reçue est égale à votre taux d’échantillonnage).

En guise d’aparté, certains lecteurs ont peut-être l’habitude d’observer la bande FM à l’aide d’une SDR ou d’un analyseur de spectre et de voir le spectrogramme suivant, et de penser que les signaux en forme de blocs adjacents à certaines des stations FM sont des données RDS.

System Message: WARNING/2 (/home/vs/SPHINX/source/rds.rst, line 38)

Cannot scale image! Could not get size from "../_images/fm_band_psd.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fm_band_psd.png'

Il s’avère que ces signaux en forme de blocs sont en fait la Radio HD, une version numérique du même signal radio FM (même contenu audio). Cette version numérique permet d’obtenir un signal audio de meilleure qualité au niveau du récepteur car la FM analogique comprendra toujours un certain bruit après démodulation, puisqu’il s’agit d’un schéma analogique, mais le signal numérique peut être démodulé/décodé avec un bruit nul, en supposant qu’il n’y ait aucune erreur de bit.

Revenons aux cinq signaux que nous avons découverts dans notre DSP; le diagramme suivant indique à quoi sert chaque signal.

System Message: WARNING/2 (/home/vs/SPHINX/source/rds.rst, line 46)

Cannot scale image! Could not get size from "../_images/fm_psd_labeled.svg": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/fm_psd_labeled.svg'

Je passe en revue chacun de ces signaux sans ordre particulier:

Les signaux audio mono et stéréo transportent simplement le signal audio, dans un schéma où leur addition et leur soustraction vous donnent les canaux gauche et droit.

La tonalité pilote de 19 kHz est utilisée pour démoduler le signal audio stéréo. Si vous doublez la tonalité, elle sert de référence de fréquence et de phase, puisque le signal audio stéréo est centré à 38 kHz. Doubler la tonalité peut être fait en élevant simplement les échantillons au carré, en se rappelant la propriété de Fourier de décalage de fréquence que nous avons apprise dans le chapitre Domaine fréquentiel.

DirectBand était un réseau de diffusion de données sans fil en Amérique du Nord, détenu et exploité par Microsoft, également appelé « MSN Direct » sur les marchés grand public. DirectBand transmettait des informations à des appareils tels que des récepteurs GPS portables, des montres-bracelets et des stations météorologiques domestiques. Il permettait même aux utilisateurs de recevoir des messages courts de Windows Live Messenger. L’une des applications les plus réussies de DirectBand était l’affichage en temps réel de données sur le trafic local sur les récepteurs GPS Garmin, qui étaient utilisés par des millions de personnes avant que les smartphones ne deviennent omniprésents. Le service DirectBand a été fermé en janvier 2012, ce qui soulève la question suivante: pourquoi le voit-on dans nos signaux FM enregistrés après 2012? Ma seule hypothèse est que la plupart des émetteurs FM ont été conçus et construits bien avant 2012, et que même sans « alimentation » DirectBand active, ils transmettent toujours quelque chose, peut-être des symboles de pilotage.

Enfin, nous en arrivons au RDS, qui est l’objet du reste de ce chapitre. Comme nous pouvons le voir dans notre premier PSD, le RDS a une largeur de bande d’environ 4 kHz (avant d’être modulé en FM), et se situe entre le signal audio stéréo et le signal DirectBand. Il s’agit d’un protocole de communication numérique à faible débit de données qui permet aux stations FM d’inclure l’identification de la station, des informations sur le programme, l’heure et d’autres informations diverses à côté du signal audio. La norme RDS est publiée sous le nom de norme IEC 62106 et peut être trouvée ici [http://www.interactive-radio-system.com/docs/EN50067_RDS_Standard.pdf].

Le signal RDS

Dans ce chapitre, nous allons utiliser Python pour recevoir RDS, mais pour mieux comprendre comment le recevoir, nous devons d’abord apprendre comment le signal est formé et transmis.

Côté émission

Les informations RDS à transmettre par la station FM (par exemple, le nom de la piste, etc.) sont codées en jeux de 8 octets. Chaque ensemble de 8 octets, qui correspond à 64 bits, est combiné à 40 « bits de contrôle » pour former un seul « groupe ». Ces 104 bits sont transmis ensemble, mais il n’y a pas d’intervalle de temps entre les groupes. Ainsi, du point de vue du récepteur, il reçoit ces bits sans interruption et doit déterminer la limite entre les groupes de 104 bits. Nous verrons plus de détails sur le codage et la structure du message lorsque nous nous plongerons dans la partie réception.

Pour transmettre ces bits sans fil, le RDS utilise la modulation par déplacement de phase (BPSK), qui, comme nous l’avons appris dans le chapitre Modulation numérique, est un schéma de modulation numérique simple utilisé pour associer des 1 et des 0 à la phase d’une porteuse. Comme de nombreux protocoles basés sur la BPSK, le RDS utilise le codage différentiel, ce qui signifie simplement que les 1 et les 0 des données sont codés dans les changements de 1 et de 0, ce qui vous permet de ne plus vous soucier de savoir si vous êtes déphasé de 180 degrés (nous y reviendrons plus tard). Les symboles BPSK sont transmis à 1187,5 symboles par seconde, et comme la BPSK transporte un bit par symbole, cela signifie que le RDS a un débit de données brut d’environ 1,2 kbps (y compris l’overhead). Le RDS ne contient aucun codage de canal (ou correction d’erreur), bien que les paquets de données contiennent un contrôle de redondance cyclique (CRC) pour savoir si une erreur s’est produite. L’utilisateur expérimenté de la BPSK peut se demander pourquoi nous avons vu un signal en forme de double lobe dans la première DSP; la BPSK a généralement un lobe principal. Il s’avère que RDS prend le signal BPSK et le duplique sur la fréquence centrale de 57 kHz, pour plus de robustesse. Lorsque nous nous plongerons dans le code Python utilisé pour recevoir le RDS, l’une de nos étapes consistera à filtrer pour isoler un seul de ces signaux BPSK.

Le signal final « double BPSK » est ensuite décalé en fréquence jusqu’à 57 kHz et ajouté à toutes les autres composantes du signal FM, avant d’être modulé en FM et transmis sur les ondes à la fréquence de la station. Les signaux radio FM sont transmis à une puissance extrêmement élevée par rapport à la plupart des autres communications sans fil, jusqu’à 80 kW! C’est pourquoi de nombreux utilisateurs de la radio logicielle ont un filtre de rejet de la FM (c’est-à-dire un filtre coupe-bande) avec leur antenne, afin que la FM n’ajoute pas d’interférences à ce qu’ils essaient de recevoir.

Il ne s’agissait là que d’un bref aperçu de l’aspect transmission, mais nous entrerons dans les détails lorsque nous aborderons la réception du RDS.

Côté récepteur

Afin de démoduler et de décoder le RDS, nous allons effectuer les étapes suivantes, dont beaucoup sont des étapes de transmission en sens inverse (pas besoin de mémoriser cette liste, nous allons parcourir chaque étape individuellement ci-dessous):

Recevoir un signal radio FM centré sur la fréquence de la station (ou lu dans un enregistrement IQ), généralement à une fréquence d’échantillonnage de 250 kHz

Démodulez la FM en utilisant ce qu’on appelle la « démodulation en quadrature ».

Décalage de fréquence de 57 kHz pour que le signal RDS soit centré à 0 Hz.

Filtre passe-bas, pour filtrer tout ce qui n’est pas RDS.

Décimation par 10 pour pouvoir travailler à un taux d’échantillonnage plus faible, puisque nous avons de toute façon filtré les hautes fréquences.

Rééchantillonnage à 19 kHz, ce qui nous donnera un nombre entier d’échantillons par symbole.

Isolez l’un des deux signaux RDS BPSK avec un filtre passe-bande.

Synchronisation temporelle au niveau du symbole, en utilisant Mueller et Muller dans cet exemple.

Synchronisation fine de la fréquence en utilisant une boucle de Costas

Démodulation du BPSK en 1 et 0.

Décodage différentiel, pour annuler l’encodage différentiel qui a été appliqué.

Décodage des 1 et 0 en groupes d’octets.

Analyse des groupes d’octets dans notre sortie finale.

Bien que cela puisse sembler beaucoup d’étapes, RDS est en fait l’un des protocoles de communication numérique sans fil les plus simples qui soient. Un protocole sans fil moderne comme le WiFi ou la 5G nécessite un manuel entier pour couvrir uniquement les informations de haut niveau de la couche PHY/MAC.

Nous allons maintenant nous plonger dans le code Python utilisé pour recevoir le RDS. Ce code a été testé pour fonctionner en utilisant un enregistrement radio FM que vous pouvez trouver ici [https://github.com/777arc/498x/blob/master/fm_rds_250k_1Msamples.iq?raw=true], bien que vous devriez être en mesure d’introduire votre propre signal tant qu’il est reçu à un SNR assez élevé, il suffit de régler la fréquence centrale de la station et d’échantillonner à un taux de 250 kHz. Dans cette section, nous présenterons de petites portions du code individuellement, avec une discussion, mais le même code est fourni à la fin de ce chapitre en un grand bloc. Chaque section présentera un bloc de code, puis expliquera ce qu’il fait.

Acquisition d’un signal

import numpy as np
from scipy.signal import resample_poly, firwin, bilinear, lfilter
import matplotlib.pyplot as plt

# Lire le signal
x = np.fromfile('/home/marc/Downloads/fm_rds_250k_1Msamples.iq', dtype=np.complex64)
sample_rate = 250e3
center_freq = 99.5e6

Nous avons lu notre enregistrement de test, qui a été échantillonné à 250 kHz et centré sur une station FM reçue à un SNR élevé. Veillez à mettre à jour le chemin du fichier pour refléter votre système et l’endroit où vous avez sauvegardé l’enregistrement. Si vous avez un SDR déjà configuré et fonctionnant depuis Python, n’hésitez pas à recevoir un signal en direct, bien qu’il soit utile d’avoir d’abord testé l’ensemble du code avec un enregistrement de QI connu pour fonctionner [https://github.com/777arc/498x/blob/master/fm_rds_250k_1Msamples.iq?raw=true]. Tout au long de ce code, nous utiliserons x pour stocker le signal à manipuler.

Démodulation FM

# Démodulation en quadrature
x = 0.5 * np.angle(x[0:-1] * np.conj(x[1:])) # see https://wiki.gnuradio.org/index.php/Quadrature_Demod

Comme nous l’avons vu au début de ce chapitre, plusieurs signaux individuels sont combinés en fréquence et modulés en FM pour créer ce qui est réellement transmis dans l’air. La première étape consiste donc à annuler cette modulation FM. Une autre façon de voir les choses est que l’information est stockée dans la variation de fréquence du signal que nous recevons, et nous voulons le démoduler pour que l’information soit maintenant dans l’amplitude et non dans la fréquence. Notez que la sortie de cette démodulation est un signal réel, même si nous avons introduit un signal complexe.

Ce que fait cette simple ligne de Python, c’est d’abord calculer le produit de notre signal avec une version retardée et conjuguée de notre signal. Ensuite, elle trouve la phase de chaque échantillon dans ce résultat, qui est le moment où il passe de complexe à réel. Pour nous prouver que cela nous donne l’information contenue dans les variations de fréquence, considérons un son à la fréquence \(f\) avec une phase arbitraire \(\phi\), que nous pouvons représenter comme \(e^{j2 \pi (f t + \phi)}\). En temps discret, on utilise un entier \(n\) au lieu de \(t\), cela devient \(e^{j2 \pi (f n + \phi)}\). La version conjuguée et retardée est \(e^{-j2 \pi (f (n-1) + \phi)}\). En multipliant les deux, on obtient \(e^{j2 \pi f}\), ce qui est génial car \(\phi\) a disparu, et quand on calcule la phase de cette expression, il ne reste que \(f\).

Un effet secondaire pratique de la modulation FM est que les variations d’amplitude du signal reçu ne modifient pas réellement le volume de l’audio, contrairement à la radio AM.

Déplacement de fréquence

# décalage de freq
N = len(x)
f_o = -57e3 # valeur du décalage
t = np.arange(N)/sample_rate # vecteur de temps
x = x * np.exp(2j*np.pi*f_o*t) # décalage de freq

Ensuite, nous décalons la fréquence de 57 kHz vers le bas, en utilisant l’astuce \(e^{j2 \pi f_ot}\) que nous avons apprise dans le chapitre Synchronisation où f_o est le décalage de fréquence en Hz et t est juste un vecteur temps, le fait qu’il commence à 0 n’est pas important, ce qui compte c’est qu’il utilise la bonne période d’échantillonnage (qui est l’inverse du taux d’échantillonnage). Par ailleurs, comme il s’agit d’un signal réel, il n’est pas important d’utiliser une fréquence de -57 ou +57 kHz car les fréquences négatives correspondent aux positives, donc dans tous les cas, notre RDS sera décalé à 0 Hz.

Filtrer pour isoler le RDS

# filtre passe bas
taps = firwin(numtaps=101, cutoff=7.5e3, fs=sample_rate)
x = np.convolve(x, taps, 'valid')

Maintenant, nous devons filtrer tout ce qui n’est pas RDS. Puisque nous avons un RDS centré à 0 Hz, cela signifie qu’un filtre passe-bas est celui que nous voulons. Nous utilisons firwin() pour concevoir un filtre FIR (c’est-à-dire, trouver les taps), qui a juste besoin de savoir combien de taps nous voulons pour le filtre, et la fréquence de coupure. La fréquence d’échantillonnage doit également être fournie, sinon la fréquence de coupure n’a pas de sens pour firwin. Le résultat est un filtre passe-bas symétrique, donc nous savons que les taps seront des nombres réels, et nous pouvons appliquer le filtre à notre signal en utilisant une convolution. Nous choisissons 'valid' pour nous débarrasser des effets de bord de la convolution, bien que dans ce cas, cela n’ait pas vraiment d’importance parce que nous introduisons un signal si long que quelques échantillons bizarres sur l’un ou l’autre des bords ne vont rien gâcher.

Decimer par 10

# Décimer par 10, maintenant que nous avons filtré et qu'il n'y aura pas de repliement.
x = x[::10]
sample_rate = 25e3

Chaque fois que vous filtrez jusqu’à une petite fraction de votre bande passante (par exemple, nous avons commencé avec 125 kHz de bande passante réelle et n’avons sauvegardé que 7.5 kHz de celle-ci), il est logique de décimer. Rappelez-vous le début du chapitre échantillonnage IQ où nous avons appris le taux de Nyquist et la possibilité de stocker entièrement des informations à bande limitée tant que nous échantillonnions à deux fois la fréquence la plus élevée. Maintenant que nous avons utilisé notre filtre passe-bas, notre fréquence la plus élevée est d’environ 7.5 kHz, donc nous n’avons besoin que d’une fréquence d’échantillonnage de 15 kHz. Par sécurité, nous allons ajouter un peu de marge et utiliser une nouvelle fréquence d’échantillonnage de 25 kHz (ce qui s’avère être une bonne solution mathématique par la suite).

Nous effectuons la décimation en éliminant simplement 9 échantillons sur 10, puisque nous étions précédemment à un taux d’échantillonnage de 250 kHz et que nous voulons qu’il soit maintenant à 25 kHz. Cela peut sembler déroutant au premier abord, car en éliminant 90% des échantillons, on a l’impression de perdre de l’information, mais si vous relisez le chapitre échantillonnage IQ, vous verrez pourquoi nous ne perdons rien en fait, car nous avons filtré correctement (ce qui a agi comme notre filtre anti-repliement) et réduit notre fréquence maximale et donc la largeur de bande du signal.

Du point de vue du code, c’est probablement l’étape la plus simple de toutes, mais assurez-vous de mettre à jour votre variable sample_rate pour refléter le nouveau taux d’échantillonnage.

Rééchantillonnage à 19 kHz

# Rééchantillonnage à 19 kHz
x = resample_poly(x, 19, 25) # up, down
sample_rate = 19e3

Dans le chapitre Mise en Forme nous avons solidifié le concept « d’échantillons par symbole », et appris la commodité d’avoir un nombre entier d’échantillons par symbole (une valeur fractionnaire est valide, mais pas pratique). Comme nous l’avons mentionné précédemment, le RDS utilise une BPSK transmettant 1187.5 symboles par seconde. Si nous continuons à utiliser notre signal tel quel, échantillonné à 25 kHz, nous aurons 21.052631579 échantillons par symbole (faites une pause et réfléchissez au calcul si cela n’a pas de sens). Ce que nous voulons vraiment, c’est une fréquence d’échantillonnage qui soit un multiple entier de 1187.5 Hz, mais nous ne pouvons pas aller trop bas ou nous ne serons pas en mesure de « stocker » toute la largeur de bande de notre signal. Dans la sous-section précédente, nous avons expliqué que nous avions besoin d’une fréquence d’échantillonnage de 15 kHz ou plus, et nous avons choisi 25 kHz juste pour nous donner une certaine marge.

Trouver la meilleure fréquence d’échantillonnage pour rééchantillonner se résume à savoir combien d’échantillons par symbole nous voulons. Hypothétiquement, envisageons de viser 10 échantillons par symbole. Le taux de symbole RDS de 1187.5 multiplié par 10 nous donnerait un taux d’échantillonnage de 11.875 kHz, ce qui n’est malheureusement pas assez élevé pour Nyquist. Que diriez-vous de 13 échantillons par symbole? 1187.5 multiplié par 13 nous donne 15437.5 Hz, ce qui est supérieur à 15 kHz, mais un nombre assez inégal. Que diriez-vous de la puissance de 2 suivante, soit 16 échantillons par symbole? 1187.5 multiplié par 16 est exactement 19 kHz! Le nombre pair est moins une coïncidence qu’un choix de conception du protocole.

Pour rééchantillonner de 25 kHz à 19 kHz, nous utilisons resample_poly() qui suréchantillonne par une valeur entière, filtre, puis sous-échantillonne par une valeur entière. C’est pratique car au lieu d’entrer 25000 et 19000, nous pouvons utiliser 25 et 19. Si nous avions utilisé 13 échantillons par symbole en utilisant une fréquence d’échantillonnage de 15437.5 Hz, nous ne pourrions pas utiliser resample_poly() et le processus de rééchantillonnage serait beaucoup plus compliqué.

Encore une fois, n’oubliez jamais de mettre à jour votre variable sample_rate lorsque vous effectuez une opération qui la modifie.

Filtre passe-bande

# Filtre passe-bande pour isoler un signal RDS BPSK
taps = firwin(numtaps=501, cutoff=[0.05e3, 2e3], fs=sample_rate, pass_zero=False)
x = np.convolve(x, taps, 'valid')

Rappelons que le RDS contient deux signaux BPSK identiques, d’où la forme que nous avons vue dans la PSD au début. Nous devons en choisir un, donc nous allons arbitrairement décider de garder le positif avec un filtre passe-bande. Nous utilisons firwin() à nouveau, mais notez le pass_zero=False qui indique que vous voulez un filtre passe-bande plutôt qu’un filtre passe-bas, et il y a deux fréquences de coupure pour définir la bande. Le signal s’étend approximativement de 0 Hz à 2 kHz mais vous ne pouvez pas spécifier une fréquence de départ de 0 Hz donc nous utilisons 0.05 kHz. Enfin, nous devons augmenter le nombre de taps, pour obtenir une réponse en fréquence plus abrupte. Nous pouvons vérifier que ces chiffres ont fonctionné en examinant notre filtre dans le domaine temporel (en traçant les taps) et dans le domaine fréquentiel (en prenant la FFT des taps). Notez comment dans le domaine fréquentiel, nous atteignons une réponse proche de zéro à environ 0 Hz.

Remarque: à un moment ou à un autre, je mettrai à jour le filtre ci-dessus pour utiliser un filtre adapté (le root-raised cosine, je crois que c’est ce que RDS utilise), pour des raisons conceptuelles, mais j’ai obtenu les mêmes taux d’erreur en utilisant l’approche firwin() que le filtre adapté de GNU Radio, donc ce n’est clairement pas une exigence stricte.

Synchronisation en temps (niveau symbole)

# Synchronisation des symboles, en utilisant ce que nous avons fait dans le chapitre sur la synchronisation.
samples = x # comme dans le chapitre de la synchronisation
samples_interpolated = resample_poly(samples, 32, 1) # Nous utiliserons 32 comme facteur d'interpolation, choisi arbitrairement.
sps = 16
mu = 0.01 # estimation initiale de la phase de l'échantillon
out = np.zeros(len(samples) + 10, dtype=np.complex64)
out_rail = np.zeros(len(samples) + 10, dtype=np.complex64) # stocke les valeurs, à chaque itération nous avons besoin des 2 valeurs précédentes plus la valeur actuelle.
i_in = 0 # index des échantillons d'entrée
i_out = 2 # indice de sortie (les deux premières sorties sont 0)
while i_out < len(samples) and i_in+32 < len(samples):
    out[i_out] = samples_interpolated[i_in*32 + int(mu*32)] # prendre ce que nous pensons être le "meilleur" échantillon
    out_rail[i_out] = int(np.real(out[i_out]) > 0) + 1j*int(np.imag(out[i_out]) > 0)
    x = (out_rail[i_out] - out_rail[i_out-2]) * np.conj(out[i_out-1])
    y = (out[i_out] - out[i_out-2]) * np.conj(out_rail[i_out-1])
    mm_val = np.real(y - x)
    mu += sps + 0.01*mm_val
    i_in += int(np.floor(mu)) # arrondir à l'entier le plus proche puisque nous l'utilisons comme un index
    mu = mu - np.floor(mu) # supprimer la partie entière de mu
    i_out += 1 # incrémenter l'indice de sortie
x = out[2:i_out] # supprimer les deux premiers, et tout ce qui suit i_out (qui n'a jamais été rempli)

Nous sommes enfin prêts pour notre synchronisation temps symbole, ici nous utiliserons exactement le même code de synchronisation de Mueller et Muller du chapitre Synchronisation, rendez-vous y si vous voulez en savoir plus sur son fonctionnement. Nous avons fixé l’échantillon par symbole (sps) à 16 comme discuté précédemment. Une valeur de gain de 0.01 a été trouvée par expérimentation pour fonctionner correctement. La sortie devrait maintenant être un échantillon par symbole, c’est-à-dire que notre sortie est nos « symboles souples », avec un éventuel décalage de fréquence inclus. L’animation suivante de la constellation est utilisée pour vérifier que nous obtenons des symboles BPSK (avec un décalage de fréquence provoquant une rotation) :

System Message: WARNING/2 (/home/vs/SPHINX/source/rds.rst, line 244)

Cannot scale image! Could not get size from "../_images/constellation-animated.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/constellation-animated.gif'

Si vous utilisez votre propre signal FM et que vous n’obtenez pas deux groupes distincts d’échantillons complexes à ce stade, cela signifie que la synchronisation du symbole ci-dessus n’a pas réussi à atteindre la synchronisation, ou qu’il y a un problème avec l’une des étapes précédentes. Vous n’avez pas besoin d’animer la constellation, mais si vous la tracez, veillez à ne pas tracer tous les échantillons, car cela ressemblera à un cercle. Si vous ne tracez que 100 ou 200 échantillons à la fois, vous aurez une meilleure idée de la présence ou non de deux groupes de points, même si elles tournent.

Synchronisation fine de la fréquence

# Synchronisation fine de la fréquence
samples = x # comme dans le chapitre de la synchro
N = len(samples)
phase = 0
freq = 0
# Ces deux paramètres suivants sont ce qu'il faut ajuster, pour rendre la boucle de rétroaction plus rapide ou plus lente (ce qui a un impact sur la stabilité).
alpha = 8.0
beta = 0.002
out = np.zeros(N, dtype=np.complex64)
freq_log = []
for i in range(N):
    out[i] = samples[i] * np.exp(-1j*phase) # ajuster l'échantillon d'entrée par l'inverse du décalage de phase estimé
    error = np.real(out[i]) * np.imag(out[i]) # Voici la formule d'erreur pour une boucle de Costas de 2ème ordre (par exemple pour BPSK)

    # Avancer la boucle (recalculer la phase et le décalage de fréquence)
    freq += (beta * error)
    freq_log.append(freq * sample_rate / (2*np.pi)) # convertir de la vitesse angulaire en Hz pour les logs
    phase += freq + (alpha * error)

    # Facultatif : Ajustez la phase pour qu'elle soit toujours comprise entre 0 et 2pi, rappelez-vous que la phase tourne autour 2pi
    while phase >= 2*np.pi:
        phase -= 2*np.pi
    while phase < 0:
        phase += 2*np.pi
x = out

Nous allons également copier le code Python de synchronisation fine de fréquence du chapitre Synchronisation, qui utilise une boucle de Costas pour supprimer tout décalage de fréquence résiduel, ainsi que pour aligner notre BPSK sur l’axe réel (I), en forçant Q à être aussi proche de zéro que possible. Tout ce qui reste dans Q est probablement dû au bruit du signal, en supposant que la boucle de Costas a été réglée correctement. Juste pour le plaisir, regardons la même animation que ci-dessus, mais après que la synchronisation de fréquence ait été effectuée (plus de rotation !) :

System Message: WARNING/2 (/home/vs/SPHINX/source/rds.rst, line 284)

Cannot scale image! Could not get size from "../_images/constellation-animated-postcostas.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/constellation-animated-postcostas.gif'

De plus, nous pouvons regarder l’erreur de fréquence estimée dans le temps pour voir le fonctionnement de la boucle de Costas, notez comment nous l’avons enregistrée dans le code ci-dessus. Il semble qu’il y avait environ 13 Hz de décalage de fréquence, soit à cause de l’oscillateur local (LO) de l’émetteur qui était éteint, soit à cause de l’OL du récepteur (plus probablement le récepteur). Si vous utilisez votre propre signal FM, vous devrez peut-être modifier alpha et beta jusqu’à ce que la courbe soit similaire, elle devrait atteindre la synchronisation assez rapidement (par exemple, quelques centaines de symboles) et la maintenir avec un minimum d’oscillations. Le modèle que vous voyez ci-dessous après qu’il ait trouvé son état stable est une gigue de fréquence, pas une oscillation.

System Message: WARNING/2 (/home/vs/SPHINX/source/rds.rst, line 290)

Cannot scale image! Could not get size from "../_images/freq_error.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/freq_error.png'

Démoduler le BPSK

# Demodulation BPSK
bits = (np.real(x) > 0).astype(int) # 1s et 0s

La démodulation du BPSK à ce stade est très facile, rappelez-vous que chaque échantillon représente un symbole souple, donc tout ce que nous avons à faire est de vérifier si chaque échantillon est au-dessus ou au-dessous de 0. Le .astype(int) est juste pour que nous puissions travailler avec un tableau d’entiers au lieu d’un tableau de booléens. Vous pouvez vous demander si au-dessus ou au-dessous de zéro représente un 1 ou un 0. Comme vous le verrez à l’étape suivante, cela n’a pas d’importance!

Décodage différentiel

# Décodage différentiel, de sorte qu'il importe peu que notre BPSK ait subi une rotation de 180 degrés sans que nous nous en rendions compte.
bits = (bits[1:] - bits[0:-1]) % 2
bits = bits.astype(np.uint8) # decodage

Le signal BPSK a utilisé un codage différentiel lors de sa création, ce qui signifie que chaque 1 et 0 des données d’origine a été transformé de telle sorte qu’un changement de 1 à 0 ou de 0 à 1 a été mis en correspondance avec un 1, et aucun changement n’a été mis en correspondance avec un 0. L’avantage de l’utilisation du codage différentiel est que vous n’avez pas à vous soucier des rotations de 180 degrés lors de la réception de la BPSK, car le fait que nous considérions qu’un 1 est supérieur ou inférieur à zéro n’a plus d’impact, ce qui compte c’est le changement entre 1 et 0. Ce concept peut être plus facile à comprendre en regardant un exemple de données, ci-dessous les 10 premiers symboles avant et après le décodage différentiel :

[1 1 1 1 0 1 0 0 1 1] # avant le décodage différentiel
[- 0 0 0 1 1 1 0 1 0] # après le décodage différentiel

Décodage RDS

Nous avons enfin nos bits d’information, et nous sommes prêts à décoder leur signification! L’énorme bloc de code fourni ci-dessous est ce que nous allons utiliser pour décoder les 1 et les 0 en groupes d’octets. Cette partie aurait beaucoup plus de sens si nous avions d’abord créé la partie émetteur du RDS, mais pour l’instant, sachez simplement qu’en RDS, les octets sont regroupés en groupes de 12 octets, où les 8 premiers représentent les données et les 4 derniers servent de mot de synchronisation (appelés « mots de décalage »). Les 4 derniers octets ne sont pas nécessaires à l’étape suivante (l’analyseur syntaxique), nous ne les incluons donc pas dans la sortie. Ce bloc de code prend les 1 et 0 créés ci-dessus (sous la forme d’un tableau 1D d’uint8) et produit une liste de listes d’octets (une liste de 8 octets où ces 8 octets sont dans une liste). Ceci est pratique pour l’étape suivante, qui va itérer à travers la liste de 8 octets, un groupe de 8 à la fois.

La plupart du code de décodage ci-dessous tourne autour de la synchronisation (au niveau de l’octet, pas du symbole) et de la vérification des erreurs. Il fonctionne par blocs de 104 bits, chaque bloc est soit reçu correctement soit en erreur (en utilisant le CRC pour vérifier), et tous les 50 blocs il vérifie si plus de 35 d’entre eux ont été reçus avec une erreur, auquel cas il réinitialise tout et tente de se synchroniser à nouveau. Le CRC est effectué en utilisant une vérification sur 10 bits, avec le polynôme \(x^{10}+x^8+x^7+x^5+x^4+x^3+1\); cela se produit lorsque reg est xor avec 0x5B9 qui est l’équivalent binaire de ce polynôme. En Python, les opérateurs binaires pour [and, or, not, xor] sont & | ~ ^ respectivement, exactement comme en C++. Un décalage de bit gauche est x << y (comme la multiplication de x par 2**y), et un décalage de bit droit est x >> y (comme la division de x par 2**y), également comme en C++.

Notez que vous n’avez pas besoin de parcourir tout ce code, ou une partie de celui-ci, surtout si vous vous concentrez sur l’apprentissage de la couche physique (PHY) du DSP et de la SDR, car cela ne représente pas le traitement du signal. Ce code est simplement une implémentation d’un décodeur RDS, et essentiellement rien de ce code ne peut être réutilisé pour d’autres protocoles, car il est tellement spécifique à la façon dont le RDS fonctionne. Si vous êtes déjà un peu épuisé par ce chapitre, sentez-vous libre de sauter cet énorme bloc de code qui a un travail assez simple mais qui le fait d’une manière complexe.

# Constantes
syndrome = [383, 14, 303, 663, 748]
offset_pos = [0, 1, 2, 3, 2]
offset_word = [252, 408, 360, 436, 848]

# regardez Annex B, page 64 du standard
def calc_syndrome(x, mlen):
    reg = 0
    plen = 10
    for ii in range(mlen, 0, -1):
        reg = (reg << 1) | ((x >> (ii-1)) & 0x01)
        if (reg & (1 << plen)):
            reg = reg ^ 0x5B9
    for ii in range(plen, 0, -1):
        reg = reg << 1
        if (reg & (1 << plen)):
            reg = reg ^ 0x5B9
    return reg & ((1 << plen) - 1) # sélectionner les plen bits de reg les plus bas

# Initialiser toutes les variables de travail dont nous aurons besoin pendant la boucle.
synced = False
presync = False

wrong_blocks_counter = 0
blocks_counter = 0
group_good_blocks_counter = 0

reg = np.uint32(0) # était unsigned long en C++ (64 bits) mais numpy ne supporte pas les opérations bit à bit de uint64, je ne pense pas qu'il atteigne cette valeur de toute façon.
lastseen_offset_counter = 0
lastseen_offset = 0

# le processus de synchronisation est décrit dans l'annexe C, page 66 de la norme */
bytes_out = []
for i in range(len(bits)):
    # en C++, reg n'est pas initié, il sera donc aléatoire au début, pour le nôtre, il s'agit de 0.
    # C'était aussi un unsigned long  mais il ne semblait pas s'approcher de la valeur maximale.
    # les bits sont soit 0 soit 1
    reg = np.bitwise_or(np.left_shift(reg, 1), bits[i]) # reg contient les 26 derniers bits de RDS. Ce sont tous deux des opérations par bit.
    if not synced:
        reg_syndrome = calc_syndrome(reg, 26)
        for j in range(5):
            if reg_syndrome == syndrome[j]:
                if not presync:
                    lastseen_offset = j
                    lastseen_offset_counter = i
                    presync = True
                else:
                    if offset_pos[lastseen_offset] >= offset_pos[j]:
                        block_distance = offset_pos[j] + 4 - offset_pos[lastseen_offset]
                    else:
                        block_distance = offset_pos[j] - offset_pos[lastseen_offset]
                    if (block_distance*26) != (i - lastseen_offset_counter):
                        presync = False
                    else:
                        print('Etat de la synchronisation détecté')
                        wrong_blocks_counter = 0
                        blocks_counter = 0
                        block_bit_counter = 0
                        block_number = (j + 1) % 4
                        group_assembly_started = False
                        synced = True
            break # syndrome trouvé, plus de cycles

    else: # SYNCHRONISÉ
        # attendre que 26 bits entrent dans le tampon */
        if block_bit_counter < 25:
            block_bit_counter += 1
        else:
            good_block = False
            dataword = (reg >> 10) & 0xffff
            block_calculated_crc = calc_syndrome(dataword, 16)
            checkword = reg & 0x3ff
            if block_number == 2: # gérer le cas particulier du mot de décalage C ou C'.
                block_received_crc = checkword ^ offset_word[block_number]
                if (block_received_crc == block_calculated_crc):
                    good_block = True
                else:
                    block_received_crc = checkword ^ offset_word[4]
                    if (block_received_crc == block_calculated_crc):
                        good_block = True
                    else:
                        wrong_blocks_counter += 1
                        good_block = False
            else:
                block_received_crc = checkword ^ offset_word[block_number] # xor binaire
                if block_received_crc == block_calculated_crc:
                    good_block = True
                else:
                    wrong_blocks_counter += 1
                    good_block = False

            # Vérification du CRC terminée
            if block_number == 0 and good_block:
                group_assembly_started = True
                group_good_blocks_counter = 1
                group = bytearray(8) # 8 octets remplis de 0
            if group_assembly_started:
                if not good_block:
                    group_assembly_started = False
                else:
                    # octets de données brutes, tels que reçus du RDS. 8 octets d'information, suivis de 4 caractères de décalage RDS : ABCD/ABcD/EEEE (aux Etats-Unis) que nous laissons de côté ici.
                    # Mots d'information RDS
                    # le numéro de bloc est soit 0,1,2,3 donc c'est comme ça qu'on remplit les 8 octets
                    group[block_number*2] = (dataword >> 8) & 255
                    group[block_number*2+1] = dataword & 255
                    group_good_blocks_counter += 1
                    #print('group_good_blocks_counter:', group_good_blocks_counter)
                if group_good_blocks_counter == 5:
                    #print(group)
                    bytes_out.append(group) # liste de listes d'octets de longueur 8
            block_bit_counter = 0
            block_number = (block_number + 1) % 4
            blocks_counter += 1
            if blocks_counter == 50:
                if wrong_blocks_counter > 35: # Autant de blocs erronés doivent signifier que nous avons perdu la synchronisation.
                    print("Perte de synchronisation (obtient ", wrong_blocks_counter, " mauvais blocs sur ", blocks_counter, " en total)")
                    synced = False
                    presync = False
                else:
                    print("Toujours synchronisé (obtient ", wrong_blocks_counter, " mauvais blocs sur ", blocks_counter, " en total)")
                blocks_counter = 0
                wrong_blocks_counter = 0

Vous trouverez ci-dessous un exemple de sortie de cette étape de décodage. Notez que dans cet exemple, la synchronisation est assez rapide, mais qu’elle est perdue plusieurs fois pour une raison quelconque, bien qu’elle soit toujours capable d’analyser toutes les données comme nous le verrons. Si vous utilisez le fichier d’échantillons téléchargeable de 1M échantillons, vous ne verrez que les premières lignes ci-dessous. Le contenu réel de ces octets ressemble à des nombres/caractères aléatoires selon la façon dont vous les affichez, mais dans l’étape suivante, nous allons les analyser en informations lisibles par l’homme!

Etat de la synchronisation détecté
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   1  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   5  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   26  mauvais blocs sur  50 en total)
Perte de synchronisation (obtient 50 mauvais blocs sur  50 en total)
Etat de la synchronisation détecté
Toujours synchronisé (obtient   3  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   0  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   2  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   1  mauvais blocs sur  50 en total)
Toujours synchronisé (obtient   20  mauvais blocs sur  50 en total)
Perte de synchronisation (obtient 47 mauvais blocs sur  50 en total)
Etat de la synchronisation détecté
Toujours synchronisé (obtient   32 mauvais blocs sur  50 en total)

Analyse du RDS

Maintenant que nous avons des octets, par groupes de 8, nous pouvons extraire les données finales, c’est-à-dire la sortie finale qui est compréhensible par l’homme. C’est ce qu’on appelle l’analyse des octets, et tout comme le décodeur de la section précédente, il s’agit simplement d’une mise en œuvre du protocole RDS, et il n’est pas vraiment important de le comprendre. Heureusement, ce n’est pas une tonne de code, si vous n’incluez pas les deux tables définies au début, qui sont simplement les tables de recherche pour le type de canal FM et la zone de couverture.

Pour ceux qui veulent apprendre comment ce code fonctionne, je vais fournir quelques informations supplémentaires. Le protocole utilise le concept de drapeau A/B, ce qui signifie que certains messages sont marqués A et d’autres B, et que l’analyse syntaxique change en fonction de ces derniers (le fait qu’il s’agisse de A ou de B est stocké dans le troisième bit du deuxième octet). Il utilise également différents types de « groupes » qui sont analogues au type de message, et dans ce code, nous n’analysons que le type de message 2, qui est le type de message contenant le texte de la radio, qui est la partie intéressante, c’est le texte qui défile sur l’écran de votre voiture. Nous serons toujours en mesure d’analyser le type de chaîne et la région, car ils sont stockés dans chaque message. Enfin, notez que radiotext est une chaîne qui est initialisée à tous les espaces, se remplit lentement au fur et à mesure que les octets sont analysés, puis se réinitialise à tous les espaces si un ensemble spécifique d’octets est reçu. Si vous êtes curieux de savoir quels autres types de messages existent, la liste est la suivante : [« BASIC », « PIN/SL », « RT », « AID », « CT », « TDC », « IH », « RP », « TMC », « EWS », « EON »]. Le message « RT » est un radiotexte qui est le seul que nous décodons. Le bloc RDS GNU Radio décode aussi « BASIC », mais pour les stations que j’ai utilisées pour les tests, il ne contenait pas beaucoup d’informations intéressantes, et aurait ajouté beaucoup de lignes au code ci-dessous.

# Annexe F de la norme RBDS Tableau F.1 (Amérique du Nord) et Tableau F.2 (Europe)
#              Europe                   Amérique du Nord
pty_table = [["Undefined",             "Undefined"],
             ["News",                  "News"],
             ["Current Affairs",       "Information"],
             ["Information",           "Sports"],
             ["Sport",                 "Talk"],
             ["Education",             "Rock"],
             ["Drama",                 "Classic Rock"],
             ["Culture",               "Adult Hits"],
             ["Science",               "Soft Rock"],
             ["Varied",                "Top 40"],
             ["Pop Music",             "Country"],
             ["Rock Music",            "Oldies"],
             ["Easy Listening",        "Soft"],
             ["Light Classical",       "Nostalgia"],
             ["Serious Classical",     "Jazz"],
             ["Other Music",           "Classical"],
             ["Weather",               "Rhythm & Blues"],
             ["Finance",               "Soft Rhythm & Blues"],
             ["Children’s Programmes", "Language"],
             ["Social Affairs",        "Religious Music"],
             ["Religion",              "Religious Talk"],
             ["Phone-In",              "Personality"],
             ["Travel",                "Public"],
             ["Leisure",               "College"],
             ["Jazz Music",            "Spanish Talk"],
             ["Country Music",         "Spanish Music"],
             ["National Music",        "Hip Hop"],
             ["Oldies Music",          "Unassigned"],
             ["Folk Music",            "Unassigned"],
             ["Documentary",           "Weather"],
             ["Alarm Test",            "Emergency Test"],
             ["Alarm",                 "Emergency"]]
pty_locale = 1 # mis à 0 pour l'Europe qui utilisera la première colonne à la place.

# page 72, Annex D, table D.2 in the standard
coverage_area_codes = ["Local",
                       "International",
                       "National",
                       "Supra-regional",
                       "Regional 1",
                       "Regional 2",
                       "Regional 3",
                       "Regional 4",
                       "Regional 5",
                       "Regional 6",
                       "Regional 7",
                       "Regional 8",
                       "Regional 9",
                       "Regional 10",
                       "Regional 11",
                       "Regional 12"]

radiotext_AB_flag = 0
radiotext = [' ']*65
first_time = True
for group in bytes_out:
    group_0 = group[1] | (group[0] << 8)
    group_1 = group[3] | (group[2] << 8)
    group_2 = group[5] | (group[4] << 8)
    group_3 = group[7] | (group[6] << 8)

    group_type = (group_1 >> 12) & 0xf # voici ce que chacun signifie, par exemple RT est radiotexte qui est le seul que nous décodons ici : ["BASIC", "PIN/SL", "RT", "AID", "CT", "TDC", "IH", "RP", "TMC", "EWS", "___", "___", "___", "___", "EON", "___"]
    AB = (group_1 >> 11 ) & 0x1 # b si 1, a si 0

    #print("group_type:", group_type) # il s'agit essentiellement du type de message, je ne vois que les types 0 et 2 dans mon enregistrement.
    #print("AB:", AB)

    program_identification = group_0     # "PI"

    program_type = (group_1 >> 5) & 0x1f # "PTY"
    pty = pty_table[program_type][pty_locale]

    pi_area_coverage = (program_identification >> 8) & 0xf
    coverage_area = coverage_area_codes[pi_area_coverage]

    pi_program_reference_number = program_identification & 0xff # juste un entier

    if first_time:
        print("PTY:", pty)
        print("program:", pi_program_reference_number)
        print("coverage_area:", coverage_area)
        first_time = False

    if group_type == 2:
        # lorsque le flag A/B est activé, effacez votre radiotexte actuel.
        if radiotext_AB_flag != ((group_1 >> 4) & 0x01):
            radiotext = [' ']*65
        radiotext_AB_flag = (group_1 >> 4) & 0x01
        text_segment_address_code = group_1 & 0x0f
        if AB:
            radiotext[text_segment_address_code * 2    ] = chr((group_3 >> 8) & 0xff)
            radiotext[text_segment_address_code * 2 + 1] = chr(group_3        & 0xff)
        else:
            radiotext[text_segment_address_code *4     ] = chr((group_2 >> 8) & 0xff)
            radiotext[text_segment_address_code * 4 + 1] = chr(group_2        & 0xff)
            radiotext[text_segment_address_code * 4 + 2] = chr((group_3 >> 8) & 0xff)
            radiotext[text_segment_address_code * 4 + 3] = chr(group_3        & 0xff)
        print(''.join(radiotext))
    else:
        pass
        #print("group_type non supporté:", group_type)

L’exemple ci-dessous montre la sortie de l’étape d’analyse syntaxique pour une station FM. Notez comment il doit construire la chaîne de radiotexte sur plusieurs messages, puis il efface périodiquement la chaîne et recommence. Si vous utilisez l’exemple de fichier téléchargé de 1M, vous ne verrez que les premières lignes ci-dessous.

PTY: Top 40
program: 29
coverage_area: Regional 4
            ing.
            ing. Upb
            ing. Upbeat.
            ing. Upbeat. Rea

WAY-
WAY-FM U
WAY-FM Uplif
WAY-FM Uplifting
WAY-FM Uplifting. Up
WAY-FM Uplifting. Upbeat
WAY-FM Uplifting. Upbeat. Re

WayF
WayFM Up
WayFM Uplift
WayFM Uplifting.
WayFM Uplifting. Upb
WayFM Uplifting. Upbeat.
WayFM Uplifting. Upbeat. Rea

Récapitulation et code final

Vous l’avez fait! Ci-dessous se trouve tout le code ci-dessus, concaténé, il devrait fonctionner avec l’enregistrement radio FM test que vous pouvez trouver ici [https://github.com/777arc/498x/blob/master/fm_rds_250k_1Msamples.iq?raw=true], bien que vous devriez être en mesure d’alimenter votre propre signal tant que son SNR reçu est assez élevé, il suffit de régler la fréquence centrale de la station et d’échantillonner à un taux de 250 kHz. Si vous trouvez que vous avez dû faire des ajustements pour le faire fonctionner avec votre propre enregistrement ou SDR en direct, faites-moi savoir ce que vous avez dû faire, vous pouvez le soumettre comme un PR GitHub à la page GitHub du manuel [https://github.com/777arc/PySDR]. Vous pouvez également trouver une version de ce code avec des dizaines de tracés/graphes de débogage inclus, que j’ai utilisé à l’origine pour faire ce chapitre, ici [https://github.com/777arc/PySDR/blob/master/figure-generating-scripts/rds_demo.py].

Final Code

import numpy as np
from scipy.signal import resample_poly, firwin, bilinear, lfilter
import matplotlib.pyplot as plt

# Lire le signal
x = np.fromfile('/home/marc/Downloads/fm_rds_250k_1Msamples.iq', dtype=np.complex64)
sample_rate = 250e3
center_freq = 99.5e6

# Démodulation en quadrature
x = 0.5 * np.angle(x[0:-1] * np.conj(x[1:])) # see https://wiki.gnuradio.org/index.php/Quadrature_Demod

# décalage de freq
N = len(x)
f_o = -57e3 # valeur du décalage
t = np.arange(N)/sample_rate # vecteur de temps
x = x * np.exp(2j*np.pi*f_o*t) # décalage de freq

# filtre passe bas
taps = firwin(numtaps=101, cutoff=7.5e3, fs=sample_rate)
x = np.convolve(x, taps, 'valid')

# Décimer par 10, maintenant que nous avons filtré et qu'il n'y aura pas de repliement.
x = x[::10]
sample_rate = 25e3

# Rééchantillonnage à 19 kHz
x = resample_poly(x, 19, 25) # up, down
sample_rate = 19e3

# Filtre passe-bande pour isoler un signal RDS BPSK
taps = firwin(numtaps=501, cutoff=[0.05e3, 2e3], fs=sample_rate, pass_zero=False)
x = np.convolve(x, taps, 'valid')

 # Synchronisation des symboles, en utilisant ce que nous avons fait dans le chapitre sur la synchronisation.
samples = x # comme dans le chapitre de la synchronisation
samples_interpolated = resample_poly(samples, 32, 1) # Nous utiliserons 32 comme facteur d'interpolation, choisi arbitrairement.
sps = 16
mu = 0.01 # estimation initiale de la phase de l'échantillon
out = np.zeros(len(samples) + 10, dtype=np.complex64)
out_rail = np.zeros(len(samples) + 10, dtype=np.complex64) # stocke les valeurs, à chaque itération nous avons besoin des 2 valeurs précédentes plus la valeur actuelle.
i_in = 0 # index des échantillons d'entrée
i_out = 2 # indice de sortie (les deux premières sorties sont 0)
while i_out < len(samples) and i_in+32 < len(samples):
    out[i_out] = samples_interpolated[i_in*32 + int(mu*32)] # prendre ce que nous pensons être le "meilleur" échantillon
    out_rail[i_out] = int(np.real(out[i_out]) > 0) + 1j*int(np.imag(out[i_out]) > 0)
    x = (out_rail[i_out] - out_rail[i_out-2]) * np.conj(out[i_out-1])
    y = (out[i_out] - out[i_out-2]) * np.conj(out_rail[i_out-1])
    mm_val = np.real(y - x)
    mu += sps + 0.01*mm_val
    i_in += int(np.floor(mu)) # arrondir à l'entier le plus proche puisque nous l'utilisons comme un index
    mu = mu - np.floor(mu) # supprimer la partie entière de mu
    i_out += 1 # incrémenter l'indice de sortie
x = out[2:i_out] # supprimer les deux premiers, et tout ce qui suit i_out (qui n'a jamais été rempli)

# Synchronisation fine de la fréquence
samples = x # comme dans le chapitre de la synchro
N = len(samples)
phase = 0
freq = 0
# Ces deux paramètres suivants sont ce qu'il faut ajuster, pour rendre la boucle de rétroaction plus rapide ou plus lente (ce qui a un impact sur la stabilité).
alpha = 8.0
beta = 0.002
out = np.zeros(N, dtype=np.complex64)
freq_log = []
for i in range(N):
    out[i] = samples[i] * np.exp(-1j*phase) # ajuster l'échantillon d'entrée par l'inverse du décalage de phase estimé
    error = np.real(out[i]) * np.imag(out[i]) # Voici la formule d'erreur pour une boucle de Costas de 2ème ordre (par exemple pour BPSK)

    # Avancer la boucle (recalculer la phase et le décalage de fréquence)
    freq += (beta * error)
    freq_log.append(freq * sample_rate / (2*np.pi)) # convertir de la vitesse angulaire en Hz pour les logs
    phase += freq + (alpha * error)

    # Facultatif : Ajustez la phase pour qu'elle soit toujours comprise entre 0 et 2pi, rappelez-vous que la phase tourne autour 2pi
    while phase >= 2*np.pi:
        phase -= 2*np.pi
    while phase < 0:
        phase += 2*np.pi
x = out

# Demodulation BPSK
bits = (np.real(x) > 0).astype(int) # 1s et 0s

# Décodage différentiel, de sorte qu'il importe peu que notre BPSK ait subi une rotation de 180 degrés sans que nous nous en rendions compte.
bits = (bits[1:] - bits[0:-1]) % 2
bits = bits.astype(np.uint8) # decodage

############
# DECODAGE #
############

# Constants
syndrome = [383, 14, 303, 663, 748]
offset_pos = [0, 1, 2, 3, 2]
offset_word = [252, 408, 360, 436, 848]

# regardez Annex B, page 64 du standard
def calc_syndrome(x, mlen):
    reg = 0
    plen = 10
    for ii in range(mlen, 0, -1):
        reg = (reg << 1) | ((x >> (ii-1)) & 0x01)
        if (reg & (1 << plen)):
            reg = reg ^ 0x5B9
    for ii in range(plen, 0, -1):
        reg = reg << 1
        if (reg & (1 << plen)):
            reg = reg ^ 0x5B9
    return reg & ((1 << plen) - 1) # sélectionner les plen bits de reg les plus bas

# Initialiser toutes les variables de travail dont nous aurons besoin pendant la boucle.
synced = False
presync = False

wrong_blocks_counter = 0
blocks_counter = 0
group_good_blocks_counter = 0

reg = np.uint32(0) # était unsigned long en C++ (64 bits) mais numpy ne supporte pas les opérations bit à bit de uint64, je ne pense pas qu'il atteigne cette valeur de toute façon.
lastseen_offset_counter = 0
lastseen_offset = 0

# le processus de synchronisation est décrit dans l'annexe C, page 66 de la norme */
bytes_out = []
for i in range(len(bits)):
    # en C++, reg n'est pas initié, il sera donc aléatoire au début, pour le nôtre, il s'agit de 0.
    # C'était aussi un unsigned long  mais il ne semblait pas s'approcher de la valeur maximale.
    # les bits sont soit 0 soit 1
    reg = np.bitwise_or(np.left_shift(reg, 1), bits[i]) # reg contient les 26 derniers bits de RDS. Ce sont tous deux des opérations par bit.
    if not synced:
        reg_syndrome = calc_syndrome(reg, 26)
        for j in range(5):
            if reg_syndrome == syndrome[j]:
                if not presync:
                    lastseen_offset = j
                    lastseen_offset_counter = i
                    presync = True
                else:
                    if offset_pos[lastseen_offset] >= offset_pos[j]:
                        block_distance = offset_pos[j] + 4 - offset_pos[lastseen_offset]
                    else:
                        block_distance = offset_pos[j] - offset_pos[lastseen_offset]
                    if (block_distance*26) != (i - lastseen_offset_counter):
                        presync = False
                    else:
                        print('Etat de la synchronisation détecté')
                        wrong_blocks_counter = 0
                        blocks_counter = 0
                        block_bit_counter = 0
                        block_number = (j + 1) % 4
                        group_assembly_started = False
                        synced = True
            break # syndrome trouvé, plus de cycles

    else: # SYNCHRONISÉ
        # attendre que 26 bits entrent dans le tampon */
        if block_bit_counter < 25:
            block_bit_counter += 1
        else:
            good_block = False
            dataword = (reg >> 10) & 0xffff
            block_calculated_crc = calc_syndrome(dataword, 16)
            checkword = reg & 0x3ff
            if block_number == 2: # gérer le cas particulier du mot de décalage C ou C'.
                block_received_crc = checkword ^ offset_word[block_number]
                if (block_received_crc == block_calculated_crc):
                    good_block = True
                else:
                    block_received_crc = checkword ^ offset_word[4]
                    if (block_received_crc == block_calculated_crc):
                        good_block = True
                    else:
                        wrong_blocks_counter += 1
                        good_block = False
            else:
                block_received_crc = checkword ^ offset_word[block_number] # xor binaire
                if block_received_crc == block_calculated_crc:
                    good_block = True
                else:
                    wrong_blocks_counter += 1
                    good_block = False

            # Vérification du CRC terminée
            if block_number == 0 and good_block:
                group_assembly_started = True
                group_good_blocks_counter = 1
                group = bytearray(8) # 8 octets remplis de 0
            if group_assembly_started:
                if not good_block:
                    group_assembly_started = False
                else:
                    # octets de données brutes, tels que reçus du RDS. 8 octets d'information, suivis de 4 caractères de décalage RDS : ABCD/ABcD/EEEE (aux Etats-Unis) que nous laissons de côté ici.
                    # Mots d'information RDS
                    # le numéro de bloc est soit 0,1,2,3 donc c'est comme ça qu'on remplit les 8 octets
                    group[block_number*2] = (dataword >> 8) & 255
                    group[block_number*2+1] = dataword & 255
                    group_good_blocks_counter += 1
                    #print('group_good_blocks_counter:', group_good_blocks_counter)
                if group_good_blocks_counter == 5:
                    #print(group)
                    bytes_out.append(group) # liste de listes d'octets de longueur 8
            block_bit_counter = 0
            block_number = (block_number + 1) % 4
            blocks_counter += 1
            if blocks_counter == 50:
                if wrong_blocks_counter > 35: # Autant de blocs erronés doivent signifier que nous avons perdu la synchronisation.
                    print("Perte de synchronisation (obtient ", wrong_blocks_counter, " mauvais blocs sur ", blocks_counter, " en total)")
                    synced = False
                    presync = False
                else:
                    print("Toujours synchronisé (obtient ", wrong_blocks_counter, " mauvais blocs sur ", blocks_counter, " en total)")
                blocks_counter = 0
                wrong_blocks_counter = 0

############
# Analyse  #
############

# Annexe F de la norme RBDS Tableau F.1 (Amérique du Nord) et Tableau F.2 (Europe)
#              Europe                   Amérique du Nord
pty_table = [["Undefined",             "Undefined"],
             ["News",                  "News"],
             ["Current Affairs",       "Information"],
             ["Information",           "Sports"],
             ["Sport",                 "Talk"],
             ["Education",             "Rock"],
             ["Drama",                 "Classic Rock"],
             ["Culture",               "Adult Hits"],
             ["Science",               "Soft Rock"],
             ["Varied",                "Top 40"],
             ["Pop Music",             "Country"],
             ["Rock Music",            "Oldies"],
             ["Easy Listening",        "Soft"],
             ["Light Classical",       "Nostalgia"],
             ["Serious Classical",     "Jazz"],
             ["Other Music",           "Classical"],
             ["Weather",               "Rhythm & Blues"],
             ["Finance",               "Soft Rhythm & Blues"],
             ["Children’s Programmes", "Language"],
             ["Social Affairs",        "Religious Music"],
             ["Religion",              "Religious Talk"],
             ["Phone-In",              "Personality"],
             ["Travel",                "Public"],
             ["Leisure",               "College"],
             ["Jazz Music",            "Spanish Talk"],
             ["Country Music",         "Spanish Music"],
             ["National Music",        "Hip Hop"],
             ["Oldies Music",          "Unassigned"],
             ["Folk Music",            "Unassigned"],
             ["Documentary",           "Weather"],
             ["Alarm Test",            "Emergency Test"],
             ["Alarm",                 "Emergency"]]
pty_locale = 1 # mis à 0 pour l'Europe qui utilisera la première colonne à la place.

# page 72, Annex D, table D.2 in the standard
coverage_area_codes = ["Local",
                       "International",
                       "National",
                       "Supra-regional",
                       "Regional 1",
                       "Regional 2",
                       "Regional 3",
                       "Regional 4",
                       "Regional 5",
                       "Regional 6",
                       "Regional 7",
                       "Regional 8",
                       "Regional 9",
                       "Regional 10",
                       "Regional 11",
                       "Regional 12"]

radiotext_AB_flag = 0
radiotext = [' ']*65
first_time = True
for group in bytes_out:
    group_0 = group[1] | (group[0] << 8)
    group_1 = group[3] | (group[2] << 8)
    group_2 = group[5] | (group[4] << 8)
    group_3 = group[7] | (group[6] << 8)

    group_type = (group_1 >> 12) & 0xf # voici ce que chacun signifie, par exemple RT est radiotexte qui est le seul que nous décodons ici : ["BASIC", "PIN/SL", "RT", "AID", "CT", "TDC", "IH", "RP", "TMC", "EWS", "___", "___", "___", "___", "EON", "___"]
    AB = (group_1 >> 11 ) & 0x1 # b si 1, a si 0

    #print("group_type:", group_type) # il s'agit essentiellement du type de message, je ne vois que les types 0 et 2 dans mon enregistrement.
    #print("AB:", AB)

    program_identification = group_0     # "PI"

    program_type = (group_1 >> 5) & 0x1f # "PTY"
    pty = pty_table[program_type][pty_locale]

    pi_area_coverage = (program_identification >> 8) & 0xf
    coverage_area = coverage_area_codes[pi_area_coverage]

    pi_program_reference_number = program_identification & 0xff # juste un entier

    if first_time:
        print("PTY:", pty)
        print("program:", pi_program_reference_number)
        print("coverage_area:", coverage_area)
        first_time = False

    if group_type == 2:
        # lorsque le flag A/B est activé, effacez votre radiotexte actuel.
        if radiotext_AB_flag != ((group_1 >> 4) & 0x01):
            radiotext = [' ']*65
        radiotext_AB_flag = (group_1 >> 4) & 0x01
        text_segment_address_code = group_1 & 0x0f
        if AB:
            radiotext[text_segment_address_code * 2    ] = chr((group_3 >> 8) & 0xff)
            radiotext[text_segment_address_code * 2 + 1] = chr(group_3        & 0xff)
        else:
            radiotext[text_segment_address_code *4     ] = chr((group_2 >> 8) & 0xff)
            radiotext[text_segment_address_code * 4 + 1] = chr(group_2        & 0xff)
            radiotext[text_segment_address_code * 4 + 2] = chr((group_3 >> 8) & 0xff)
            radiotext[text_segment_address_code * 4 + 3] = chr(group_3        & 0xff)
        print(''.join(radiotext))
    else:
        pass
        #print("group_type non supporté:", group_type)

Encore une fois, l’exemple d’enregistrement FM connu pour fonctionner avec ce code peut être trouvé ici [https://github.com/777arc/498x/blob/master/fm_rds_250k_1Msamples.iq?raw=true].

Pour ceux qui sont intéressés par la démodulation du signal audio réel, il suffit d’ajouter les lignes suivantes juste après (merci à Joel Cordeiro [http://github.com/joeugenio] pour le code):

# Ajoutez le code suivant juste après la section acquisition d'un signal.

from scipy.io import wavfile

# Demodulation
x = np.diff(np.unwrap(np.angle(x)))

# Filtre de désaccentuation (de-emphasis), H(s) = 1/(RC*s + 1), implémenté comme un IIR via une transformation bilinéaire
bz, az = bilinear(1, [75e-6, 1], fs=sample_rate)
x = lfilter(bz, az, x)

# filtre de décimation pour obtenir un son mono
x = x[::6]
sample_rate = sample_rate/6

# normaliser le volume
x /= x.std()

# Enregistrez dans un fichier wav, vous pouvez l'ouvrir dans Audacity par exemple.
wavfile.write('fm.wav', int(sample_rate), x)

La partie la plus compliquée est le filtre de désaccentuation (ou de-emphasis), que vous pouvez apprendre ici [https://wiki.gnuradio.org/index.php/FM_Preemphasis], bien qu’il s’agisse en fait d’une étape optionnelle si vous pouvre vous suffir d’un audio qui a un mauvais équilibre entre les basses et les aigus. Pour les curieux, voici à quoi ressemble la réponse en fréquence de ce filtre IIR [https://fr.wikipedia.org/wiki/Filtre_à_réponse_impulsionnelle_infinie], il ne filtre pas complètement les fréquences, c’est plus un filtre de « mise en forme ».

Remerciements

La plupart des étapes ci-dessus utilisées pour recevoir le RDS ont été adaptées de l’implémentation GNU Radio du RDS, qui est dans le module GNU Radio appelé gr-rds [https://github.com/bastibl/gr-rds], créé à l’origine par Dimitrios Symeonidis et maintenu par Bastian Bloessl, et je voudrais remercier le travail de ces auteurs. Afin de créer ce chapitre, j’ai commencé par utiliser gr-rds dans GNU Radio, avec un enregistrement FM fonctionnel, et j’ai converti pas-à-pas chacun des blocs (y compris de nombreux blocs intégrés) en Python. Cela a pris pas mal de temps, il y a des nuances dans les blocs intégrés qui sont faciles à rater, et passer d’un traitement de signal de type stream (c’est-à-dire utilisant une fonction de travail qui traite quelques milliers d’échantillons à la fois) à un bloc de Python n’a pas été toujours simple. GNU Radio est un outil extraordinaire pour ce type de prototypage et je n’aurais jamais pu créer tout ce code Python fonctionnel sans lui.

Aller plus loin

https://fr.wikipedia.org/wiki/RDS_(radio)

https://www.sigidwiki.com/wiki/Radio_Data_System_(RDS) (anglais)

https://github.com/bastibl/gr-rds

https://www.gnuradio.org/

échantillonnage IQ

Dans ce chapitre, nous présentons un concept appelé échantillonnage IQ, ou échantillonnage complexe ou échantillonnage en quadrature. Nous abordons également l’échantillonnage de Nyquist, les nombres complexes, les porteuses RF, les fréquences intermédiaires, et la densité spectrale de puissance. L’échantillonnage IQ est la forme d’échantillonnage utilisée par la radio logicielle, ainsi que par de nombreux récepteurs (et émetteurs) numériques. Il s’agit d’une version légèrement plus complexe de l’échantillonnage numérique ordinaire (jeu de mots), nous allons donc y aller doucement et, avec un peu de pratique, le concept va certainement faire tilt!

Les bases de l’échantillonnage

Avant d’aborder l’échantillonnage QI, voyons d’abord ce que signifie l’échantillonnage. Vous avez peut-être déjà rencontré l’échantillonnage sans vous en rendre compte en enregistrant des données audio avec un microphone. Le microphone est un transducteur qui convertit les ondes sonores en un signal électrique (un niveau de tension). Ce signal électrique est transformé par un convertisseur analogique-numérique (CAN), produisant une représentation numérique de l’onde sonore. Pour simplifier, le microphone capte les ondes sonores qui sont converties en électricité, et cette électricité est à son tour convertie en nombres. Le CAN fait le lien entre les domaines analogique et numérique. Les SDR sont étonnamment similaires. Au lieu d’un microphone, ils utilisent une antenne, et utilisent également des CAN. Dans les deux cas, le niveau de tension est échantillonné par un CAN. Pour les SDR, il s’agit d’ondes radio en entrée et de nombres en sortie.

Qu’il s’agisse d’audio ou de radiofréquences, nous devons échantillonner si nous voulons capturer, traiter ou enregistrer un signal numériquement. L’échantillonnage peut sembler simple, mais il est très complexe. Une façon plus technique d’envisager l’échantillonnage d’un signal est de saisir des valeurs à des moments précis et de les sauvegarder numériquement. Disons que nous avons une fonction aléatoire, \(S(t)\), qui peut représenter n’importe quoi, et que c’est une fonction continue que nous voulons échantillonner :

Nous enregistrons la valeur de \(S(t)\) à intervalles réguliers de \(T\) secondes, appelés période d’échantillonnage. La fréquence à laquelle nous échantillonnons, c’est-à-dire le nombre d’échantillons prélevés par seconde, est simplement \(\frac{1}{T}\). Nous l’appelons le taux d’échantillonnage, et c’est l’inverse de la période d’échantillonnage. Par exemple, si nous avons une fréquence d’échantillonnage de 10 Hz, la période d’échantillonnage est de 0.1 seconde; il y aura 0.1 seconde entre chaque échantillon. Dans la pratique, nos fréquences d’échantillonnage seront de l’ordre de centaines de kHz à des dizaines de MHz, voire plus. Lorsque nous échantillonnons des signaux, nous devons être attentifs à la fréquence d’échantillonnage, c’est un paramètre très important.

Pour ceux qui préfèrent les mathématiques, soit \(S_n\) l’échantillon \(n\), généralement un nombre entier commençant à 0. En utilisant cette convention, le processus d’échantillonnage peut être représenté mathématiquement comme \(S_n = S(nT)\) pour des valeurs entières de \(n\). C’est-à-dire que nous évaluons le signal analogique \(S(t)\) à ces intervalles de \(nT\).

Echantillonnage de Nyquist

Pour un signal donné, la grande question est souvent de savoir à quelle vitesse nous devons échantillonner. Examinons un signal qui est juste une onde sinusoïdale, de fréquence f, représentée en vert ci-dessous. Disons que nous échantillonnons à une fréquence Fs (échantillons représentés en bleu). Si nous échantillonnons ce signal à une fréquence égale à f (c’est-à-dire Fs = f), nous obtiendrons quelque chose qui ressemble à ceci:

La ligne pointillée rouge dans l’image ci-dessus reconstruit une fonction différente (incorrecte) qui aurait pu conduire à l’enregistrement des mêmes échantillons. Elle indique que notre taux d’échantillonnage était trop faible car les mêmes échantillons auraient pu provenir de deux fonctions différentes, entraînant une ambiguïté. Si nous voulons reconstruire avec précision le signal original, nous ne pouvons pas tolérer cette ambiguïté.

Essayons d’échantillonner un peu plus rapidement, à Fs = 1.2f:

Là encore, il existe un signal différent qui pourrait correspondre à ces échantillons. Cette ambiguïté signifie que si quelqu’un nous donnait cette liste d’échantillons, nous ne pourrions pas distinguer quel signal est le signal original sur la base de notre échantillonnage.

Que diriez-vous d’un échantillonnage à Fs = 1.5f:

Toujours pas assez rapide! Selon une partie de la théorie du DSP dans laquelle nous ne nous plongerons pas, vous devez échantillonner à deux fois la fréquence du signal afin de lever l’ambiguïté que nous rencontrons:

Il n’y a pas de signal erroné cette fois-ci, car nous avons échantillonné suffisamment rapidement pour qu’il n’existe aucun signal correspondant à ces échantillons autre que celui que vous voyez (à moins que vous n’alliez plus haut en fréquence, mais nous en discuterons plus tard).

Dans l’exemple ci-dessus, notre signal était une simple onde sinusoïdale. La plupart des signaux réels comportent de nombreuses composantes de fréquence. Pour échantillonner avec précision un signal donné, la fréquence d’échantillonnage doit être « au moins deux fois la fréquence de la composante de fréquence maximale ». Voici une visualisation à l’aide d’un exemple de tracé dans le domaine fréquentiel. Notez qu’il y aura toujours un plancher de bruit et que la fréquence la plus élevée est généralement une approximation:

Nous devons identifier la composante de fréquence la plus élevée, puis la doubler, et nous assurer que nous échantillonnons à cette fréquence ou plus rapidement. La fréquence minimale à laquelle nous pouvons échantillonner est connue sous le nom de taux de Nyquist. En d’autres termes, le taux de Nyquist est le taux minimum auquel un signal (à bande passante finie) doit être échantillonné pour conserver toutes ses informations. Il s’agit d’un élément théorique extrêmement important dans le domaine du DSP et de la SDR, qui sert de pont entre les signaux continus et discrets.

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 63)

Cannot scale image! Could not get size from "../_images/nyquist_rate.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/nyquist_rate.png'

Si l’échantillonnage n’est pas assez rapide, nous obtenons ce que l’on appelle le repliement ou l’aliasing, dont nous parlerons plus tard, et que nous essayons d’éviter à tout prix. Ce que font nos SDR (et la plupart des récepteurs en général), c’est filtrer tout ce qui est au-dessus de Fs/2 juste avant l’échantillonnage. Si nous essayons de recevoir un signal avec une fréquence d’échantillonnage trop faible, ce filtre coupera une partie du signal. Nos récepteurs SDR se donnent beaucoup de mal pour nous fournir des échantillons exempts de repliement ainsi que d’autres imperfections.

Échantillonnage en quadrature

Le terme « quadrature » a de nombreuses significations, mais dans le contexte du DSP et de la SDR, il désigne deux ondes déphasées de 90 degrés. Pourquoi un déphasage de 90 degrés? Observez que deux ondes qui sont déphasées de 180 degrés sont essentiellement la même onde avec une multipliée par -1. En étant déphasées de 90 degrés, elles deviennent orthogonales, et il y a beaucoup de choses intéressantes à faire avec les fonctions orthogonales. Par souci de simplicité, nous utilisons le sinus et le cosinus comme nos deux ondes sinusoïdales déphasées de 90 degrés.

Ensuite, attribuons des variables pour représenter l” amplitude du sinus et du cosinus. Nous utiliserons \(I\) pour le cos() et \(Q\) pour le sin():

\[ \begin{align}\begin{aligned}I \cos(2\pi ft)\\Q \sin(2\pi ft)\end{aligned}\end{align} \]

Nous pouvons le constater visuellement en traçant I et Q égaux à 1 :

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 85)

Cannot scale image! Could not get size from "../_images/IQ_wave.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/IQ_wave.png'

Nous appelons le fonction cos() la composante « en phase », noté par la lettre I (pour in-phase en anglais), et le sin() la composante « en quadrature », d’où Q. Toutefois, si vous mélangez accidentellement les deux et attribuez Q au cos() et I au sin(), cela ne fera pas de différence dans la plupart des situations.

L’échantillonnage IQ est plus facile à comprendre en se plaçant du point de vue de l’émetteur, c’est-à-dire en considérant la tâche consistant à transmettre un signal RF dans l’air. Ce que nous faisons, en tant qu’émetteur, c’est additionner la composante sin() et la composante cos(). Disons que x(t) est notre signal à transmettre:

\[x(t) = I \cos(2\pi ft) + Q \sin(2\pi ft)\]

Que se passe-t-il lorsque l’on additionne un sinus et un cosinus? Ou plutôt, que se passe-t-il lorsque nous additionnons deux sinusoïdes déphasées de 90 degrés? Dans l’animation ci-dessous, il y a un curseur pour ajuster I et un autre pour ajuster Q. Ce qui est tracé est le cosinus, le sinus, et ensuite la somme des deux.

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 98)

Cannot scale image! Could not get size from "../_images/IQ3.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/IQ3.gif'

(Le code utilisé pour cette application Python pyqtgraph se trouve à l’adresse suivante ici [https://raw.githubusercontent.com/777arc/PySDR/master/figure-generating-scripts/sin_plus_cos.py])

Ce qu’il faut retenir, c’est que lorsque nous additionnons le cos() et le sin(), nous obtenons une autre onde sinusoïdale pure avec une phase et une amplitude différentes. De plus, la phase se décale lorsque nous retirons ou ajoutons lentement l’une des deux parties. L’amplitude change également. Tout ceci est le résultat de l’identité trigonométrique : \(a \cos(x) + b \sin(x) = A \cos(x-\phi)\), sur laquelle nous reviendrons plus tard. « L’utilité » de ce résultat est que nous pouvons contrôler la phase et l’amplitude d’une onde sinusoïdale résultante en ajustant les amplitudes I et Q (nous n’avons pas besoin d’ajuster la phase du cosinus ou du sinus). Par exemple, nous pouvons ajuster I et Q de manière à ce que l’amplitude reste constante et que la phase soit celle que nous voulons. En tant qu’émetteur, cette approche est extrêmement utile car nous savons que nous devons transmettre un signal sinusoïdal pour qu’il se propage dans l’air comme une onde électromagnétique. Et il est beaucoup plus facile de régler deux amplitudes et d’effectuer une opération d’addition que de régler une amplitude et une phase. Le résultat est que notre émetteur ressemblera à quelque chose comme ceci:

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 106)

Cannot scale image! Could not get size from "../_images/IQ_diagram.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/IQ_diagram.png'

Il nous suffit de générer une onde sinusoïdale et de la décaler de 90 degrés pour obtenir la partie Q.

Les nombres complexes

En fin de compte, la convention IQ est un moyen alternatif de représenter la magnitude et la phase, ce qui nous amène aux nombres complexes et à la possibilité de les représenter sur un plan complexe. Vous avez peut-être déjà vu des nombres complexes dans d’autres cours. Prenons par exemple le nombre complexe 0.7-0.4j:

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 118)

Cannot scale image! Could not get size from "../_images/complex_plane_1.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/complex_plane_1.png'

Un nombre complexe n’est en fait que deux nombres réunis, une partie réelle et une partie imaginaire. Un nombre complexe a également une magnitude et une phase, ce qui est plus logique si vous le considérez comme un vecteur plutôt que comme un point. La magnitude est la longueur de la ligne entre l’origine et le point (c’est-à-dire la longueur du vecteur), tandis que la phase est l’angle entre ce vecteur et la ligne à 0 degré, que nous définissons comme l’axe réel positif:

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 124)

Cannot scale image! Could not get size from "../_images/complex_plane_2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/complex_plane_2.png'

Cette représentation d’une sinusoïde est connue sous le nom de « diagramme de phase ». Il s’agit simplement de tracer des nombres complexes et de les traiter comme des vecteurs. Maintenant, quelles sont la magnitude et la phase de notre exemple de nombre complexe 0.7-0.4j? Pour un nombre complexe donné où \(a\) est la partie réelle et \(b\) la partie imaginaire :

\[ \begin{align}\begin{aligned}\mathrm{magnitude} = \sqrt{a^2 + b^2} = 0.806\\\mathrm{phase} = \tan^{-1} \left( \frac{b}{a} \right) = -29.7^{\circ} = -0.519 \quad \mathrm{radians}\end{aligned}\end{align} \]

En Python, vous pouvez utiliser np.abs(x) et np.angle(x) pour la magnitude et la phase. L’entrée peut être un nombre complexe ou un tableau de nombres complexes, et la sortie sera un ou plusieurs nombres réels (du type float).

Vous avez peut-être déjà compris comment ce diagramme vectoriel est lié à la convention IQ: I est réel et Q est imaginaire. À partir de maintenant, lorsque nous dessinerons le plan complexe, nous l’étiquetterons avec I et Q au lieu de réel et imaginaire. Mais il s’agira toujours des même nombres complexes!

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 139)

Cannot scale image! Could not get size from "../_images/complex_plane_3.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/complex_plane_3.png'

Disons maintenant que nous voulons transmettre notre point d’exemple 0.7-0.4j. Nous allons transmettre:

\[ \begin{align}\begin{aligned}x(t) = I \cos(2\pi ft) + Q \sin(2\pi ft)\\\quad \quad \quad = 0.7 \cos(2\pi ft) - 0.4 \sin(2\pi ft)\end{aligned}\end{align} \]

Nous pouvons utiliser l’identité trigonométrique \(a \cos(x) + b \sin(x) = A \cos(x-\phi)\) où \(A\) est notre magnitude trouvée avec \(\sqrt{I^2 + Q^2}\) et \(\phi\) est notre phase, égale à \(\tan^{-1} \left( Q/I \right)\). L’équation ci-dessus devient alors:

\[x(t) = 0.806 \cos(2\pi ft + 0.519)\]

Même si nous avons commencé avec un nombre complexe, ce que nous transmettons est réel, ce qui est une bonne chose car vous ne pouvez pas réellement transmettre quelque chose d’imaginaire avec des ondes électromagnétiques. Nous utilisons simplement des nombres imaginaires/complexes pour représenter ce que nous transmettons. Nous parlerons bientôt de \(f\).

Les nombres complexes dans les FFT

Les nombres complexes ci-dessus ont été supposés être des échantillons du domaine temporel, mais vous rencontrerez également des nombres complexes lorsque vous effectuerez une FFT. Lorsque nous avons abordé les séries de Fourier et les FFT au chapitre précédent, nous n’avions pas encore plongé dans les nombres complexes. Lorsque vous effectuez la FFT d’une série d’échantillons, vous obtenez la représentation dans le domaine fréquentiel. Nous avons parlé de la façon dont la FFT détermine quelles fréquences existent dans cet ensemble d’échantillons (l’amplitude de la FFT indique la « puissance » de chaque fréquence). Mais la FFT détermine également le retard (décalage temporel) nécessaire à appliquer à chacune de ces fréquences, afin que l’ensemble des sinusoïdes puissent être additionnées pour reconstruire le signal dans le domaine temporel. Ce retard est simplement la phase de la FFT. La sortie d’une FFT est un tableau de nombres complexes, et chaque nombre complexe vous donne la magnitude et la phase, et l’indice de ce nombre vous donne la fréquence. Si vous générez des sinusoïdes à ces fréquences/amplitudes/phases et que vous les additionnez, vous obtiendrez votre signal original dans le domaine temporel (ou quelque chose de très proche, et c’est là que le théorème d’échantillonnage de Nyquist entre en jeu).

Côté récepteur

Prenons maintenant la perspective d’un récepteur radio qui essaie de recevoir un signal (par exemple, un signal radio FM). En utilisant l’échantillonnage IQ, le diagramme se présente maintenant comme suit:

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 169)

Cannot scale image! Could not get size from "../_images/IQ_diagram_rx.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/IQ_diagram_rx.png'

Ce qui entre est un signal réel reçu par notre antenne, et ceux-ci sont transformés en valeurs IQ. Ce que nous faisons, c’est échantillonner les branches I et Q individuellement, en utilisant deux CAN, puis nous combinons les paires et les stockons sous forme de nombres complexes. En d’autres termes, à chaque pas de temps, on échantillonne une valeur I et une valeur Q et on les combine sous la forme \(I + jQ\) (c’est-à-dire un nombre complexe par échantillon IQ). Il y aura toujours une « fréquence d’échantillonnage », c’est-à-dire la vitesse à laquelle l’échantillonnage est effectué. Quelqu’un pourrait dire : « J’ai une radio logicielle qui fonctionne à une fréquence d’échantillonnage de 2 MHz ». Ce qu’il veut dire, c’est que la radio logicielle génère deux millions d’échantillons IQ par seconde.

Si quelqu’un vous donne un fichier d’échantillons QI, cela ressemblera à un tableau/vecteur 1D de nombres complexes. Ce point, complexe ou non, est le but de départ de tout ce chapitre, et nous l’avons finalement atteint.

Tout au long de ce manuel, vous deviendrez très familier avec le fonctionnement des échantillons IQ, comment les recevoir et les transmettre avec un SDR, comment les traiter dans Python et comment les enregistrer dans un fichier pour une analyse ultérieure.

Une dernière remarque importante: la figure ci-dessus montre ce qui se passe à l’intérieur de la SDR. Nous n’avons pas besoin de générer une onde sinusoïdale, de la décaler de 90, de la multiplier ou de l’additionner - la SDR le fait pour nous. Nous indiquons à la SDR la fréquence à laquelle nous voulons échantillonner, ou la fréquence à laquelle nous voulons transmettre nos échantillons. Du côté du récepteur, la SDR nous fournira les échantillons IQ. Du côté de l’émetteur, nous devons fournir à la SDR les échantillons IQ. En termes de type de données, il s’agira soit d’entiers complexes, soit de flottants.

Porteuse et Descente en Fréquence

Jusqu’à présent, nous n’avons pas parlé de la fréquence, mais nous avons vu qu’il y avait un \(f\) dans les équations impliquant le cos() et le sin(). Cette fréquence est la fréquence de l’onde sinusoïdale que nous envoyons réellement dans l’air (la fréquence de l’onde électromagnétique). Nous l’appelons la « porteuse » car elle transporte nos informations sur une certaine fréquence. Lorsque nous nous accordons sur une fréquence avec notre SDR et recevons des échantillons, nos informations sont stockées dans I et Q; cette porteuse n’apparaît pas dans I et Q, en supposant que nous nous sommes réglés sur la porteuse.

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 188)

Cannot scale image! Could not get size from "../_images/carrier.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/carrier.png'

Pour référence, les signaux radio tels que la radio FM, le WiFi, le Bluetooth, le LTE, le GPS, etc., utilisent généralement une fréquence (c’est-à-dire une porteuse) comprise entre 100 MHz et 6 GHz. Ces fréquences se déplacent vraiment bien dans l’air, mais elles ne nécessitent pas de super longues antennes ou une grande puissance pour transmettre ou recevoir. Votre micro-ondes cuit les aliments avec des ondes électromagnétiques à 2.4 GHz. S’il y a une fuite dans la porte, votre micro-ondes brouillera les signaux WiFi et pourra peut-être aussi vous brûler la peau. La lumière est une autre forme d’ondes électromagnétiques. La lumière visible a une fréquence d’environ 500 THz. Cette fréquence est si élevée que nous n’utilisons pas d’antennes traditionnelles pour transmettre la lumière. Nous utilisons des méthodes comme les LED, qui sont des dispositifs semi-conducteurs. Elles créent de la lumière lorsque les électrons sautent entre les orbites atomiques du matériau semi-conducteur, et la couleur dépend de la distance à laquelle ils sautent. Techniquement, la radiofréquence (RF) est définie comme la plage comprise entre 20 kHz et 300 GHz environ. Il s’agit des fréquences auxquelles l’énergie d’un courant électrique oscillant peut rayonner à partir d’un conducteur (une antenne) et voyager dans l’espace. Les fréquences comprises entre 100 MHz et 6 GHz sont les plus utiles, du moins pour la plupart des applications modernes. Les fréquences supérieures à 6 GHz ont été utilisées pour les radars et les communications par satellite pendant des décennies, et sont maintenant utilisées dans la 5G « mmWave » (24 - 29 GHz) pour compléter les bandes inférieures et augmenter les débits.

Lorsque nous changeons rapidement nos valeurs IQ et que nous transmettons notre porteuse, cela s’appelle « moduler » la porteuse (avec des données ou ce que nous voulons). Lorsque nous changeons I et Q, nous modifions la phase et l’amplitude de la porteuse. Une autre option consiste à modifier la fréquence de la porteuse, c’est-à-dire à la décaler légèrement vers le haut ou vers le bas, ce que fait la radio FM.

Pour prendre un exemple simple, disons que nous transmettons l’échantillon IQ 1+0j, puis que nous passons à la transmission de 0+1j. Nous passons de \(\cos(2\pi ft)\) à \(\sin(2\pi ft)\), ce qui signifie que notre porteuse se déphase de 90 degrés lorsque nous passons d’un échantillon à un autre.

Revenons maintenant à l’échantillonnage pour une seconde. Au lieu de recevoir des échantillons en multipliant ce qui provient de l’antenne par un cos() et un sin() puis en enregistrant I et Q, que se passerait-il si nous envoyions le signal de l’antenne dans un seul CAN, comme dans l’architecture d’échantillonnage direct dont nous venons de parler? Supposons que la fréquence porteuse soit de 2.4 GHz, comme le WiFi ou le Bluetooth. Cela signifie que nous devrions échantillonner à 4.8 GHz, comme nous l’avons appris. C’est extrêmement rapide! Un CAN qui échantillonne aussi rapidement coûte des milliers de dollars. Au lieu de cela, nous « descendons en fréquence » (downconversion en anglais) le signal pour que le signal que nous voulons échantillonner soit centré sur le courant continu ou 0 Hz. Cette descente en fréquence a lieu avant l’échantillonnage. Nous passons de:

\[I \underbrace{\cos(2\pi ft)}_{porteuse} \ + \ \ Q \underbrace{\sin(2\pi ft)}_{porteuse}\]

à juste I et Q.

Visualisons la conversion de fréquence dans le domaine des fréquences:

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 208)

Cannot scale image! Could not get size from "../_images/downconversion.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/downconversion.png'

Lorsque nous sommes centrés autour de 0 Hz, la fréquence maximale n’est plus de 2,4 GHz mais est basée sur les caractéristiques du signal car nous avons supprimé la porteuse. La plupart des signaux ont une largeur de bande d’environ 100 kHz à 40 MHz, de sorte que, grâce à la conversion de fréquence, nous pouvons échantillonner à un taux beaucoup plus faible. Les USRP B2X0 et PlutoSDR contiennent un circuit intégré RF (RFIC) qui peut échantillonner jusqu’à 56 MHz, ce qui est suffisamment élevé pour la plupart des signaux que nous rencontrerons dans la vie de tous les jours.

Pour rappel, le processus de conversion de fréquence est effectué par notre SDR; en tant qu’utilisateur de la SDR, nous n’avons rien à faire autre que de lui indiquer sur quelle fréquence s’accorder. La conversion de fréquence est effectuée par un composant appelé mélangeur, généralement représenté dans les diagrammes par un symbole de multiplication à l’intérieur d’un cercle. Le mélangeur reçoit un signal, émet le signal converti en fréquence et possède un troisième port d’entrée pour un oscillateur. La fréquence de l’oscillateur détermine le décalage de fréquence appliqué au signal, et le mélangeur est essentiellement une fonction de multiplication (rappelons que la multiplication par une sinusoïde entraîne justement un décalage de fréquence).

Enfin, vous êtes peut-être curieux de savoir à quelle vitesse les signaux se déplacent dans l’air. Rappelez-vous que les ondes radio ne sont que des ondes électromagnétiques à basse fréquence (entre 3 kHz et 80 GHz environ). La lumière visible est également constituée d’ondes électromagnétiques, à des fréquences beaucoup plus élevées (400 THz à 700 THz). Toutes les ondes électromagnétiques se déplacent à la vitesse de la lumière, qui est d’environ 3e8 m/s, du moins lorsqu’elles se déplacent dans le vide. Comme elles se déplacent toujours à la même vitesse, la distance parcourue par l’onde en une oscillation complète (une période complète de l’onde sinusoïdale) dépend de sa fréquence. Nous appelons cette distance la longueur d’onde, désignée par \(\lambda\). Vous avez probablement déjà vu cette relation :

\[f = \frac{c}{\lambda}\]

où \(c\) est la vitesse de la lumière, généralement fixée à 3e8 lorsque \(f\) est en Hz et \(\lambda\) en mètre. Dans le domaine des communications sans fil, cette relation devient importante lorsqu’il s’agit d’antennes, car pour recevoir un signal à une certaine fréquence porteuse, \(f\), vous avez besoin d’une antenne qui correspond à sa longueur d’onde, \(\lambda\), généralement l’antenne a une longueur de \(\lambda/2\) ou \(\lambda/4\). Cependant, quelle que soit la fréquence/longueur d’onde, l’information transportée par ce signal se déplace toujours à la vitesse de la lumière, de l’émetteur au récepteur. Pour calculer ce délai dans l’air, une règle empirique est que la lumière parcourt environ 30 cm en une nanoseconde. Autre règle empirique : un signal se rendant à un satellite en orbite géostationnaire et en revenant prendra environ 0.25 seconde pour l’ensemble du trajet.

Architectures des récepteurs

La figure de la section « Côté récepteur » montre comment le signal d’entrée est converti et divisé en I et Q. Cet arrangement est appelé « conversion directe », car les fréquences RF sont directement converties en bande de base. Une autre option consiste à ne pas effectuer de conversion de fréquence du tout et à échantillonner très rapidement pour tout capturer de 0 Hz à la moitié de la fréquence d’échantillonnage. Cette stratégie est appelée « échantillonnage direct », et elle nécessite une puce CAN extrêmement coûteuse. Une troisième architecture, populaire car c’est ainsi que fonctionnaient les anciennes radios, est connue sous le nom de « superhétérodyne ». Elle implique une conversion vers le bas, mais pas jusqu’à 0 Hz. Elle place le signal d’intérêt à une fréquence intermédiaire, appelée « FI ». Un amplificateur à faible bruit (LNA pour Low Noise Amplifier en anglais) est simplement un amplificateur conçu pour des signaux de très faible puissance à l’entrée. Voici les schémas fonctionnels de ces trois architectures, notez que des variations et des hybrides de ces architectures existent également :

Signaux en Bande de Base et Passe-Bande

On dit d’un signal centré autour de 0 Hz qu’il est en « bande de base ». À l’inverse, on parle de « bande passante » lorsqu’un signal existe à une fréquence RF qui n’est pas proche de 0 Hz, mais qui a été décalée vers le haut dans le but d’une transmission sans fil. Il n’y a pas de notion de « transmission en bande de base », car on ne peut pas transmettre quelque chose d’imaginaire. Un signal en bande de base peut être parfaitement centré à 0 Hz, comme la partie droite de la figure de la section précédente. Il peut être proche de 0 Hz, comme les deux signaux illustrés ci-dessous. Ces deux signaux sont toujours considérés comme étant en bande de base. Vous trouverez également un exemple de signal passe-bande, centré sur une fréquence très élevée notée \(f_c\).

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 239)

Cannot scale image! Could not get size from "../_images/baseband_bandpass.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/baseband_bandpass.png'

Vous pouvez également entendre le terme de fréquence intermédiaire (abrégé en FI); pour l’instant, considérez la FI comme une étape de conversion intermédiaire dans une radio entre la bande de base et la bande passante RF.

Nous avons tendance à créer, enregistrer ou analyser des signaux en bande de base parce que nous pouvons travailler à une fréquence d’échantillonnage inférieure (pour les raisons évoquées dans la sous-section précédente). Il est important de noter que les signaux en bande de base sont souvent des signaux complexes, tandis que les signaux en bande passante (par exemple, les signaux que nous transmettons réellement par RF) sont réels. Comme le signal transmis par une antenne doit être réel, vous ne pouvez pas transmettre directement un signal complexe/imaginaire. Vous saurez qu’un signal est définitivement un signal complexe si les parties de fréquence négative et de fréquence positive du signal ne sont pas exactement les mêmes. Après tout, les nombres complexes sont la façon dont nous représentons les fréquences négatives. En réalité, il n’y a pas de fréquences négatives; il s’agit simplement de la partie du signal située en dessous de la fréquence porteuse.

Dans la section précédente où nous avons joué avec le point complexe 0.7-0.4j, il s’agissait essentiellement d’un échantillon dans un signal en bande de base. La plupart du temps, lorsque vous voyez des échantillons complexes (échantillons IQ), vous êtes en bande de base. Les signaux sont rarement représentés ou stockés numériquement en RF, en raison de la quantité de données que cela prendrait, et du fait que nous ne sommes généralement intéressés que par une petite partie du spectre RF.

Le Pic DC et le Décalage DC

Lorsque vous commencez à travailler avec les SDR, vous trouvez souvent un pic important au centre de la FFT. On l’appelle « offset DC » ou « pic DC » ou parfois « fuite LO », où LO signifie Local oscillator pour oscillateur local en français.

Voici un exemple d’un pic de courant continu:

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 257)

Cannot scale image! Could not get size from "../_images/dc_spike.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/dc_spike.png'

Parceque la SDR s’accorde sur une fréquence centrale, la composante à 0hz de la FFT correspond à la fréquence centrale. Ceci étant dit, un pic de courant continu ne signifie pas nécessairement qu’il y a de l’énergie à la fréquence centrale. S’il n’y a qu’un pic de courant continu et que le reste de la FFT ressemble à du bruit, il est fort probable qu’il n’y a pas de signal présent à l’endroit où elle vous le montre.

Un décalage DC est un artefact commun dans les récepteurs à conversion directe, qui est l’architecture utilisée pour les SDRs comme le PlutoSDR, RTL-SDR, LimeSDR, et de nombreux USRPs Ettus. Dans les récepteurs à conversion directe, un oscillateur local convertit le signal de sa fréquence réelle en bande de base. Par conséquent, les fuites de cet oscillateur apparaissent au centre de la bande passante observée. La fuite du LO est une énergie supplémentaire créée à cause de la combinaison des fréquences. L’élimination de ce bruit supplémentaire est difficile car il est proche du signal de sortie souhaité. De nombreux circuits intégrés RF (RFIC) intègrent une fonction automatique d’élimination du décalage continu, mais elle nécessite généralement la présence d’un signal pour fonctionner. C’est pourquoi le pic de courant continu est très apparent lorsqu’aucun signal n’est présent.

Un moyen rapide de gérer le décalage en courant continu consiste à suréchantillonner le signal et à le désaccorder (ou detune en anglais). Par exemple, disons que nous voulons visualiser 5 MHz de spectre à 100 MHz. Ce que nous pouvons faire, c’est échantillonner à 20 MHz à une fréquence centrale de 95 MHz.

System Message: WARNING/2 (/home/vs/SPHINX/source/sampling.rst, line 267)

Cannot scale image! Could not get size from "../_images/offtuning.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/offtuning.png'

La boîte bleue ci-dessus montre ce qui est effectivement échantillonné par la radio SDR, et la boîte verte affiche la partie du spectre que nous voulons. Notre LO sera réglé sur 95 MHz car c’est la fréquence sur laquelle nous demandons au SDR de s’accorder. Comme 95 MHz est en dehors de la boîte verte, nous n’aurons pas de pic de courant continu.

Il y a un problème: si nous voulons que notre signal soit centré à 100 MHz et ne contienne que 5 MHz, nous devrons effectuer nous-mêmes un décalage de fréquence, un filtrage et un sous-échantillonnage du signal (ce que nous apprendrons à faire plus tard). Heureusement, ce processus de désaccordage, c’est-à-dire l’application d’un décalage du LO, est souvent intégré aux SDR, qui effectuent automatiquement le désaccordage et décalent ensuite la fréquence vers la fréquence centrale souhaitée. L’avantage est que la SDR peut le faire en interne: nous n’avons pas besoin d’envoyer une fréquence d’échantillonnage plus élevée sur notre connexion USB ou Ethernet, chose qui limitera la fréquence d’échantillonnage que nous pouvons utiliser.

Cette sous-section concernant les décalages DC est un bon exemple de ce qui différencie ce manuel des autres. Votre manuel DSP moyen abordera l’échantillonnage, mais il a tendance à ne pas inclure les obstacles à la mise en œuvre tels que les décalages DC, malgré leur importance dans la pratique.

Echantillonnage à l’aide de notre SDR

Pour obtenir des informations spécifiques à la SDR sur l’exécution de l’échantillonnage, consultez l’un des chapitres suivants :

Chapitre PlutoSDR en Python

Chapitre USRP en Python

Calcul de la puissance moyenne

Pour un signal complexe discret, c’est-à-dire un signal que nous avons échantillonné, nous pouvons trouver la puissance moyenne en prenant la module de chaque échantillon, en l’élevant au carré, puis en calculant la moyenne :

\[P = \frac{1}{N} \sum_{n=1}^{N} |x[n]|^2\]

Rappelez-vous que la valeur absolue d’un nombre complexe est juste le module, c’est-à-dire, \(\sqrt{I^2+Q^2}\)

En Python, le calcul de la puissance moyenne ressemblera à ceci :

avg_pwr = np.mean(np.abs(x)**2)

Voici une astuce très utile pour calculer la puissance moyenne d’un signal échantillonné. Si votre signal a une moyenne approximativement nulle - ce qui est généralement le cas dans la SDR (nous verrons pourquoi plus tard) - alors la puissance du signal peut être trouvée en prenant la variance des échantillons. Dans ces circonstances, vous pouvez calculer la puissance de cette façon en Python:

avg_pwr = np.var(x) # (signal should have roughly zero mean)

La raison pour laquelle la variance des échantillons calcule la puissance moyenne est assez simple: l’équation de la variance est \(\frac{1}{N}\sum^N_{n=1} |x[n]-\mu|^2\) où \(\mu\) est la moyenne du signal. Cette équation semble familière! Si \(\mu\) est égal à zéro, l’équation permettant de déterminer la variance des échantillons devient équivalente à l’équation de la puissance. Vous pouvez également soustraire la moyenne des échantillons de votre fenêtre d’observation, puis calculer la variance. Sachez simplement que si la valeur moyenne n’est pas nulle, la variance et la puissance ne sont pas égales.

Calcul de la densité spectrale de puissance

Dans le chapitre précédent, nous avons appris que nous pouvions convertir un signal dans le domaine des fréquences à l’aide d’une FFT, et que le résultat était appelé densité spectrale de puissance (DSP), qu’on notera par la suite PSD (pour Power Spectral Density) et eviter ainsi toute confusion avec DSP de Digitl Signal Processing.

Mais pour trouver réellement la PSD d’un lot d’échantillons et la tracer, nous ne nous contentons pas d’effectuer une FFT. Nous devons effectuer les six opérations suivantes:

Prenez la FFT de nos échantillons. Si nous avons x échantillons, la taille de la FFT sera la longueur de x par défaut. Utilisons les 1024 premiers échantillons comme exemple pour créer une FFT de taille 1024. La sortie sera de 1024 flottants complexes.

Prenez la magnitude de la sortie de la FFT, ce qui nous donne 1024 flottants réels.

Élevez au carré la magnitude résultante pour obtenir la puissance.

Normaliser: diviser par la taille de la FFT (\(N\)) et le taux d’échantillonnage (\(Fs\)).

Convertissez en dB en utilisant \(10 \log_{10}()\) ; nous considérons toujours les PSD en échelle logarithmique.

Effectuez un décalage FFT, abordé dans le chapitre précédent, pour déplacer «  0 Hz «  au centre et les fréquences négatives à gauche du centre.

Ces six étapes en Python sont:

Fs = 1e6 # Disons que nous échantillonnons à 1 MHz
# supposez que x contient votre tableau d'échantillons de QI
N = 1024
x = x[0:N] # nous ne prendrons que la FFT des 1024 premiers échantillons, voir le texte ci-dessous
PSD = np.abs(np.fft.fft(x))**2 / (N*Fs)
PSD_log = 10.0*np.log10(PSD)
PSD_shifted = np.fft.fftshift(PSD_log)

En option, nous pouvons appliquer une fenêtre, comme nous l’avons appris dans le chapitre Domaine fréquentiel. Le fenêtrage aura lieu juste avant la ligne de code avec fft().

# ajouter la ligne suivante après avoir fait x = x[0:1024]
x = x * np.hamming(len(x)) # appliquer une fenêtre de Hamming

Pour tracer cette PSD, nous devons connaître les valeurs de l’axe des abscisses. Comme nous l’avons appris au chapitre précédent, lorsque nous échantillonnons un signal, nous ne « voyons » que le spectre compris entre -Fs/2 et Fs/2, Fs étant notre fréquence d’échantillonnage. La résolution que nous obtenons dans le domaine fréquentiel dépend de la taille de notre FFT, qui par défaut est égale au nombre d’échantillons sur lesquels nous effectuons l’opération de FFT. Dans ce cas, notre axe x est constitué de 1024 points équidistants entre -0.5 MHz et 0.5 MHz. Si nous avions réglé notre SDR sur 2.4 GHz, notre fenêtre d’observation serait comprise entre 2.3995 GHz et 2.4005 GHz. En Python, le déplacement de la fenêtre d’observation ressemblera à ceci :

center_freq = 2.4e9 # fréquence sur laquelle nous avons réglé notre SDR.
f = np.arange(Fs/-2.0, Fs/2.0, Fs/N) # début, fin, pas, centré autour de 0 Hz
f += center_freq # ajoutez maintenant la fréquence centrale
plt.plot(f, PSD_shifted)
plt.show()

Nous devrions nous retrouver avec un magnifique PSD!

Si vous voulez trouver la DSP de millions d’échantillons, ne faites pas une FFT d’un million de points car cela prendra probablement une éternité. Cela vous donnera une sortie d’un million de « bins de fréquence », après tout, ce qui est trop pour être affiché dans un graphique. Je vous suggère plutôt de faire plusieurs DSP plus petites et d’en faire la moyenne ou de les afficher à l’aide d’un spectrogramme. Alternativement, si vous savez que votre signal ne change pas rapidement, il est adéquat d’utiliser quelques milliers d’échantillons et de trouver la DSP de ceux-ci; dans ce laps de temps de quelques milliers d’échantillons, vous aurez probablement capturé assez du signal pour obtenir une bonne représentation.

Voici un exemple de code complet qui inclut la génération d’un signal (exponentielle complexe à 50 Hz) et de bruit. Notez que N, le nombre d’échantillons à simuler, devient la longueur de la FFT car nous prenons la FFT de tout le signal simulé.

import numpy as np
import matplotlib.pyplot as plt

Fs = 300 # taux d'échantillonnage
Ts = 1/Fs # période d'échantillonnage
N = 2048 # nombre d'échantillons à simuler

t = Ts*np.arange(N)
x = np.exp(1j*2*np.pi*50*t) # simule une sinusoïde à 50 Hz

n = (np.random.randn(N) + 1j*np.random.randn(N))/np.sqrt(2) # bruit complexe à puissance unitaire
noise_power = 2
r = x + n * np.sqrt(noise_power)

PSD = np.abs(np.fft.fft(r))**2 / (N*Fs)
PSD_log = 10.0*np.log10(PSD)
PSD_shifted = np.fft.fftshift(PSD_log)

f = np.arange(Fs/-2.0, Fs/2.0, Fs/N) # début, fin, pas

plt.plot(f, PSD_shifted)
plt.xlabel("Fréquence [Hz]")
plt.ylabel("Amplitude [dB]")
plt.grid(True)
plt.show()

sortie:

Aller plus loin (anglais)

http://rfic.eecs.berkeley.edu/~niknejad/ee242/pdf/eecs242_lect3_rxarch.pdf

Synchronisation

Ce chapitre traite de la synchronisation des signaux sans fil en temps et en fréquence, afin de corriger les décalages de la fréquence porteuse et d’effectuer un alignement temporel au niveau des symboles et des trames. Nous utiliserons la technique de récupération d’horloge de Mueller et Muller, ainsi que la boucle de Costas, en Python.

Introduction

Nous avons vu comment transmettre numériquement par voie hertzienne, en utilisant un schéma de modulation numérique comme la QPSK et en appliquant une mise en forme des impulsions pour limiter la largeur de bande du signal. Le codage de canal peut être utilisé pour traiter les canaux bruyants, par exemple lorsque le rapport signal/bruit est faible au niveau du récepteur. Il est toujours utile de filtrer autant que possible le signal avant de le traiter numériquement. Dans ce chapitre, nous allons étudier la manière dont la synchronisation est effectuée du côté de la réception. La synchronisation est un ensemble de traitements qui se produisent avant la démodulation et le décodage du canal. La chaîne globale tx-canal-rx est représentée ci-dessous, avec les blocs abordés dans ce chapitre surlignés en jaune. (Ce diagramme n’est pas exhaustif : la plupart des systèmes incluent également l’égalisation et le multiplexage).

Simulation d’un canal sans fil

Avant d’apprendre à mettre en œuvre la synchronisation temporelle et fréquentielle, nous devons rendre nos signaux simulés plus réalistes. Sans l’ajout d’un retard aléatoire, la synchronisation dans le temps est triviale. En fait, il suffit de prendre en compte le retard d’échantillonnage de tous les filtres que vous utilisez. Nous voulons également simuler un décalage de fréquence car, comme nous le verrons, les oscillateurs ne sont pas parfaits; il y aura toujours un certain décalage entre la fréquence centrale de l’émetteur et celle du récepteur.

Examinons maintenant le code Python permettant de simuler un retard non entier et un décalage de fréquence. Le code Python de ce chapitre part du code que nous avons écrit lors de l’exercice Python de mise en forme des impulsions (cliquez ci-dessous si vous en avez besoin); vous pouvez le considérer comme le point de départ du code de ce chapitre, et le nouveau code viendra ensuite.

Code Python de la mise en forme des impulsions

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal
import math

# cette partie provient de l'exercice des impulsions de mise en forme
num_symbols = 100
sps = 8
bits = np.random.randint(0, 2, num_symbols) # Nos données à transmettre: des 1 et des 0.
pulse_train = np.array([])
for bit in bits:
    pulse = np.zeros(sps)
    pulse[0] = bit*2-1 # définir la première valeur à 1 ou -1
    pulse_train = np.concatenate((pulse_train, pulse)) # ajouter les 8 échantillons au signal

# Créer notre filtre à base de cosinus surélevé
num_taps = 101
beta = 0.35
Ts = sps # Supposons que la fréquence d'échantillonnage est de 1Hz, donc la période d'échantillonnage est de 1, donc la période du *symbole* est de 8.
t = np.arange(-51, 52) # n'oubliez pas que le nombre final n'est pas inclus
h = np.sinc(t/Ts) * np.cos(np.pi*beta*t/Ts) / (1 - (2*beta*t/Ts)**2)

# Filtrer notre signal, afin d'appliquer l'impulsion de mise en forme
samples = np.convolve(pulse_train, h)

Nous laisserons de côté le code relatif au tracé car vous avez probablement déjà appris à tracer n’importe quel signal. Pour que les tracés soient jolis, comme c’est souvent le cas dans ce manuel, il faut beaucoup de code supplémentaire qu’il n’est pas nécessaire de comprendre.

Ajouter un délai

Nous pouvons facilement simuler un retard en décalant les échantillons, mais cela ne simule qu’un retard qui est un multiple entier de notre période d’échantillonnage. Dans le monde réel, le retard sera une fraction de la période d’échantillonnage. Nous pouvons simuler le retard d’une fraction d’échantillon en créant un filtre à « retard fractionnel », qui laisse passer toutes les fréquences mais retarde les échantillons d’une certaine quantité qui n’est pas limitée à l’intervalle d’échantillonnage. Vous pouvez l’imaginer comme un filtre passe-tout qui applique le même déphasage à toutes les fréquences. (Rappelez-vous qu’un retard temporel et un déphasage sont équivalents.) Le code Python permettant de créer ce filtre est présenté ci-dessous:

# Créer et appliquer un filtre à retard fractionnel
delay = 0.4 # délai fractionné, en échantillons
N = 21 # nombre de taps
n = np.arange(-N//2, N//2) # ...-3,-2,-1,0,1,2,3...
h = np.sinc(n - delay) # calcul des taps du filtre
h *= np.hamming(N) # fenêtre du filtre pour s'assurer qu'il décroit vers 0 des deux côtés
h /= np.sum(h) # normaliser pour obtenir un gain unitaire, nous ne voulons pas changer l'amplitude/puissance
samples = np.convolve(samples, h) # appliquer le filtre

Comme vous pouvez le voir, nous calculons les prises du filtre à l’aide d’une fonction sinc(). Une fonction sinc dans le domaine temporel est un rectangle dans le domaine fréquentiel, et notre rectangle pour ce filtre couvre toute la gamme de fréquences de notre signal. Ce filtre ne remodèle pas le signal, il le retarde simplement dans le temps. Dans notre exemple, nous retardons de 0.4 échantillon. N’oubliez pas que l’application de n’importe quel filtre retarde un signal de la moitié des taps du filtre moins un, en raison de la convolution du signal à travers le filtre.

Si nous traçons le graphique « avant » et « après » le filtrage d’un signal, nous pouvons observer le retard fractionnel. Dans notre graphique, nous ne zoomons que sur quelques symboles. Sinon, le retard fractionnel n’est pas visible.

Ajout d’un décalage de fréquence

Pour rendre notre signal simulé plus réaliste, nous allons appliquer un décalage de fréquence. Disons que notre fréquence d’échantillonnage dans cette simulation est de 1 MHz (la valeur n’a pas vraiment d’importance, mais vous verrez pourquoi il est plus facile de choisir un nombre). Si nous voulons simuler un décalage de fréquence de 13 kHz (un nombre arbitraire), nous pouvons le faire via le code suivant:

# appliquer un décalage de fréquence
fs = 1e6 # supposons que notre fréquence d'échantillonnage est de 1 MHz
fo = 13000 # simuler le décalage de la fréquence
Ts = 1/fs # période d'échantillonnage
t = np.arange(0, Ts*len(samples), Ts) # créer un vecteur temps
samples = samples * np.exp(1j*2*np.pi*fo*t) # effectuer un décalage de fréquence

La figure ci-dessous montre le signal avant et après l’application du décalage de fréquence.

Nous n’avons pas représenté graphiquement la partie Q puisque nous transmettions en BPSK, ce qui fait que la partie Q est toujours nulle. Maintenant que nous ajoutons un décalage de fréquence pour simuler les canaux sans fil, l’énergie s’étend sur I et Q. À partir de maintenant, nous devrions tracer à la fois I et Q. N’hésitez pas à substituer un décalage de fréquence différent pour votre code. Si vous abaissez le décalage à environ 1 kHz, vous serez en mesure de voir la sinusoïde dans l’enveloppe du signal car elle oscille suffisamment lentement pour couvrir plusieurs symboles.

En ce qui concerne le choix d’une fréquence d’échantillonnage arbitraire, si vous examinez le code, vous remarquerez que ce qui importe est le rapport entre fo et fs.

Vous pouvez prétendre que les deux blocs de code présentés précédemment simulent un canal sans fil. Le code devrait venir après le code côté émission (ce que nous avons fait dans le chapitre sur les impulsions de mise en forme) et avant le code côté réception, qui est ce que nous allons explorer dans le reste de ce chapitre.

Synchronisation du temps

Lorsque nous transmettons un signal sans fil, il arrive au récepteur avec un déphasage aléatoire dû au temps parcouru. Nous ne pouvons pas simplement commencer à échantillonner les symboles à notre débit de symboles car il est peu probable que nous l’échantillonnions au bon endroit dans l’impulsion, comme nous l’avons vu à la fin du chapitre Mise en Forme. Revoyez les trois figures à la fin de ce chapitre si vous ne suivez pas.

La plupart des techniques de synchronisation prennent la forme d’une boucle à verrouillage de phase (ou PLL en anglais pour phase locked loop). Nous n’étudierons pas les PLL ici, mais il est important de connaître ce terme et vous pouvez vous documenter sur le sujet si vous êtes intéressé. Les PLL sont des systèmes en boucle fermée qui utilisent la rétroaction pour ajuster continuellement un paramètre; dans notre cas, un décalage temporel nous permet d’échantillonner au pic des symboles numériques.

Vous pouvez vous représenter la récupération du temps comme un bloc dans le récepteur, qui accepte un flux d’échantillons et sort un autre flux d’échantillons (similaire à un filtre). Nous programmons ce bloc de récupération du temps avec des informations sur notre signal, la plus importante étant le nombre d’échantillons par symbole (ou notre meilleure estimation de celui-ci, si nous ne sommes pas sûrs à 100 % de ce qui a été transmis). Ce bloc agit comme un « décimateur », c’est-à-dire que notre échantillon de sortie sera une fraction du nombre d’échantillons d’entrée. Nous voulons un échantillon par symbole numérique, donc le taux de décimation est simplement les échantillons par symbole. Si l’émetteur transmet à 1M symboles par seconde et que nous échantillonnons à 16 Msps, nous recevrons 16 échantillons par symbole. Ce sera le taux d’échantillonnage entrant dans le bloc de synchronisation. Le taux d’échantillonnage sortant du bloc sera de 1 Msps car nous voulons un échantillon par symbole numérique.

La plupart des méthodes de récupération du temps reposent sur le fait que nos symboles numériques montent puis descendent, et que la crête est le point auquel nous voulons échantillonner le symbole. En d’autres termes, nous échantillonnons le point maximum après avoir pris la valeur absolue :

System Message: WARNING/2 (/home/vs/SPHINX/source/sync.rst, line 130)

Cannot scale image! Could not get size from "../_images/symbol_sync2.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/symbol_sync2.png'

Il existe de nombreuses méthodes de récupération du temps, la plupart ressemblant à une PLL. La différence entre elles réside généralement dans l’équation utilisée pour effectuer la « correction » du décalage temporel, que nous désignons par \(\mu\) ou mu dans le code. La valeur de mu est mise à jour à chaque itération de la boucle. Elle est exprimée en unités d’échantillons, et vous pouvez l’imaginer comme le décalage que nous devons faire pour pouvoir échantillonner au moment « parfait ». Ainsi, si mu = 3.61, cela signifie que nous devons décaler l’entrée de 3.61 échantillons pour échantillonner au bon endroit. Comme nous avons 8 échantillons par symbole, si mu dépasse 8, il revient simplement à zéro.

Le code Python suivant implémente la technique de récupération d’horloge de Mueller et Muller.

mu = 0 # estimation initiale de la phase de l'échantillon
out = np.zeros(len(samples) + 10, dtype=np.complex)
out_rail = np.zeros(len(samples) + 10, dtype=np.complex) # stocke les valeurs, à chaque itération nous avons besoin des 2 valeurs précédentes plus la valeur actuelle.
i_in = 0 # index des échantillons d'entrée
i_out = 2 # indice de sortie (les deux premières sorties sont 0)
while i_out < len(samples) and i_in+16 < len(samples):
    out[i_out] = samples[i_in + int(mu)] # prendre ce que nous pensons être le "meilleur" échantillon.
    out_rail[i_out] = int(np.real(out[i_out]) > 0) + 1j*int(np.imag(out[i_out]) > 0)
    x = (out_rail[i_out] - out_rail[i_out-2]) * np.conj(out[i_out-1])
    y = (out[i_out] - out[i_out-2]) * np.conj(out_rail[i_out-1])
    mm_val = np.real(y - x)
    mu += sps + 0.3*mm_val
    i_in += int(np.floor(mu)) # arrondir à l'entier le plus proche puisque nous l'utilisons comme un index
    mu = mu - np.floor(mu) # supprimer la partie entière de mu
    i_out += 1 # incrémenter l'indice de sortie
out = out[2:i_out] # supprimer les deux premiers, et tout ce qui suit i_out (qui n'a jamais été rempli)
samples = out # n'incluez cette ligne que si vous voulez connecter cet extrait de code avec la boucle Costas plus tard

Le bloc de récupération du timing reçoit les échantillons « reçus » et produit un échantillon de sortie un par un (notez que i_out est incrémenté de 1 à chaque itération de la boucle). Le bloc de récupération n’utilise pas seulement les échantillons « reçus » l’un après l’autre à cause de la façon dont la boucle ajuste i_in. Elle sautera quelques échantillons pour essayer de tirer le « bon » échantillon, qui serait celui au pic de l’impulsion. Au fur et à mesure que la boucle traite les échantillons, elle se synchronise lentement sur le symbole, ou du moins elle tente de le faire en ajustant mu. Étant donné la structure du code, la partie entière de mu est ajoutée à i_in, puis retirée de mu (gardez à l’esprit que mm_val peut être négatif ou positif à chaque boucle). Une fois qu’elle est complètement synchronisée, la boucle ne devrait tirer que l’échantillon central de chaque symbole/impulsion. Vous pouvez ajuster la constante 0.3, qui modifiera la vitesse de réaction de la boucle de rétroaction; une valeur plus élevée la fera réagir plus rapidement, mais avec un risque plus élevé de problèmes de stabilité.

Le graphique suivant montre un exemple de sortie où nous avons désactivé le délai fractionnel ainsi que le décalage de fréquence. Nous montrons seulement I parce que Q est tout à fait nul avec le décalage de fréquence désactivé. Les trois graphiques sont empilés les uns sur les autres pour montrer comment les bits sont alignés verticalement.

Graphique du haut

Symboles BPSK originaux, c’est-à-dire des 1 et des -1. Rappelez-vous qu’il y a des zéros entre les deux car nous voulons 8 échantillons par symbole.

Graphique du milieu

Echantillons après l’impulsion de mise en forme mais avant le synchronisation.

Graphique du bas

Sortie de la synchronisation de symboles, qui fournit seulement 1 échantillon par symbole. Cela signifie que ces échantillons peuvent être introduits directement dans un démodulateur, qui, pour la BPSK, vérifie si la valeur est supérieure ou inférieure à 0.

Concentrons-nous sur le graphique du bas, qui est la sortie de la synchronisation. Il a fallu près de 30 symboles pour que la synchronisation se verrouille sur le bon délai. En raison inévitablement du temps nécessaire aux synchroniseurs pour se verrouiller, de nombreux protocoles de communication utilisent un préambule contenant une séquence de synchronisation: il sert à annoncer l’arrivée d’un nouveau paquet et donne au récepteur le temps de se synchroniser sur celui-ci. Mais après ces ~30 échantillons, la synchronisation fonctionne parfaitement. Nous nous retrouvons avec des 1 et des -1 parfaits qui correspondent aux données d’entrée. Il est utile que cet exemple n’ait pas eu de bruit ajouté. N’hésitez pas à ajouter du bruit ou des décalages temporels et voyez comment la synchronisation se comporte. Si nous utilisions la QPSK, nous aurions affaire à des nombres complexes, mais l’approche serait la même.

Synchronisation du temps avec interpolation

Les synchroniseurs de symboles ont tendance à interpoler les échantillons d’entrée par un certain nombre, par exemple 16, afin de pouvoir se décaler d’une fraction d’échantillon. Le retard aléatoire causé par le canal sans fil ne sera probablement pas un multiple exact d’un échantillon, de sorte que le pic du symbole peut ne pas se produire réellement sur un échantillon. C’est particulièrement vrai dans le cas où il n’y aurait que 2 ou 4 échantillons par symbole reçu. L’interpolation des échantillons nous permet d’échantillonner « entre » les échantillons réels, afin d’atteindre le pic de chaque symbole. La sortie du synchroniseur n’est toujours qu’un échantillon par symbole. Les échantillons d’entrée sont eux-mêmes interpolés.

Le code Python de synchronisation temporelle que nous avons implémenté ci-dessus n’incluait pas d’interpolation. Pour étendre notre code, activez le retard temporel fractionnaire que nous avons implémenté au début de ce chapitre afin que notre signal reçu ait un retard plus réaliste. Laissez le décalage de fréquence désactivé pour le moment. Si vous relancez la simulation, vous constaterez que la synchronisation ne parvient pas à se synchroniser complètement sur le signal. C’est parce que nous n’interpolons pas, et que le code n’a aucun moyen « d’échantillonner entre les échantillons » pour compenser le retard fractionnel. Ajoutons l’interpolation.

Un moyen rapide d’interpoler un signal en Python est d’utiliser signal.resample ou signal.resample_poly de scipy. Ces deux fonctions font la même chose mais fonctionnent différemment. Nous utiliserons la dernière fonction car elle a tendance à être plus rapide. Interpolons par 16, c’est-à-dire que nous allons insérer 15 échantillons supplémentaires entre chaque échantillon. Cela peut être fait en une ligne de code, et cela devrait se faire avant d’effectuer la synchronisation temporelle (avant le gros extrait de code ci-dessus). Nous allons également tracer le graphique avant et après pour voir la différence:

samples_interpolated = signal.resample_poly(samples, 16, 1)

# Tracez l'ancien et le nouveau
plt.figure('avant interp')
plt.plot(samples,'.-')
plt.figure('après interp')
plt.plot(samples_interpolated,'.-')
plt.show()

Si on zoome beaucoup, on voit que c’est le même signal, mais avec 16x plus de points :

J’espère que la raison pour laquelle nous devons interpoler à l’intérieur du bloc de synchronisation temporelle devient claire. Ces échantillons supplémentaires nous permettront de prendre en compte une fraction d’un échantillon de retard. En plus de calculer samples_interpolated, nous devons également modifier une ligne de code dans notre synchronisation temporelle. Nous allons changer la première ligne à l’intérieur de la boucle while pour devenir:

out[i_out] = samples_interpolated[i_in*16 + int(mu*16)]

Nous avons fait plusieurs choses ici. D’abord, nous ne pouvons plus utiliser i_in comme index de l’échantillon d’entrée. Nous devons le multiplier par 16 car nous avons interpolé nos échantillons d’entrée par 16. Rappelez-vous que la boucle de rétroaction ajuste la variable mu. Elle représente le délai qui nous permet d’échantillonner au bon moment. Rappelez-vous également qu’après avoir calculé la nouvelle valeur de mu, nous avons ajouté la partie entière à i_in. Maintenant, nous allons utiliser la partie restante, qui est un flottant de 0 à 1, et qui représente la fraction d’échantillon que nous devons retarder. Avant, nous n’étions pas capables de retarder d’une fraction d’échantillon, mais maintenant nous le pouvons, au moins par incréments de 16ème d’échantillon. Il faut donc multiplier mu par 16 pour savoir de combien d’échantillons de notre signal interpolé nous devons retarder. Ensuite, nous devons arrondir ce nombre, car la valeur entre parenthèses est finalement un index et doit être un nombre entier. Si ce paragraphe n’a pas eu de sens, essayez de revenir au code initial de récupération d’horloge de Mueller et Muller, et lisez également les commentaires à côté de chaque ligne de code.

Le résultat du tracé de ce nouveau code devrait être à peu près le même que précédemment. Tout ce que nous avons fait, c’est rendre notre simulation plus réaliste en ajoutant un retard d’échantillon fractionnaire, puis nous avons ajouté l’interpolateur à la synchronisation afin de compenser ce retard d’échantillon fractionnaire.

N’hésitez pas à jouer avec différents facteurs d’interpolation, c’est-à-dire à remplacer tous les 16 par une autre valeur. Vous pouvez également essayer d’activer le décalage de fréquence, ou d’ajouter un bruit blanc gaussien au signal avant qu’il ne soit reçu, pour voir comment cela affecte les performances de synchronisation (indice : vous devrez peut-être ajuster le multiplicateur de 0.3).

Si nous activons uniquement le décalage de fréquence en utilisant une fréquence de 1kHz, nous obtenons les performances de synchronisation suivantes. Nous devons montrer à la fois I et Q maintenant que nous avons ajouté un décalage de fréquence :

C’est peut-être difficile à voir, mais la synchronisation du temps fonctionne toujours très bien. Il faut environ 20 à 30 symboles avant qu’elle ne soit verrouillée. Cependant, il y a un motif sinusoïdal parce que nous avons encore un décalage de fréquence, et nous allons apprendre à le gérer dans la section suivante.

La figure ci-dessous montre le graphique IQ (aussi appelé constellation) du signal avant et après la synchronisation. Rappelez-vous que vous pouvez tracer des échantillons sur un graphique IQ en utilisant un nuage de points : plt.plot(np.real(samples), np.imag(samples), '.'). Dans l’animation ci-dessous, nous avons spécifiquement laissé de côté les 30 premiers symboles. Ils sont apparus avant la fin de la synchronisation temporelle. Les symboles restants sont tous approximativement sur le cercle des unités en raison du décalage de fréquence.

Pour en savoir encore plus, nous pouvons observer la constellation dans le temps afin de discerner ce qui arrive réellement aux symboles. Au tout début, pendant une courte période de temps, les symboles ne sont pas à 0 ou sur le cercle unitaire. C’est la période pendant laquelle la synchronisation temporelle trouve le bon délai. C’est très rapide, regardez bien! La rotation est juste le décalage de fréquence. La fréquence est un changement constant de la phase, donc un décalage de fréquence provoque une rotation de la BPSK (créant un cercle dans le tracé statique/persistant ci-dessus).

Nous espérons qu’en voyant un exemple de synchronisation temporelle, vous avez une idée de ce qu’elle fait et une idée générale de son fonctionnement. En pratique, la boucle while que nous avons créée ne fonctionnerait que sur un petit nombre d’échantillons à la fois (par exemple, 1000). Vous devez vous souvenir de la valeur de mu entre les appels à la fonction sync, ainsi que des deux dernières valeurs de out et out_rail.

Ensuite, nous allons étudier la synchronisation de la fréquence, que nous divisons en synchro de fréquence grossière et fine. La synchronisation grossière vient généralement avant la synchronisation temporelle, tandis que la synchronisation fine vient après.

Synchronisation grossière des fréquences

Même si nous demandons à l’émetteur et au récepteur de fonctionner sur la même fréquence centrale, il y aura un léger décalage de fréquence entre les deux en raison d’imperfections matérielles (par exemple, l’oscillateur) ou d’un décalage Doppler dû au mouvement. Ce décalage de fréquence sera minuscule par rapport à la fréquence porteuse, mais même un petit décalage peut perturber un signal numérique. Le décalage évoluera probablement dans le temps, ce qui nécessite une boucle de rétroaction permanente pour corriger le décalage. Par exemple, l’oscillateur à l’intérieur du Pluto a une spécification de décalage maximale de 25 PPM. C’est-à-dire 25 parties par million par rapport à la fréquence centrale. Si vous êtes réglé sur 2.4 GHz, le décalage maximal serait de +/- 60 kHz. Les échantillons que notre SDR nous fournit sont en bande de base, ce qui fait que tout décalage de fréquence se manifeste dans ce signal en bande de base. Un signal BPSK avec un petit décalage de la porteuse ressemblera au tracé temporel ci-dessous, ce qui n’est évidemment pas idéal pour démoduler des bits. Nous devons supprimer tout décalage de fréquence avant la démodulation.

System Message: WARNING/2 (/home/vs/SPHINX/source/sync.rst, line 244)

Cannot scale image! Could not get size from "../_images/carrier-offset.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/carrier-offset.png'

La synchronisation de fréquence est généralement décomposée en synchronisation grossière et synchronisation fine, où la synchronisation grossière corrige les grands décalages de l’ordre du kHz ou plus, tandis que la synchronisation fine corrige ce qui reste. La synchronisation grossière intervient avant la synchronisation temporelle, tandis que la synchronisation fine intervient après.

Mathématiquement, si nous disposons d’un signal en bande de base \(s(t)\) et qu’il subit un décalage de fréquence (aussi appelé porteuse) de \(f_o\) Hz, nous pouvons représenter ce qui est reçu comme suit:

\[r(t) = s(t) e^{j2\pi f_o t} + n(t)\]

où \(n(t)\) est le bruit.

La première astuce que nous allons apprendre, afin d’effectuer une estimation grossière du décalage de fréquence (si nous pouvons estimer la fréquence de décalage, alors nous pouvons la compenser), est de prendre le carré de notre signal. Ignorons le bruit pour l’instant, afin de garder les mathématiques plus simples :

\[r^2(t) = s^2(t) e^{j4\pi f_o t}\]

Voyons ce qui se passe lorsque nous prenons le carré de notre signal \(s(t)\) en considérant ce que ferait la QPSK. L’élévation au carré de nombres complexes donne lieu à un comportement intéressant, surtout lorsqu’il s’agit de constellations comme la BPSK et la QPSK. L’animation suivante montre ce qui se passe lorsqu’on élève au carré une QPSK, puis si on l’élève encore une deuxième fois. J’ai utilisé spécifiquement la QPSK au lieu de la BPSK parce que vous pouvez voir que lorsque vous érigez la QPSK une fois, vous obtenez essentiellement la BPSK. Et après un autre carré, on obtient un cluster. (Merci à http://ventrella.com/ComplexSquaring/ qui a créé cette belle application web).

System Message: WARNING/2 (/home/vs/SPHINX/source/sync.rst, line 266)

Cannot scale image! Could not get size from "../_images/squaring-qpsk.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/squaring-qpsk.gif'

Voyons ce qui se passe lorsqu’on applique à notre signal QPSK une petite rotation de phase et une mise à l’échelle de l’amplitude, ce qui est plus réaliste :

System Message: WARNING/2 (/home/vs/SPHINX/source/sync.rst, line 272)

Cannot scale image! Could not get size from "../_images/squaring-qpsk2.gif": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/squaring-qpsk2.gif'

Il s’agit toujours d’un seul groupe, mais avec un déphasage. Ce qu’il faut retenir, c’est que si vous mettez la QPSK au carré deux fois (et la BPSK une fois), les quatre groupes de points seront fusionnés en un seul groupe. Pourquoi cela est-il utile? En fusionnant les groupes, nous supprimons essentiellement la modulation! Si tous les points sont maintenant dans le même groupe, c’est comme si on avait un tas de constantes dans une rangée. C’est comme s’il n’y avait plus de modulation, et que la seule chose qui restait était la sinusoïde causée par le décalage de fréquence (nous avons aussi du bruit, mais ignorons-le pour l’instant). Il s’avère que vous devez élever le signal au carré N fois, où N est l’ordre du schéma de modulation utilisé, ce qui signifie que cette astuce ne fonctionne que si vous connaissez le schéma de modulation à l’avance. L’équation est en fait la suivante :

\[r^N(t) = s^N(t) e^{j2N\pi f_o t}\]

Pour notre cas de BPSK, nous avons un schéma de modulation d’ordre 2, nous utiliserons donc l’équation suivante pour notre synchronisation grossière de la fréquence:

\[r^2(t) = s^2(t) e^{j4\pi f_o t}\]

Nous avons découvert ce qui arrive à la partie \(s(t)\) de l’équation, mais qu’en est-il de la partie sinusoïde (alias exponentielle complexe)? Comme on peut le voir, on ajoute le terme \(N\), ce qui la rend équivalente à une sinusoïde à une fréquence de \(Nf_o\) au lieu de \(f_o\). Une méthode simple pour déterminer \(nf_o\) est de prendre la FFT du signal après l’avoir élevé au carré N fois et de voir où le pic se produit. Faisons une simulation en Python. Nous allons retourner à la génération de notre signal BPSK, et au lieu de lui appliquer un retard fractionnel, nous allons appliquer un décalage de fréquence en multipliant le signal par \(e^{j2\pi f_o t}\) comme nous l’avons fait dans le chapitre Filtres pour convertir un filtre passe-bas en un filtre passe-haut.

En utilisant le code du début de ce chapitre, appliquez un décalage de fréquence de +13 kHz à votre signal numérique. Cela peut se produire juste avant ou juste après l’ajout du retard fractionné; cela n’a pas d’importance. Quoi qu’il en soit, cela doit se faire après l’impulsion de mise en forme, mais avant d’effectuer toute fonction côté réception, comme la synchronisation temporelle.

Maintenant que nous avons un signal avec un décalage de fréquence de 13kHz, traçons la FFT avant et après la mise au carré, pour voir ce qui se passe. Vous devriez maintenant savoir comment effectuer une FFT, y compris les opérations abs() et fftshift(). Pour cet exercice, peu importe que vous preniez ou non le logarithme ou que vous éleviez au carré le signal après avoir effectué l’opération abs().

Regardez d’abord le signal avant de l’élever au carré (juste une FFT normale):

psd = np.fft.fftshift(np.abs(np.fft.fft(samples)))
f = np.linspace(-fs/2.0, fs/2.0, len(psd))
plt.plot(f, psd)
plt.show()

On ne voit pas vraiment de pic associé au décalage de la porteuse. Il est couvert par notre signal.

Maintenant avec l’élévation au carré ajoutée (juste une puissance de 2 parce que c’est une BPSK) :

# Ajoutez ceci avant la ligne FFT
samples = samples**2

Il faut zoomer pour voir sur quelle fréquence se trouve le pic :

Vous pouvez essayer d’augmenter le nombre de symboles simulés (par exemple, 1000 symboles) afin d’avoir suffisamment d’échantillons pour travailler. Plus il y a d’échantillons dans notre FFT, plus notre estimation du décalage de fréquence sera précise. Pour rappel, le code ci-dessus doit venir avant la synchornisation de temps.

Le pic de fréquence apparaît à \(Nf_o\). Nous devons diviser cette valeur (26.6kHz) par 2 pour trouver notre réponse finale, qui est très proche du décalage de fréquence de 13kHz que nous avons appliqué au début du chapitre! Si vous avez joué avec ce nombre et qu’il n’est plus de 13kHz, ce n’est pas grave. Assurez-vous simplement que vous êtes conscient de ce que vous avez réglé.

Comme notre fréquence d’échantillonnage est de 1 MHz, les fréquences maximales que nous pouvons voir sont de -500kHz à 500kHz. Si nous portons notre signal à la puissance N, cela signifie que nous ne pouvons « voir » les décalages de fréquence que jusqu’à \(500e3/N\), ou dans le cas de la BPSK +/- 250kHz. Si nous recevions un signal QPSK, il ne serait que de +/- 125kHz, et un décalage de la porteuse supérieur ou inférieur à cette valeur serait hors de notre portée avec cette technique. Pour vous donner une idée du décalage Doppler, si vous transmettez dans la bande des 2.4GHz et que l’émetteur ou le récepteur se déplace à 96km/h (c’est la vitesse relative qui compte), cela entraînera un décalage de fréquence de 214Hz. Le décalage dû à un oscillateur de mauvaise qualité sera probablement le principal coupable dans cette situation.

En fait, la correction de ce décalage de fréquence se fait exactement comme nous avons simulé le décalage en premier lieu: en multipliant par une exponentielle complexe, mais avec un signe négatif puisque nous voulons supprimer le décalage.

max_freq = f[np.argmax(psd)]
Ts = 1/fs # période d'échantillonnage
t = np.arange(0, Ts*len(samples), Ts) # vecteur de temps
samples = samples * np.exp(-1j*2*np.pi*max_freq*t/2.0)

C’est à vous de décider si vous voulez le corriger ou modifier le décalage de fréquence initial que nous avons appliqué au début à un nombre plus petit (comme 500Hz) pour tester la synchronisation de fréquence fine que nous allons maintenant apprendre à faire.

Synchronisation fine de la fréquence

Ensuite, nous allons passer à la synchronisation fine de la fréquence. L’astuce précédente est plutôt destinée à l’évanouissement grossier, et ce n’est pas une opération en boucle fermée (de type feedback). Mais pour la synchronisation fine de la fréquence, nous aurons besoin d’une boucle de rétroaction par laquelle nous ferons passer des échantillons, ce qui sera une fois de plus une forme de PLL. Notre objectif est de ramener le décalage de fréquence à zéro et de l’y maintenir, même si le décalage change au fil du temps. Nous devons continuellement suivre le décalage. Les techniques de synchronisation fine de la fréquence fonctionnent mieux avec un signal qui a déjà été synchronisé dans le temps au niveau du symbole, donc le code dont nous parlons dans cette section viendra après la synchronisation temporelle.

Nous allons utiliser une technique appelée boucle de Costas. Il s’agit d’une forme de PLL spécialement conçue pour la correction du décalage de la fréquence de la porteuse pour les signaux numériques tels que BPSK et QPSK. Elle a été inventée par John P. Costas chez General Electric dans les années 1950 et a eu un impact majeur sur les communications numériques modernes. La boucle de Costas supprime le décalage de fréquence tout en fixant le décalage de phase. L’énergie est alignée avec l’axe I. La fréquence n’est qu’un changement de phase, ils peuvent donc être suivis comme un tout. La boucle de Costas est résumée à l’aide du diagramme suivant (notez que les 1/2 ont été laissés de côté dans les équations car ils n’ont pas d’importance fonctionnelle).

L’oscillateur commandé en tension (ou VCO en anglais pour voltage controlled oscillator) est simplement un générateur d’ondes sin/cos qui utilise une fréquence basée sur l’entrée. Dans notre cas, puisque nous simulons un canal sans fil, il ne s’agit pas d’une tension, mais plutôt d’un niveau représenté par une variable. Elle détermine la fréquence et la phase des ondes sinus et cosinus générées. Ce qu’il fait, c’est multiplier le signal reçu par une sinusoïde générée en interne, afin de tenter d’annuler le décalage de fréquence et de phase. Ce comportement est similaire à celui d’une SDR qui effectue une conversion de fréquence et crée les branches I et Q.

Voici le code Python qui constitue notre boucle Costas:

N = len(samples)
phase = 0
freq = 0
# Ces deux paramètres suivants sont ce qu'il faut ajuster, pour rendre la boucle de rétroaction plus rapide ou plus lente (ce qui a un impact sur la stabilité).
alpha = 0.132
beta = 0.00932
out = np.zeros(N, dtype=np.complex)
freq_log = []
for i in range(N):
    out[i] = samples[i] * np.exp(-1j*phase) # ajuster l'échantillon d'entrée par l'inverse du décalage de phase estimé
    error = np.real(out[i]) * np.imag(out[i]) # Voici la formule d'erreur pour une boucle de Costas de 2ème ordre (par exemple pour BPSK)

    # Avancer la boucle (recalculer la phase et le décalage de fréquence)
    freq += (beta * error)
    freq_log.append(freq * fs / (2*np.pi)) # convertir de la vitesse angulaire en Hz pour la journalisation
    phase += freq + (alpha * error)

    # Facultatif: Ajustez la phase de façon à ce qu'elle soit toujours entre 0 et 2pi, rappelez-vous que la phase s'enroule autour de chaque 2pi
    while phase >= 2*np.pi:
        phase -= 2*np.pi
    while phase < 0:
        phase += 2*np.pi

# Tracez la fréquence en fonction du temps pour voir combien de temps il faut pour atteindre le bon décalage.
plt.plot(freq_log,'.-')
plt.show()

Il y a beaucoup de choses ici, alors passons-les en revue. Certaines lignes sont simples et d’autres sont super compliquées. samples est notre entrée, et out les échantillons de sortie. phase et frequency sont comme le mu du code de synchronisation temporelle. Ils contiennent les estimations du décalage actuel, et à chaque itération de la boucle, nous créons les échantillons de sortie en multipliant les échantillons d’entrée par np.exp(-1j*phase). La variable error contient la métrique d’erreur, et pour une boucle de Costas d’ordre 2, c’est une équation très simple. Nous multiplions la partie réelle de l’échantillon (I) par la partie imaginaire (Q), et parce que Q devrait être égal à zéro pour la BPSK, la fonction d’erreur est minimisée lorsqu’il n’y a pas de décalage de phase ou de fréquence qui fait passer l’énergie de I à Q. Pour une boucle de Costas d’ordre 4, c’est encore relativement simple mais pas tout à fait une ligne, car I et Q auront de l’énergie même lorsqu’il n’y a pas de décalage de phase ou de fréquence, pour la QPSK. Si vous êtes curieux de voir à quoi cela ressemble, cliquez ci-dessous, mais nous ne l’utiliserons pas dans notre code pour le moment. La raison pour laquelle cela fonctionne pour la QPSK est que lorsque vous prenez la valeur absolue de I et Q, vous obtenez +1+1j, et s’il n’y a pas de décalage de phase ou de fréquence, la différence entre la valeur absolue de I et Q devrait être proche de zéro.

Équation d'erreur de la boucle de Costas de l'ordre 4 (pour les curieux)

# For QPSK
def phase_detector_4(sample):
    if sample.real > 0:
        a = 1.0
    else:
        a = -1.0
    if sample.imag > 0:
        b = 1.0
    else:
        b = -1.0
    return a * sample.imag - b * sample.real

Les variables alpha et beta définissent la vitesse de mise à jour de la phase et de la fréquence, respectivement. Il y a une certaine théorie derrière le choix de ces deux valeurs, mais nous ne l’aborderons pas ici. Si vous êtes curieux, vous pouvez essayer de modifier alpha et/ou beta pour voir ce qui se passe.

Nous enregistrons la valeur de freq à chaque itération afin de pouvoir la tracer à la fin, pour voir comment la boucle de Costas converge vers le décalage de fréquence correct. Nous devons multiplier freq par la fréquence d’échantillonnage et convertir la fréquence angulaire en Hz, en la divisant par \(2\pi\). Notez que si vous avez effectué une synchronisation temporelle avant la boucle Costas, vous devrez également diviser par votre facteur de suréchantillonnage sps (par exemple, 8), car les échantillons provenant de la synchronisation temporelle sont à un taux égal à votre taux d’échantillonnage original divisé par sps.

Enfin, après avoir recalculé la phase, nous ajoutons ou supprimons suffisamment de \(2 \pi\)’s pour maintenir la phase entre 0 et \(2 \pi\)’s, ce qui enroule la phase autour.

Notre signal avant et après la boucle de Costas ressemble à ceci:

Et l’estimation du décalage de fréquence au fil du temps, se stabilisant sur le décalage correct (un décalage de -300Hz a été utilisé dans cet exemple de signal) :

Il faut près de 70 échantillons pour que l’algorithme se verrouille complètement sur le décalage de fréquence. Vous pouvez voir que dans mon exemple simulé, il restait environ -300 Hz après la synchronisation grossière de la fréquence. Les vôtres peuvent varier. Comme je l’ai déjà mentionné, vous pouvez désactiver la synchronisation grossière de la fréquence et définir le décalage initial de la fréquence à la valeur de votre choix et voir si la boucle de Costas s’en rend compte.

La boucle de Costas, en plus de supprimer le décalage de fréquence, a aligné notre signal BPSK pour qu’il soit sur la partie I, ce qui rend Q à nouveau nul. Il s’agit d’un effet secondaire pratique de la boucle de Costas, et il permet à la boucle de Costas d’agir essentiellement comme notre démodulateur. Maintenant, tout ce que nous avons à faire est de prendre I et de voir s’il est supérieur ou inférieur à zéro. Nous ne saurons pas vraiment comment transformer un négatif et un positif en 0 et 1 parce qu’il peut y avoir ou non une inversion; il n’y a aucun moyen pour la boucle de Costas (ou notre synchronisation temporelle) de le savoir. C’est là que le codage différentiel entre en jeu. Il lève l’ambiguïté car les 1 et les 0 sont basés sur le fait que le symbole a changé ou non, et non sur le fait qu’il était +1 ou -1. Si on ajoute le codage différentiel, on utilise toujours la BPSK. Nous ajouterions un bloc de codage différentiel juste avant la modulation du côté Tx et juste après la démodulation du côté Rx.

Vous trouverez ci-dessous une animation de la synchronisation temporelle et de la synchronisation de fréquence. La synchronisation temporelle se produit presque immédiatement, mais la synchronisation de fréquence prend presque toute l’animation pour s’installer complètement, et ce parce que alpha et beta ont été réglés trop bas, à 0.005 et 0.001 respectivement. Le code utilisé pour générer cette animation peut être trouvé ici [https://github.com/777arc/PySDR/blob/master/figure-generating-scripts/costas_loop_animation.py].

Synchronisation des trames

Nous avons vu comment corriger les décalages de temps, de fréquence et de phase dans notre signal reçu. Mais la plupart des protocoles de communication modernes ne se contentent pas de transmettre des bits en continu à un taux d’utilisation de 100%. Ils utilisent plutôt des paquets/trames. Au niveau du récepteur, nous devons être en mesure d’identifier le début d’une nouvelle trame. Habituellement, l’en-tête de trame (au niveau de la couche MAC) indique le nombre d’octets contenus dans la trame. Nous pouvons utiliser cette information pour connaître la longueur de la trame, par exemple, en unités d’échantillons ou de symboles. Néanmoins, la détection du début de la trame est une tâche totalement distincte. Vous trouverez ci-dessous un exemple de structure de trame WiFi. Notez que la toute première chose transmise est un en-tête de la couche PHY, et que la première moitié de cet en-tête est un « préambule ». Ce préambule contient une séquence de synchronisation que le récepteur utilise pour détecter le début des trames, et c’est une séquence connue d’avance par le récepteur.

System Message: WARNING/2 (/home/vs/SPHINX/source/sync.rst, line 447)

Cannot scale image! Could not get size from "../_images/wifi-frame.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/wifi-frame.png'

Une méthode courante et simple de détection de ces séquences au niveau du récepteur consiste à effectuer une corrélation croisée entre les échantillons reçus et la séquence connue. Lorsque la séquence se produit, cette intercorrélation ressemble à une autocorrélation (avec du bruit ajouté). Typiquement, les séquences choisies pour les préambules auront de belles propriétés d’autocorrélation, telles que l’autocorrélation de la séquence crée un seul pic fort à 0 et aucun autre pic. Les codes de Barker en sont un exemple. Dans la norme 802.11/WiFi, une séquence de Barker de longueur 11 est utilisée pour les débits de 1 et 2Mbit/sec :

+1 +1 +1 −1 −1 −1 +1 −1 −1 +1 −1

On peut l’assimiler à 11 symboles BPSK. Nous pouvons regarder l’autocorrélation de cette séquence très facilement en Python :

import numpy as np
import matplotlib.pyplot as plt
x = [1,1,1,-1,-1,-1,1,-1,-1,1,-1]
plt.plot(np.correlate(x,x,'same'),'.-')
plt.grid()
plt.show()

Vous pouvez voir qu’il y a 11 (longueur de la séquence) au centre, et -1 ou 0 pour tous les autres délais. Il fonctionne bien pour trouver le début d’une trame car il intègre essentiellement 11 symboles d’énergie dans une tentative de créer un pic de 1 bit dans la sortie de la corrélation croisée. En fait, la partie la plus difficile de la détection du début d’une trame est de trouver un bon seuil. Vous ne voulez pas que des trames qui ne font pas réellement partie de votre protocole le déclenchent. Cela signifie qu’en plus de la corrélation croisée, vous devez également effectuer une sorte de normalisation de la puissance, que nous n’examinerons pas ici. En décidant d’un seuil, vous devez faire un compromis entre la probabilité de détection et la probabilité de fausses alarmes. Rappelez-vous que l’en-tête de trame lui-même contiendra des informations, donc certaines fausses alarmes sont acceptables; vous découvrirez rapidement qu’il ne s’agit pas d’une trame lorsque vous décoderez l’en-tête et que le CRC échouera inévitablement (parce qu’il ne s’agissait pas d’une trame). Cependant, si certaines fausses alarmes sont acceptables, manquer complètement la détection d’une trame est une mauvaise chose.

Les séquences de Zadoff-Chu, utilisées en LTE, sont une autre séquence présentant d’excellentes propriétés d’autocorrélation. Elles ont l’avantage de se présenter sous forme d’ensembles; vous pouvez avoir plusieurs séquences différentes qui ont toutes de bonnes propriétés d’autocorrélation, mais elles ne se déclencheront pas les unes les autres (c’est-à-dire qu’elles ont également de bonnes propriétés de corrélation croisée, lorsque vous corrèlez différentes séquences de l’ensemble). Grâce à cette fonctionnalité, des séquences différentes seront attribuées à différentes stations de bases, de sorte qu’un téléphone puisse non seulement trouver le début de la trame mais aussi savoir de quelle station il reçoit.

USRP en Python

System Message: WARNING/2 (/home/vs/SPHINX/source/usrp.rst, line 7)

Cannot scale image! Could not get size from "../_images/usrp.png": [Errno 2] Aucun fichier ou dossier de ce nom: '../_images/usrp.png'

Dans ce chapitre, nous apprenons à utiliser l’API Python UHD pour contrôler et recevoir/transmettre des signaux avec un USRP [https://www.ettus.com/] qui est une série de SDRs fabriqués par Ettus Research (qui fait maintenant partie de NI). Nous discuterons de la transmission et de la réception sur l’USRP en Python, et nous plongerons dans les sujets spécifiques à l’USRP, notamment les args de streams ou flux, les sous-devices, les canaux, la synchronisation 10 MHz et PPS.

Installation de logiciels/pilotes

Bien que le code Python fourni dans ce manuel doive fonctionner sous Windows, Mac et Linux, nous ne fournirons que des instructions d’installation des pilotes/API spécifiques à Ubuntu 20 (bien que les instructions ci-dessous doivent fonctionner sur la plupart des distributions basées sur Debian). Nous allons commencer par créer une VM Ubuntu 20 VirtualBox ; n’hésitez pas à sauter la partie VM si votre système d’exploitation est déjà prêt.

Configuration d’une VM Ubuntu 20

(Optionel)

Télécharger Ubuntu 20.04 Desktop .iso- https://ubuntu.com/download/desktop

Installez et ouvrez VirtualBox [https://www.virtualbox.org/wiki/Downloads].

Créez une nouvelle VM. Pour la taille de la mémoire, je recommande d’utiliser 50% de la RAM de votre ordinateur.

Créez le disque dur virtuel, choisissez VDI, et allouez dynamiquement la taille. 15 Go devraient suffire. Si vous voulez être vraiment sûr, vous pouvez utiliser plus.

Démarrez la VM. Il vous demandera le support d’installation. Choisissez le fichier .iso du bureau Ubuntu 20. Choisissez « install ubuntu », utilisez les options par défaut, et une fenêtre pop-up vous avertira des changements que vous êtes sur le point d’effectuer. Cliquez sur continuer. Choisissez le nom/mot de passe et attendez que la VM finisse de s’initialiser. Après avoir terminé, la VM va redémarrer, mais vous devez éteindre la VM après le redémarrage.

Allez dans les paramètres de la VM (l’icône de l’engrenage).

Sous système > processeur > choisissez au moins 3 processeurs. Si vous avez une carte vidéo réelle, alors dans affichage > mémoire vidéo > choisissez quelque chose de beaucoup plus élevé.

Démarrez votre VM.

Pour les USRP de type USB, vous devrez installer des ajouts invités VM. Dans la VM, allez dans Périphériques > Insérer le CD Guest Additions > cliquez sur run quand une boîte apparaît. Suivez les instructions. Redémarrez la VM, puis essayez de transférer l’USRP à la VM, en supposant qu’elle apparaisse dans la liste sous Périphériques > USB. Le presse-papiers partagé peut être activé via Dispositifs > Presse-papiers partagé > Bidirectionnel.

Installation de l’UHD et de l’API Python

Les commandes de terminal ci-dessous devraient compiler et installer la dernière version de UHD, y compris l’API Python :

sudo apt-get install git cmake libboost-all-dev libusb-1.0-0-dev python3-docutils python3-mako python3-numpy python3-requests python3-ruamel.yaml python3-setuptools build-essential
cd ~
git clone https://github.com/EttusResearch/uhd.git
cd uhd/host
mkdir build
cd build
cmake -DENABLE_TESTS=OFF -DENABLE_C_API=OFF -DENABLE_MANUAL=OFF ..
make -j8
sudo make install
sudo ldconfig

Pour plus d’aide, voir la page officielle d’Ettus Building and Installing UHD from source [https://files.ettus.com/manual/page_build_guide.html]. Notez qu’il existe également des méthodes d’installation des pilotes qui ne nécessitent pas de construire à partir des sources.

Test des pilotes UHD et de l’API Python

Ouvrez un nouveau terminal et tapez les commandes suivantes :

python3
import uhd
usrp = uhd.usrp.MultiUSRP()
samples = usrp.recv_num_samps(10000, 100e6, 1e6, [0], 50)
print(samples[0:10])

Si aucune erreur ne se produit, vous êtes prêt à partir !

Analyse comparative de la vitesse de l’USRP en Python

(Optionel)

Si vous avez utilisé l’installation standard, la commande suivante devrait évaluer le taux de réception de votre USRP en utilisant l’API Python. Si l’utilisation de 56e6 a causé beaucoup d’échantillons perdus ou de dépassements, essayez de diminuer le nombre. Les échantillons perdus ne vont pas nécessairement ruiner quoi que ce soit, mais c’est un bon moyen de tester les inefficacités qui peuvent venir de l’utilisation d’une VM ou d’un ordinateur plus ancien, par exemple. Si vous utilisez un B 2X0, un ordinateur assez moderne avec un port USB 3.0 fonctionnant correctement devrait réussir à faire 56 MHz sans échantillons perdus, surtout avec num_recv_frames réglé aussi haut.

python /usr/lib/uhd/examples/python/benchmark_rate.py --rx_rate 56e6 --args "num_recv_frames=1000"

Réception

La réception d’échantillons à partir d’une USRP est extrêmement facile grâce à la fonction de commodité intégrée « recv_num_samps() ». Le code Python ci-dessous accorde l’USRP à 100MHz, utilise une fréquence d’échantillonnage de 1MHz et prélève 10 000 échantillons à partir de l’USRP, en utilisant un gain de réception de 50dB :

import uhd
usrp = uhd.usrp.MultiUSRP()
samples = usrp.recv_num_samps(10000, 100e6, 1e6, [0], 50) # unités: N, Hz, Hz, liste des canaux IDs, dB
print(samples[0:10])

Le [0] indique à l’USRP d’utiliser son premier port d’entrée et de ne recevoir qu’un seul canal d’échantillons (pour qu’un B210 reçoive sur deux canaux à la fois, par exemple, vous pourriez utiliser [0, 1]).

Voici une astuce si vous essayez de recevoir à un taux élevé mais que vous obtenez des débordements (des O s’affichent dans votre console). Au lieu de usrp = uhd.usrp.MultiUSRP(), utilisez :

usrp = uhd.usrp.MultiUSRP("num_recv_frames=1000")

qui rend le tampon de réception beaucoup plus grand (la valeur par défaut est de 32), ce qui permet de réduire les débordements. La taille réelle du tampon en octets dépend de l’USRP et du type de connexion, mais le simple fait de définir num_recv_frames à une valeur bien supérieure à 32 permet d’aider.

Pour des applications plus sérieuses, je recommande de ne pas utiliser la fonction recv_num_samps(), parce qu’elle cache une partie du comportement intéressant qui se passe sous le capot, et il y a une certaine configuration qui se produit à chaque appel que nous pourrions vouloir faire seulement une fois au début, par exemple, si nous voulons recevoir des échantillons indéfiniment. Le code suivant a la même fonctionnalité que recv_num_samps(), en fait c’est presque exactement ce qui est appelé lorsque vous utilisez cette fonction, mais maintenant nous avons la possibilité de modifier le comportement :

import uhd
import numpy as np

usrp = uhd.usrp.MultiUSRP()

num_samps = 10000 # nombre d'échantillons reçus
center_freq = 100e6 # Hz
sample_rate = 1e6 # Hz
gain = 50 # dB

usrp.set_rx_rate(sample_rate, 0)
usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(center_freq), 0)
usrp.set_rx_gain(gain, 0)

# Configurer le flux et le tampon de réception
st_args = uhd.usrp.StreamArgs("fc32", "sc16")
st_args.channels = [0]
metadata = uhd.types.RXMetadata()
streamer = usrp.get_rx_stream(st_args)
recv_buffer = np.zeros((1, 1000), dtype=np.complex64)

# Démarrer le flux
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont)
stream_cmd.stream_now = True
streamer.issue_stream_cmd(stream_cmd)

# Recevoir des échantillons
samples = np.zeros(num_samps, dtype=np.complex64)
for i in range(num_samps//1000):
    streamer.recv(recv_buffer, metadata)
    samples[i*1000:(i+1)*1000] = recv_buffer[0]

# Arrêter le flux
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)
streamer.issue_stream_cmd(stream_cmd)

print(len(samples))
print(samples[0:10])

Avec num_samps fixé à 10 000 et le recv_buffer fixé à 1000, la boucle for sera exécutée 10 fois, c’est-à-dire qu’il y aura 10 appels à streamer.recv. Notez que nous avons codé en dur le recv_buffer à 1000 mais vous pouvez trouver la valeur maximale autorisée en utilisant streamer.get_max_num_samps(), qui se situe souvent autour de 3000 et quelques. Notez également que recv_buffer doit être 2d car la même API est utilisée lors de la réception de plusieurs canaux à la fois, mais dans notre cas, nous n’avons reçu qu’un seul canal, donc recv_buffer[0] nous a donné le tableau 1D d’échantillons que nous voulions. Pour l’instant, vous n’avez pas besoin d’en savoir trop sur la façon dont le flux démarre/arrête, mais sachez qu’il existe d’autres options que le mode « continu », comme recevoir un nombre spécifique d’échantillons et faire en sorte que le flux s’arrête automatiquement. Bien que nous ne traitions pas les métadonnées dans cet exemple de code, elles contiennent toutes les erreurs qui se produisent, entre autres choses, que vous pouvez vérifier en regardant metadata.error_code à chaque itération de la boucle, si vous le souhaitez (les erreurs ont tendance à apparaître également dans la console elle-même, en raison de l’UHD, donc ne vous sentez pas obligé de les vérifier dans votre code Python).

Gain de réception

La liste suivante montre la gamme de gain des différents USRP, ils vont tous de 0dB au nombre spécifié ci-dessous. Notez que ce n’est pas du dBm, c’est essentiellement du dBm combiné à un décalage inconnu car ce ne sont pas des appareils calibrés.

B200/B210/B200-mini: 76 dB

X310/N210 with WBX/SBX/UBX: 31.5 dB

X310 with TwinRX: 93 dB

E310/E312: 76 dB

N320/N321: 60 dB

Vous pouvez également utiliser la commande uhd_usrp_probe dans un terminal et dans la section RX Frontend il mentionnera la gamme de gain.

Pour spécifier le gain, vous pouvez utiliser la fonction normale set_rx_gain() qui prend la valeur du gain en dB, mais vous pouvez aussi utiliser set_normalized_rx_gain() qui prend une valeur de 0 à 1 et la convertit automatiquement dans la gamme de l’USRP que vous utilisez. Ceci est pratique lorsqu’on crée une application qui supporte différents modèles d’USRP. L’inconvénient de l’utilisation du gain normalisé est que vous n’avez plus vos unités en dB, donc si vous voulez augmenter votre gain de 10dB, par exemple, vous devez maintenant calculer la quantité.

Contrôle automatique du gain

Certains USRP, y compris les séries B200 et E310, prennent en charge la commande automatique de gain (AGC pour automatic gain controller en anglais) qui ajuste automatiquement le gain de réception en fonction du niveau du signal reçu, afin d’essayer de « remplir » au mieux les bits du CAN. L’AGC peut être activé en utilisant :

usrp.set_rx_agc(True, 0) # 0 pour le canal 0, c'est-à-dire le premier canal de l'USRP

Si vous avez une USRP qui n’implémente pas d’AGC, une exception sera levée lors de l’exécution de la ligne ci-dessus. Avec l’AGC activé, le réglage du gain ne fera rien.

Arguments relatifs aux flux

Dans l’exemple complet ci-dessus, vous verrez la ligne st_args = uhd.usrp.StreamArgs("fc32", "sc16"). Le premier argument est le format de données CPU, qui est le type de données des échantillons une fois qu’ils sont sur votre ordinateur hôte. UHD supporte les types de données CPU suivants lors de l’utilisation de l’API Python :

Stream Arg | Numpy Data Type | Description
fc64 | np.complex128 | Complex-valued double-precision data
fc32 | np.complex64 | Complex-valued single-precision data

Vous pouvez voir d’autres options dans la documentation de l’API UHD C++, mais elles n’ont jamais été implémentées dans l’API Python, du moins au moment de la rédaction de ce document.

Le deuxième argument est le format de données « over-the-wire », c’est-à-dire le type de données lorsque les échantillons sont envoyés à l’hôte via USB/Ethernet/SFP. Pour l’API Python, les options sont : « sc16 », « sc12 » et « sc8 », l’option 12 bits n’étant prise en charge que par certains USRP. Ce choix est important car la connexion entre l’USRP et l’ordinateur hôte est souvent le goulot d’étranglement, donc en passant de 16 bits à 8 bits, vous pouvez obtenir un taux plus élevé. Rappelez-vous également que de nombreux USRP ont des CAN limités à 12 ou 14 bits, utiliser « sc16 » ne signifie pas que le CAN est de 16 bits.

Pour la partie canal du st_args, voir la sous-section Sous-dispositif and Channels ci-dessous.

Transmettre

Similaire à la fonction pratique recv_num_samps(), UHD fournit la fonction send_waveform() pour transmettre un lot d’échantillons, un exemple est montré ci-dessous. Si vous spécifiez une durée (en secondes) plus longue que le signal fourni, il sera simplement répété. Il est utile de garder les valeurs des échantillons entre -1.0 et 1.0.

import uhd
import numpy as np
usrp = uhd.usrp.MultiUSRP()
samples = 0.1*np.random.randn(10000) + 0.1j*np.random.randn(10000) # créer un signal aléatoire
duration = 10 # secondes
center_freq = 915e6
sample_rate = 1e6
gain = 20 # [dB] Commencez doucement puis montez en grade
usrp.send_waveform(samples, duration, center_freq, sample_rate, [0], gain)

Pour plus de détails sur la façon dont cette fonction pratique fonctionne sous le capot, voir le code source ici [https://github.com/EttusResearch/uhd/blob/master/host/python/uhd/usrp/multi_usrp.py].

Gain d’émission

Comme pour la réception, la plage de gain d’émission varie en fonction du modèle USRP, allant de 0 dB au nombre spécifié ci-dessous :

B200/B210/B200-mini: 90 dB

N210 with WBX: 25 dB

N210 with SBX or UBX: 31.5 dB

E310/E312: 90 dB

N320/N321: 60 dB

Il existe également une fonction set_normalized_tx_gain() si vous souhaitez spécifier le gain d’émission en utilisant la plage 0 à 1.

Transmettre et recevoir simultanément

Si vous voulez émettre et recevoir en utilisant la même USRP en même temps, la clé est de le faire en utilisant plusieurs threads dans le même processus ; l’USRP ne peut pas couvrir plusieurs processus. Par exemple, dans l’exemple C++ txrx_loopback_to_file [https://github.com/EttusResearch/uhd/blob/master/host/examples/txrx_loopback_to_file.cpp] un thread séparé est créé pour exécuter l’émetteur, et la réception est faite dans le thread principal. Vous pouvez aussi simplement créer deux threads, un pour l’émission et un pour la réception, comme cela est fait dans l’exemple Python benchmark_rate [https://github.com/EttusResearch/uhd/blob/master/host/examples/python/benchmark_rate.py]. Un exemple complet n’est pas montré ici, simplement parce que ce serait un exemple assez long et que le benchmark_rate.py d’Ettus peut toujours servir de point de départ à quelqu’un.

Sous-dispositif, canaux et antennes

Une source fréquente de confusion lors de l’utilisation des USRP est de savoir comment choisir le bon ID de sous-dispositif et de canal. Vous avez peut-être remarqué que dans tous les exemples ci-dessus, nous avons utilisé le canal 0, et n’avons rien spécifié concernant le subdev. Si vous utilisez un B210 et que vous voulez juste utiliser RF:B au lieu de RF:A, tout ce que vous avez à faire est de choisir le canal 1 au lieu de 0. Mais sur les USRP comme le X310 qui ont deux slots pour carte fille, vous devez dire à UHD si vous voulez utiliser le slot A ou B, et quel canal sur cette carte fille, par exemple :

usrp.set_rx_subdev_spec("B:0")

Si vous voulez utiliser le port TX/RX au lieu de RX2 (par défaut), c’est aussi simple que cela :

usrp.set_rx_antenna('TX/RX', 0) # Réglez le canal 0 sur 'TX/RX'.

qui ne fait que contrôler un commutateur RF à bord de l’USRP, pour l’acheminer depuis l’autre connecteur SMA.

Pour recevoir ou émettre sur deux canaux à la fois, au lieu d’utiliser st_args.channels = [0] vous fournissez une liste, telle que [0,1]. Le tampon de réception des échantillons devra être de taille (2, N) dans ce cas, au lieu de (1,N). Rappelez-vous qu’avec la plupart des USRP, les deux canaux partagent un LO, donc vous ne pouvez pas indiquer différentes fréquences en même temps.

Synchronisation à 10 MHz et PPS

Un des énormes avantages de l’utilisation d’une USRP par rapport à d’autres SDR est leur capacité à se synchroniser à une source externe ou au GPSDO [https://www.ettus.com/all-products/gpsdo-tcxo-module/] embarqué. Si vous avez connecté une source externe 10 MHz et PPS à votre USRP, vous voudrez vous assurer d’appeler ces deux lignes après avoir initialisé votre USRP :

usrp.set_clock_source("external")
usrp.set_time_source("external")

Si vous utilisez un GPSDO embarqué, vous utiliserez plutôt :

usrp.set_clock_source("gpsdo")
usrp.set_time_source("gpsdo")

Du côté de la synchronisation en fréquence, il n’y a pas grand chose d’autre à faire ; la LO utilisée dans le mélangeur de l’USRP va maintenant être liée à la source externe ou à GPSDO [https://www.ettus.com/all-products/gpsdo-tcxo-module/]. Mais du côté du timing, vous pouvez souhaiter commander à l’USRP de commencer à échantillonner exactement sur le PPS, par exemple. Cela peut être fait avec le code suivant :

# copier l'exemple de réception ci-dessus, tout jusqu'à # Start Stream

# Attendez que 1 PPS se produise, puis réglez le temps au prochain PPS à 0.0
time_at_last_pps = usrp.get_time_last_pps().get_real_secs()
while time_at_last_pps == usrp.get_time_last_pps().get_real_secs():
    time.sleep(0.1) # continuez à attendre jusqu'à ce que ça arrive - si cette boucle while ne se termine jamais alors le signal PPS n'est pas là.
usrp.set_time_next_pps(uhd.libpyuhd.types.time_spec(0.0))

# Planifie la réception des échantillons num_samps exactement 3 secondes après le dernier PPS.
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.num_done)
stream_cmd.num_samps = num_samps
stream_cmd.stream_now = False
stream_cmd.time_spec = uhd.libpyuhd.types.time_spec(3.0) # définir l'heure de début (essayez de modifier cela)
streamer.issue_stream_cmd(stream_cmd)

# Recevoir des échantillons. recv() retournera des zéros, puis nos échantillons, puis encore des zéros, pour nous dire que c'est terminé.
waiting_to_start = True # garder la trace de l'endroit où nous sommes dans le cycle (voir le commentaire ci-dessus)
nsamps = 0
i = 0
samples = np.zeros(num_samps, dtype=np.complex64)
while nsamps != 0 or waiting_to_start:
    nsamps = streamer.recv(recv_buffer, metadata)
    if nsamps and waiting_to_start:
        waiting_to_start = False
    elif nsamps:
        samples[i:i+nsamps] = recv_buffer[0][0:nsamps]
    i += nsamps

Si vous avez l’impression qu’il ne fonctionne pas, mais qu’il n’y a pas d’erreur, essayez de remplacer le chiffre 3.0 par un chiffre compris entre 1.0 et 5.0. Vous pouvez également vérifier les métadonnées après l’appel à recv(), en vérifiant simplement if metadata.error_code != uhd.types.RXMetadataErrorCode.none:.

Pour des raisons de débogage, vous pouvez vérifier que le signal 10MHz apparaît sur l’USRP en vérifiant le retour de usrp.get_mboard_sensor("ref_locked", 0). Si le signal PPS n’apparaît pas, vous le saurez car la première boucle while du code ci-dessus ne se terminera jamais.