From 48f2384a398c3f90b9cbbfc8afa46a655a8b5d30 Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Sun, 22 Mar 2026 22:17:04 +0100 Subject: [PATCH 1/9] Create doa.rst and pyqt.rst in the content-fr directory --- content-fr/doa.rst | 759 +++++++++++++++++++++++++++++++++++ content-fr/pyqt.rst | 955 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1714 insertions(+) create mode 100644 content-fr/doa.rst create mode 100644 content-fr/pyqt.rst diff --git a/content-fr/doa.rst b/content-fr/doa.rst new file mode 100644 index 00000000..80714c62 --- /dev/null +++ b/content-fr/doa.rst @@ -0,0 +1,759 @@ +.. _doa-chapter: + +################# +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. + + +.. image:: ../_images/doa_complex_scenario.svg + :align: center + :target: ../_images/doa_complex_scenario.svg + :alt: Diagram showing a complex scenario of multiple signals arriving at an array + +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_: + + +.. image:: ../_images/beamforming_taxonomy.svg + :align: center + :target: ../_images/beamforming_taxonomy.svg + :alt: A beamforming taxonomy, categorizing beamforming into conventional, adaptive, and blind, as well as showing how direction of arrival (DOA) estimation fits in + +****************************** +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 : + +.. image:: ../_images/beamforming_examples.svg + :align: center + :target: ../_images/beamforming_examples.svg + :alt: Exemples de réseaux à commande phase comprenant un réseau + PESA (Passive electronically scanned array), un réseau AESA + (Active electronically scanned array), un réseau hybride, + soit un Raytheon MIM-104 Patriot Radar, un Radal + Multi-Mission israélien ELM-2084 , Un terminal utilisateur Starlink Dishy + +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. + + +.. image:: ../_images/krakensdr.jpg + :align: center + :alt: The KrakenSDR + :target: ../_images/krakensdr.jpg + +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 :code:`np.matrix` étant obsolète et remplacée par :code:`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 :code:`a = np.array([2,3,4,5])` ou convertir une liste en vecteur avec :code:`mylist = [2, 3, 4, 5]` puis :code:`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 :code:`a.T`, ne le transformera pas en vecteur colonne ! Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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 :code:`3x10` par une matrice :code:`10x1`. Rappelons que +:code:`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 +:code:`3x1`. Par commodité, nous utiliserons + :code:`np.random.randn()` pour créer le tableau :code:`3x10` et :code:`np.arange()` pour créer le tableau :code:`10x1` : + +.. code-block:: python + 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 : :code:`[[ 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 :code:`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 : :code:`[ 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 :code:`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 : + +.. list-table:: + :widths: 35 25 40 + :header-rows: 1 + + * - Operation + - MATLAB + - Python/NumPy + * - Créer un vecteur ligne , taille :code:`1 x 4` + - :code:`a = [2 3 4 5];` + - :code:`a = np.array([2,3,4,5])` + * - Créer un vecteur colonne, taille :code:`4 x 1` + - :code:`a = [2; 3; 4; 5];` or :code:`a = [2 3 4 5].'` + - :code:`a = np.array([[2],[3],[4],[5]])` ou |br| :code:`a = np.array([2,3,4,5])` puis |br| :code:`a = a.reshape(-1,1)` + * - Créer une matrice 2D + - :code:`A = [1 2; 3 4; 5 6];` + - :code:`A = np.array([[1,2],[3,4],[5,6]])` + * - Obtenir la taille + - :code:`size(A)` + - :code:`A.shape` + * - Transposition c'est à dire :math:`A^T` + - :code:`A.'` + - :code:`A.T` + * - Transposition complexe conjuguée |br| également Transposition + conjuguéee |br| également Transposition hermitienne|br| :math:`A^H` + - :code:`A'` + - :code:`A.conj().T` |br| |br| (malheureusement il n'y a pas + :code:`A.H` pour ndarrays) + * - Multiplication élément par élément + - :code:`A .* B` + - :code:`A * B` or :code:`np.multiply(a,b)` + * - Multiplication matricielle + - :code:`A * B` + - :code:`A @ B` or :code:`np.matmul(A,B)` + * - PRoduit scalaire de 2 vecteurs (1D) + - :code:`dot(a,b)` + - :code:`np.dot(a,b)` (ne jamais utiliser np.dot pour la 2D) + * - Concatenatation + - :code:`[A A]` + - :code:`np.concatenate((A,A))` + +********************* +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 : + +.. image:: ../_images/doa.svg + :align: center + :target: ../_images/doa.svg + :alt: Schéma illustrant la direction d'arrivée (DOA = Direction Of + Arrival) d'un signal incident sur un réseau d'antennes + uniformément espacées, indiquant l'angle de visée (boresight) + et la distance d entre les éléments (ou ouvertures). + +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. + +.. image:: ../_images/doa_trig.svg + :align: center + :target: ../_images/doa_trig.svg + :alt: Trigonométrie associée à la direction d'arrivée (DOA) d'un réseau uniformément espacé + +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 (:math:`d`). Nous devons donc utiliser le cosinus : + +.. math:: + \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 : :math:`= d \cos(90 - +\theta)`. Une identité trigonométrique nous permet ensuite de +convertir cette expression en : :math:`= d \sin(\theta)`. Il s'agit +cependant d'une distance ; nous devons la convertir en temps, en +utilisant la vitesse de la lumière : :math:`= 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 : :math:`x(t)` ; il est émis à une fréquence porteuse : +:math:`f_c` ; le signal d'émission est donc : :math:`x(t) e^{2j \pi f_c t}`. Nous utiliserons : :math:`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 : :math:`d_m \sin(\theta) / c` secondes, comme calculé précédemment. Cela signifie que le deuxième élément reçoit : + +.. math:: + x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} + +.. math:: + \mathrm{where} \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 : + +.. math:: + x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} e^{-2j \pi f_c t} + +.. math:: + = 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 :math:`t` par :maht:`nT`, où :math:`T` est la période d'échantillonnage et :math:`n` prend simplement les valeurs 0, 1, 2, 3… En substituant ces valeurs, on obtient : :math:`x(nT - Δt) e^{-2j π f_c Δt}`. Or, :math:`nT` est tellement supérieur à `Δt` que l'on peut négliger le premier terme :math:`Δt` et obtenir : :math:`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 :math:`\Delta t` : + +.. math:: + +x[n] e^{-2j \pi f_c \Delta t} + +.. math:: + += 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 : :math:`\lambda = \frac{c}{f_c}`, ou inversement, :math:`f_c = \frac{c}{\lambda}`. En remplaçant ces valeurs, on obtient : + +.. math:: + 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, :math:`d`, comme une fraction de longueur d'onde (plutôt qu'en mètres). La valeur la plus courante de :math:`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 :math:`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, :math:`d` (sans l'indice :math:`m`) représente la distance normalisée et est égal à :math:`d = d_m / \lambda`. Cela signifie que nous pouvons simplifier l'équation ci-dessus comme suit : + +.. math:: + +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 : + +.. math:: + +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 : + +.. math:: + +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 :code:`Nr` éléments du tableau, de :math:`k = 0, 1, ... , N-1` : + +.. math:: + + +\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} + +où :math:`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é :math:`s` et :code:`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 :math:`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 : + +.. math:: + +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} + +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 :math:`2\pi\sin(\theta)` exprimé sous la forme :math:`\psi`, auquel cas le vecteur directeur serait simplement :math:`e^{jd\psi}`, qui est la forme plus générale (nous n'utiliserons cependant pas cette forme). En Python, `s` s'écrit : + +.. code-block:: python + +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 :math:`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 :math:`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 : + +.. code-block:: python + + 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 :code:`s` (voir le code ci-dessous) justifie tous les +calculs précédents. +.. code-block:: python + + 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 :code:`s` et :code:`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 +:code:`ourarray.reshape(-1,1)`. Nous effectuons ensuite la multiplication +matricielle, indiquée par le symbole :code:`@`. Nous devons également +convertir :code:`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. + +.. code-block:: python + + 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, :code:`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 :code:`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 :code:`.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. + +.. code-block:: python + + 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() + +.. image:: ../_images/doa_time_domain.svg + :align: center + :target: ../_images/doa_time_domain.svg + +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). + +.. code-block:: python + + n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) + X = X + 0.1*n # X et n sont tous les 2 de taille 3x10000 + +.. image:: ../_images/doa_time_domain_with_noise.svg + :align: center + :target: ../_images/doa_time_domain_with_noise.svg + +****************************** +Formation conventionnelle de faisceaux (conventionnal beamforming) et direction d'arrivée (DOA) +****************************** + +Nous allons maintenant traiter ces échantillons :code:`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, :math:`w`. Ces pondérations peuvent +être appliquées au signal entrant :code:`X` grâce à la simple équation +suivante : :math:`w^H X`, ou, en Python, à :code:`w.conj().T @ +X`. Dans l'exemple ci-dessus, :code:`X` est une matrice +:code:`3x10000`, mais après application des pondérations, il ne reste qu'une matrice :code:`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 :code:`w` doit être un tableau +unidimensionnel pour un réseau linéaire uniforme ; dans notre exemple +à trois éléments, :code:`w` est un tableau :code:`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 : :math:`\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 ! + +.. math:: + w_{conv} = e^{2j \pi d k \sin(\theta)} + +or in Python: + +.. code-block:: 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ù :code:`Nr` est le nombre d'éléments de notre réseau linéaire uniforme avec un espacement de :code:`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 :code:`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 :code:`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 :code:`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 :code:`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). + +.. code-block:: python + + 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() + +.. image:: ../_images/doa_conventional_beamformer.svg + :align: center + :target: ../_images/doa_conventional_beamformer.svg + +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 : + +.. code-block:: python + + 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() + +.. image:: ../_images/doa_conventional_beamformer_polar.svg + :align: center + :target: ../_images/doa_conventional_beamformer_polar.svg + :alt: Exemple de diagramme polaire de la direction d'arrivée (DOA) montrant le diagramme de rayonnement et l'ambiguïté à 180 degrés. + +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 :code:`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. + +******************* +Ambiguïté à 180 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 ! + + +.. image:: ../_images/doa_from_behind.svg + :align: center + :target: ../_images/doa_from_behind.svg + +Essayons de faire varier l'angle d'arrivée (AoA) de -90 à +90 degrés au lieu de le maintenir constant à 20 : + +.. image:: ../_images/doa_sweeping_angle_animation.gif + :scale: 100 % + :align: center + :alt: Animation de la direction d'arrivée (DOA) illustrant la + direction endfire du réseau + + +À 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). + +******************* +Diagramme de rayonnement +******************** + +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, + +.. code-block:: python + +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 : + +.. code-block:: python + +Nr = 3 +d = 0.5 +N_fft = 512 +theta_degrees = 20 # il n'y a pas de SOI, 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() + +.. image:: ../_images/doa_quiescent.svg + :align: center + :target: ../_images/doa_quiescent.svg + :alt: Diagramme de rayonnement du délai et de la somme, chaque coefficient étant visualisé dans le plan complexe + + +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). + +******************* +Largeur du faisceau du réseau +******************** + +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 à :math:`\frac{0,9 \lambda}{N_rd\cos(\theta)}` [1], ce qui, pour un espacement d'une demi-longueur d'onde, se simplifie à : + +.. math:: + + \text{HPBW} \approx \frac{1.8}{N_r\cos(\theta)} \text{ [radians]} \qquad \text{when } d = \lambda/2 + +La première largeur de faisceau nul (FNBW), la largeur du lobe principal d'un point nul à un autre, est approximativement :math:`\frac{2\lambda}{N_rd}` [1], ce qui, pour un espacement d'une demi-longueur d'onde, se simplifie en : + +.. math:: + + \text{FNBW} \approx \frac{4}{N_r} \text{ [radians]} \qquad \text{when } d = \lambda/2 + +Utilisons le code précédent, mais augmentons :code:`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 : + +.. image:: ../_images/doa_quiescent_beamwidth.svg + :align: center + :target: ../_images/doa_quiescent_beamwidth.svg + + +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 ! + +****************** +Quand d n'est pas égal à λ/2 +******************* + +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. + +.. image:: ../_images/doa_d_is_large_animation.gif + :scale: 100 % + :align: center + :alt: Animation de la direction d'arrivée (DOA) illustrant ce qui se produit lorsque la distance d est bien supérieure à la moitié de la longueur d'onde + + +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 :ref:`sampling-chapter`, 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 à : + + +.. math:: + + \text{spatial sampling rate} \geq 2 \text{ [samples/cycle]} \cdot \frac{2\pi/\lambda \text{ [radians/meter]}}{2\pi \text{ [radians/cycle]}} + + \text{spatial sampling rate} \geq 2/\lambda \text{ [samples/meter]} + +ou en terme de distance entre les éléments, :math:`d`, ce qui correspond essentiellement à des mètres par échantillon spatial : + +.. math:: + + d \leq \lambda/2 + +Tant que :math:`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. + +.. image:: ../_images/doa_d_is_small_animation.gif + :scale: 100 % + :align: center + :alt: Animation de la direction d'arrivée (DOA) montrant ce qui se passe lorsque la distance d est bien inférieure à la moitié de la longueur d'onde. + +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 : + +.. image:: ../_images/doa_d_is_small_animation2.gif + :scale: 100 % + :align: center + :alt: Animation de la direction d'arrivée (DOA) montrant ce qui se passe lorsque la distance d est bien inférieure à la moitié de la longueur d'onde et que deux signaux sont présents. + +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. + +.. + COMMENTED OUT BECAUSE IT"S NOT CLEAR WHAT THIS SECTION IS PROVIDING TO THE READER BESIDES AN ALTERNATIVE EQUATION AND TERM WHICH COULD BE PRESENTED A LOT MORE CONCISE + ********************** + Bartlett Beamformer + ********************** + + Now that we've covered the basics, we will take a quick detour into some notational and algebraic details of what we just did, to gain knowledge on how to mathematically represent sweeping beams across space in a condensed and elegant manner. The following algebriac notations renders itself well to vectorization, making it suitable for real-time processing. + + The process of sweeping beams across space to get an estimate of DOA actually has a technical name; it goes by "Bartlett Beamforming" (a.k.a. Fourier beamforming to some, but note that Fourier beamforming can also mean a different technique altogether). Let's do a quick recap of what we did earlier in order to calculate our DOA, using what we now know is called Bartlett beamforming: + + #. We picked a bunch of directions to point at (e.g., -90 to +90 degrees at some interval) + #. We calculated the beamforming weights at each direction, to point our beam in that direction + #. The outputs of the array elements were multiplied with their corresponding wieght, and all results were summed + #. We calculated the signal power at each direction, then plotted the results + #. Peaks were found, each one inferring that a signal was likely received from that direction + + We are now going to write the series of steps we just reiterated mathematically. Let the signal received by the array be represented by the steering vector :math:`\mathbf{s}`. This received signal is a function of the direction of arrival (DOA) of the signal, which we will denote as :math:`\theta`. Let the weight applied to the steering vector be represented by :math:`\mathbf{w}`. The output of the array is the dot product of the steering vector and the weight, which we will denote as :math:`\mathbf{w}^{H} \mathbf{s}`. Now, the power of the received signal can be obtained by squaring the magnitude of the output of the array. This is represented as :math:`\left| \mathbf{w}^{H} \mathbf{s} \right|^{2} = \mathbf{w}^{H} \mathbf{s} \mathbf{s}^{H} \mathbf{w} = \mathbf{w} \mathbf{R_{ss}} \mathbf{w}`, where :math:`\mathbf{R}` is the spatial covariance matrix estimate. The spatial covariance matrix measures the similarity between the samples received from the different elements of the array. We repeat for each direction we want to scan, but note that the only thing that changes between direction is \mathbf{w}. We are also free to pick the list of directions, it doesn't have to be a -90 to +90 degree sweep, and we can process them all in parallel if we wish, using the same value of :math:`\mathbf{R}` for all. This is the essence of Bartlett beamforming, i.e the beam sweep that we described using the earlier generated python code. + + .. math:: + P = \left\| \mathbf{w} \mathbf{s}\right\|^2 + + = (\mathbf{w}^H\mathbf{s})(\mathbf{w}^H\mathbf{s})^* + + = \mathbf{s}^H\mathbf{w}\mathbf{w}^H\mathbf{s} + + = \mathbf{s}^H\mathbf{R}\mathbf{s} + + This mathematical representation extends to other DOA techniques as well. + + +********************** +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 : + diff --git a/content-fr/pyqt.rst b/content-fr/pyqt.rst new file mode 100644 index 00000000..ecf48247 --- /dev/null +++ b/content-fr/pyqt.rst @@ -0,0 +1,955 @@ +.. _pyqt-chapter: + +########################## +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 `, 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 :code:`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 : + + +.. code-block:: python + + 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() # Start the event loop + +Essayez d'exécuter le code vous-même ; vous devrez probablement installer PyQt6 avec :code:`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 :code:`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 :code:`__init__` de la classe :code:`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 :code:`run`) en +continu. Nous n'utilisons pas de boucle :code:`while True`, car le +fonctionnement interne de PyQt exige que la fonction :code:`run` se termine et redémarre périodiquement. Pour ce faire, le signal +:code:`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 :code:`run` de ce même thread. Il est également + nécessaire d'initialiser le thread de worker dans le code de + :code:`MainWindow`, ce qui implique la création d'un nouveau + :code:`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 +:code:`MainWindow`, tandis que le code orienté SDR/DSP se trouve dans la +fonction :code:`run` du thread de travail. + + +.. code-block:: python + + from PyQt6.QtCore import QThread, pyqtSignal, QObject, QTimer + from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton + import time + + # Non-IHM opérations (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 +:code:`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 +:code:`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 :code:`if True` par :code:`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. + +.. raw:: html + +
+ Développez pour afficher le code + +.. code-block:: python + + 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) + + # Setup a timer to trigger the redraw by calling update_plot. + self.timer = QtCore.QTimer() + self.timer.setInterval(0) # causes the timer to start immediately + self.timer.timeout.connect(self.update_plot) # causes the timer to start itself again automatically + self.timer.start() + self.start_t = time.time() # used for benchmarking + + self.show() + + def update_plot(self): + self._plot_ref.set_ydata(np.random.randn(n_data)) + self.canvas.draw() # Trigger the canvas to update and redraw. + print('FPS:', 1/(time.time()-self.start_t)) # got ~42 FPS on an 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) + + # Setup a timer to trigger the redraw by calling update_plot. + self.timer = QtCore.QTimer() + self.timer.setInterval(0) # causes the timer to start immediately + self.timer.timeout.connect(self.update_plot) # causes the timer to start itself again automatically + self.timer.start() + self.start_t = time.time() # used for benchmarking + + self.show() + + def update_plot(self): + self.time_plot_curve.setData(np.random.randn(n_data)) + print('FPS:', 1/(time.time()-self.start_t)) # got ~42 FPS on an i9-10900K + self.start_t = time.time() + + app = QtWidgets.QApplication([]) + w = MainWindow() + app.exec() + +.. raw:: html + +
+ +Pour ce qui est d'utiliser PyQtGraph, nous l'importons avec +:code:`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 :code:`__init__` de :code:`MainWindow`). + +.. code-block:: python + + # 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) + +.. image:: ../_images/pyqtgraph_example.png + :scale: 80 % + :align: center + :alt: PyQtGraph exemple + + +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 :code:`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 :code:`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é :code:`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 :code:`QHBoxLayout`, :code:`QVBoxLayout`, :code:`QGridLayout` et :code:`QFormLayout`. :code:`QHBoxLayout` et :code:`QVBoxLayout` disposent les widgets horizontalement et verticalement, respectivement. :code:`QGridLayout` les dispose sous forme de grille, et :code:`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 :code:`__init__` de votre :code:`MainWindow` : + +.. code-block:: python + + 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 :code:`QHBoxLayout` par :code:`QVBoxLayout`, il est possible de les empiler verticalement. La fonction :code:`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. + +:code:`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 :code:`QGridLayout` : + +.. code-block:: python + + 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) + +.. image:: ../_images/qt_layouts.svg + :align: center + :target: ../_images/qt_layouts.svg + :alt: Agencements Qt illustrant des exemples de QHBoxLayout, QVBoxLayout et QGridLayout + +Pour notre analyseur de spectre, nous utiliserons :code:`QGridLayout` pour la mise en page générale, mais nous ajouterons également :code:`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 : + +.. code-block:: python + + layout = QGridLayout() + self.setLayout(layout) + inner_layout = QHBoxLayout() + layout.addLayout(inner_layout) + +******************* +:code:`QPushButton` +******************* + +The first actual widget we will cover is the :code:`QPushButton`, which is a simple button that can be clicked. We have already seen how to create a :code:`QPushButton` and connect its :code:`clicked` signal to a callback function. The :code:`QPushButton` has a few other signals, including :code:`pressed`, :code:`released`, and :code:`toggled`. The :code:`toggled` signal is emitted when the button is checked or unchecked, and is useful for creating toggle buttons. The :code:`QPushButton` also has a few properties, including :code:`text`, :code:`icon`, and :code:`checkable`. The :code:`QPushButton` also has a method called :code:`click()` which simulates a click on the button. For our SDR spectrum analyzer application we will be using buttons to trigger an auto-range for plots, using the current data to calculate the y limits. Because we have already used the :code:`QPushButton`, we won't go into more detail here, but you can find more information in the `QPushButton documentation `_. + +*************** +:code:`QSlider` +*************** + +The :code:`QSlider` is a widget that allows the user to select a value from a range of values. The :code:`QSlider` has a few properties, including :code:`minimum`, :code:`maximum`, :code:`value`, and :code:`orientation`. The :code:`QSlider` also has a few signals, including :code:`valueChanged`, :code:`sliderPressed`, and :code:`sliderReleased`. The :code:`QSlider` also has a method called :code:`setValue()` which sets the value of the slider, we will be using this a lot. The documentation page for `QSlider is here `_. + +For our spectrum analyzer application we will be using :code:`QSlider`'s to adjust the center frequency and gain of the SDR. Here is the snippet from the final application code that creates the gain slider: + +.. code-block:: python + + # Gain slider with label + gain_slider = QSlider(Qt.Orientation.Horizontal) + gain_slider.setRange(0, 73) # min and max, inclusive. interval is always 1 + gain_slider.setValue(50) # initial value + gain_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + gain_slider.setTickInterval(2) # for visual purposes only + 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) + +One very important thing to know about :code:`QSlider` is it uses integers, so by setting the range from 0 to 73 we are allowing the slider to choose integer values between those numbers (inclusive of start and end). The :code:`setTickInterval(2)` is purely a visual thing. It is for this reason that we will use kHz as the units for the frequency slider, so that we can have granularity down to the 1 kHz. + +Halfway into the code above you'll notice we create a :code:`QLabel`, which is just a text label for display purposes, but in order for it to display the current value of the slider we must create a slot (i.e., callback function) that updates the label. We connect this callback function to the :code:`sliderMoved` signal, which is automatically emitted whenever the slider is moved. We also call the callback function once to initialize the label with the current value of the slider (50 in our case). We also have to connect the :code:`sliderMoved` signal to a slot that lives within the worker thread, which will update the gain of the SDR (remember, we don't like to manage the SDR or do DSP in the main GUI thread). The callback function that defines this slot will be discussed later. + +***************** +:code:`QComboBox` +***************** + +The :code:`QComboBox` is a dropdown-style widget that allows the user to select an item from a list of items. The :code:`QComboBox` has a few properties, including :code:`currentText`, :code:`currentIndex`, and :code:`count`. The :code:`QComboBox` also has a few signals, including :code:`currentTextChanged`, :code:`currentIndexChanged`, and :code:`activated`. The :code:`QComboBox` also has a method called :code:`addItem()` which adds an item to the list, and :code:`insertItem()` which inserts an item at a specific index, although we will not be using them in our spectrum analyzer example. The documentation page for `QComboBox is here `_. + +For our spectrum analyzer application we will be using :code:`QComboBox` to select the sample rate from a list we pre-define. At the beginning of our code we define the possible sample rates using :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. Within the :code:`MainWindow`'s :code:`__init__` we create the :code:`QComboBox` as follows: + +.. code-block:: python + + # 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) # must give it the index, not string + 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) + +The only real difference between this and the slider is the :code:`addItems()` where you give it the list of strings to use as options, and :code:`setCurrentIndex()` which sets the starting value. + +**************** +Lambda Functions +**************** + +Recall in the above code where we did: + +.. code-block:: python + + 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) + +We are creating a function that has only a single line of code inside of it, then passing that function (functions are objects too!) to :code:`connect()`. To simplify things, let's rewrite this code pattern using basic Python: + +.. code-block:: python + + def my_function(x): + print(x) + y.call_that_takes_in_function_obj(my_function) + +In this situation, we have a function that only has one line of code inside of it, and we only reference that function once; when we are setting the :code:`connect` callback. In these situations we can use a lambda function, which is a way to define a function in a single line. Here is the above code rewritten using a lambda function: + +.. code-block:: python + + y.call_that_takes_in_function_obj(lambda x: print(x)) + +If you have never used a lambda function before, this might seem foreign, and you certainly don't need to use them, but it gets rid of two lines of code and makes the code more concise. The way it works is, the temporary argument name comes from after "lambda", and then everything after the colon is the code that will operate on that variable. It supports multiple arguments as well, using commas, or even no arguments by using :code:`lambda : `. As an exercise, try rewriting the :code:`update_sample_rate_label` function above using a lambda function. + +*********************** +PyQtGraph's PlotWidget +*********************** + +PyQtGraph's :code:`PlotWidget` is a PyQt widget used to produce 1D plots, similar to Matplotlib's :code:`plt.plot(x,y)`. We will be using it for the time and frequency (PSD) domain plots, although it is also good for IQ plots (which our spectrum analyzer does not contain). For those curious, PlotWidget is a subclass of PyQt's `QGraphicsView `_ which is a widget for displaying the contents of a `QGraphicsScene `_, which is a surface for managing a large number of 2D graphical items in Qt. But the important thing to know about PlotWidget is that it is simply a widget containing a single `PlotItem `_, so from a documentation perspective you're better off just referring to the PlotItem docs: ``_. A PlotItem contains a ViewBox for displaying the data we want to plot, as well as AxisItems and labels for displaying the axes and title, as you may expect. + +The simplest example of using a PlotWidget is as follows (which must be added inside of the :code:`MainWindow`'s :code:`__init__`): + +.. code-block:: python + + import pyqtgraph as pg + plotWidget = pg.plot(title="My Title") + plotWidget.plot(x, y) + +where x and y are typically numpy arrays just like with Matplotlib's :code:`plt.plot()`. However, this represents a static plot where the data never changes. For our spectrum analyzer we want to update the data inside of our worker thread, so when we initialize our plot we don't even need to pass it any data yet, we just have to set it up. Here is how we initialize the Time Domain plot in our spectrum analyzer app: + +.. code-block:: python + + # 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) + +You can see we are creating two different plots/curves, one for I and one for Q. The rest of the code should be self-explanatory. To be able to update the plot, we need to create a slot (i.e., callback function) within the :code:`MainWindow`'s :code:`__init__`: + +.. code-block:: python + + def time_plot_callback(samples): + time_plot_curve_i.setData(samples.real) + time_plot_curve_q.setData(samples.imag) + +We will connect this slot to the worker thread's signal that is emitted when new samples are available, as shown later. + +The final thing we will do in the :code:`MainWindow`'s :code:`__init__` is to add a couple buttons to the right of the plot that will trigger an auto-range of the plot. One will use the current min/max, and another will set the range to -1.1 to 1.1 (which is the ADC limits of many SDRs, plus a 10% margin). We will create an inner layout, specifically QVBoxLayout, to vertically stack these two buttons. Here is the code to add the buttons: + +.. code-block:: python + + # Time plot auto range buttons + 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) + +And what it ultimately looks like: + +.. image:: ../_images/pyqt_time_plot.png + :scale: 50 % + :align: center + :alt: PyQtGraph Time Plot + +We will use a similar pattern for the frequency domain (PSD) plot. + +********************* +PyQtGraph's ImageItem +********************* + +A spectrum analyzer is not complete without a waterfall (a.k.a. real-time spectrogram), and for that we will use PyQtGraph's ImageItem, which renders images with 1, 3 or 4 "channels". One channel just means you give it a 2D array of floats or ints, which then uses a lookup table (LUT) to apply a colormap and ultimately create the image. Alternatively, you can give it RGB (3 channels) or RGBA (4 channels). We will calculate our spectrogram as a 2D numpy array of floats, and pass it to the ImageItem directly. We will pick a colormap, and even make use of the built-in functionality for showing a graphical LUT that can display our data's value distribution and how the colormap is applied. + +The actual initialization of the waterfall plot is fairly straightforward, we use a PlotWidget as the container (so that we can still have our x and y axis displayed) and then add an ImageItem to it: + +.. code-block:: python + + # Waterfall plot + 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) + +The slot/callback associated with updating the waterfall data, which goes in :code:`MainWindow`'s :code:`__init__`, is as follows: + +.. code-block:: python + + 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 + +Where spectrogram will be a 2D numpy array of floats. In addition to setting the image data, we will calculate a min and max for the colormap, based on the mean and variance of the data, which we will use later. The last part of the GUI code for the spectrogram is creating the colorbar, which also sets the colormap used: + +.. code-block:: python + + # 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) + +The second line is important, it is what ultimately connects this colorbar to the ImageItem. This code is also where we choose the colormap, and set the starting levels (-30 dB to +20 dB in our case). Within the worker thread code you will see how the spectrogram 2D array is calculated/stored. Below is a screenshot of this part of the GUI, showing the incredible built-in functionality of the colorbar and LUT display, note that the sideways bell-shaped curve is the distribution of spectrogram values, which is very useful to see. + +.. image:: ../_images/pyqt_spectrogram.png + :scale: 50 % + :align: center + :alt: PyQtGraph Spectrogram and colorbar + +*********************** +Worker Thread +*********************** + +Recall towards the beginning of this chapter we learned how to create a separate thread, using a class we called SDRWorker with a run() function. This is where we will put all of our SDR and DSP code, with the exception of initialization of the SDR which we will do globally for now. The worker thread will also be responsible for updating the three plots, by emitting signals when new samples are available, to trigger the callback functions we have already created in :code:`MainWindow`, which ultimately updates the plots. The SDRWorker class can be split up into three sections: + +#. :code:`init()` - used to initialize any state, such as the spectrogram 2D array +#. PyQt Signals - we must define our custom signals that will be emitted +#. PyQt Slots - the callback functions that are triggered by GUI events like a slider moving +#. :code:`run()` - the main loop that runs nonstop + +*********************** +PyQt Signals +*********************** + +In the GUI code we didn't have to define any Signals, because they were built into the widgets we were using, like :code:`QSlider`s :code:`valueChanged`. Our SDRWorker class is custom, and any Signals we want to emit must be defined before we start calling run(). Here is the code for the SDRWorker class, which defines four signals we will be using, and their corresponding data types: + +.. code-block:: python + + # PyQt Signals + 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 + +The first three signals send a single object; a numpy array. The last signal does not send any object with it. You can also send multiple objects at a time, simply use commas between data types, but we don't need to do that for our application here. Anywhere within run() we can emit a signal to the GUI thread, using just one line of code, for example: + +.. code-block:: python + + self.time_plot_update.emit(samples) + +There is one last step to make all of the signals/slots connections- in the GUI code (comes at the very end of :code:`MainWindow`'s :code:`__init__`) we must connect the worker thread's signals to the GUI's slots, for example: + +.. code-block:: python + + worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback + +Remember that :code:`worker` is the instance of the SDRWorker class that we created in the GUI code. So what we are doing above is connecting the worker thread's signal called :code:`time_plot_update` to the GUI's slot called :code:`time_plot_callback` that we defined earlier. Now is a good time to go back and review the code snippets we have shown so far, and see how they all fit together, to ensure you understand how the GUI and worker thread are communicating, as it is a crucial part of PyQt programming. + +*********************** +Worker Thread Slots +*********************** + +The worker thread's slots are the callback functions that are triggered by GUI events, like the gain slider moving. They are pretty straightforward, for example, this slot updates the SDR's gain value to the new value chosen by the slider: + +.. code-block:: python + + def update_gain(self, val): + print("Updated gain to:", val, 'dB') + sdr.set_rx_gain(val) + +*********************** +Worker Thread Run() +*********************** + +The :code:`run()` function is where all the fun DSP part happens! In our application, we will start each run function by receiving a set of samples from the SDR (or simulating some samples if you don't have an SDR). + +.. code-block:: python + + # 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) + + ... + +As you can see, for the simulated example, we generate a tone with some white noise, and then truncate the samples from -1 to +1. + +Now for the DSP! We know we will need to take the FFT for the frequency domain plot and spectrogram. It turns out that we can simply use the PSD for that set of samples as one row of the spectrogram, so all we have to do is shift our spectrogram/waterfall up by a row, and add the new row to the bottom (or top, doesn't matter). For each of the plot updates, we emit the signal which contains the updated data to plot. We also signal the end of the :code:`run()` function so that the GUI thread immediately starts another call to :code:`run()` again. Overall, it's actually not much code: + +.. code-block:: python + + ... + + 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() + +Note how we don't send the entire batch of samples to the time plot, because it would be too many points to show, instead we only send the first 500 samples (configurable at the top of the script, not shown here). For the PSD plot, we use a running average of the PSD, by storing the previous PSD and adding 1% of the new PSD to it. This is a simple way to smooth out the PSD plot. Note that it doesn't matter the order you call :code:`emit()` for the signals, they could have all just as easily gone at the end of :code:`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 :code:`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 `_. + +.. image:: ../_images/pyqt_animation.gif + :scale: 100 % + :align: center + :alt: gif animé montrant le fonctionnement l'application analyseur de spectre PyQt + + +Bogues connus (pour aider à les corriger, modifiez ce fichier `edit +this +`_) +: + +#. 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 : + +.. code-block:: python + + 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) # antialiasing filter bandwidth + 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) From d1da4bf12ce9a14277380fb877b0828e1244654a Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Sun, 22 Mar 2026 22:27:03 +0100 Subject: [PATCH 2/9] Update doa.rst return to line suppress --- content-fr/doa.rst | 49 ++++++---------------------------------------- 1 file changed, 6 insertions(+), 43 deletions(-) diff --git a/content-fr/doa.rst b/content-fr/doa.rst index 80714c62..44c424fa 100644 --- a/content-fr/doa.rst +++ b/content-fr/doa.rst @@ -66,45 +66,15 @@ Un exemple concret pour chaque type est présenté ci-dessous : soit un Raytheon MIM-104 Patriot Radar, un Radal Multi-Mission israélien ELM-2084 , Un terminal utilisateur Starlink Dishy -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. +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). +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. @@ -113,9 +83,7 @@ La carte KrakenSDR et un exemple de configuration d'antenne sont présentés ci- :alt: The KrakenSDR :target: ../_images/krakensdr.jpg -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. +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. ************************************** @@ -123,13 +91,8 @@ 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 :code:`np.matrix` étant obsolète et remplacée par :code:`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 :code:`a = np.array([2,3,4,5])` ou convertir une liste en vecteur avec :code:`mylist = [2, 3, 4, 5]` puis :code:`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 :code:`a.T`, ne le transformera pas en vecteur colonne ! Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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. +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 :code:`a = np.array([2,3,4,5])` ou convertir une liste en vecteur avec :code:`mylist = [2, 3, 4, 5]` puis :code:`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 :code:`a.T`, ne le transformera pas en vecteur colonne ! Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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 From ace44633352ea9a4d7921804d68de6e5bc5df89c Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Mon, 23 Mar 2026 11:10:53 +0100 Subject: [PATCH 3/9] Update de doa.rst --- content-fr/doa.rst | 104 ++++++++++++++------------------------------- 1 file changed, 31 insertions(+), 73 deletions(-) diff --git a/content-fr/doa.rst b/content-fr/doa.rst index 44c424fa..d86f2519 100644 --- a/content-fr/doa.rst +++ b/content-fr/doa.rst @@ -46,14 +46,7 @@ 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. +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 : @@ -91,19 +84,13 @@ 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 :code:`np.matrix` étant obsolète et remplacée par :code:`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 :code:`a = np.array([2,3,4,5])` ou convertir une liste en vecteur avec :code:`mylist = [2, 3, 4, 5]` puis :code:`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 :code:`a.T`, ne le transformera pas en vecteur colonne ! Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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. +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 :code:`a = np.array([2,3,4,5])` ou convertir une liste en vecteur avec :code:`mylist = [2, 3, 4, 5]` puis :code:`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 :code:`a.T`, ne le transformera pas en vecteur colonne ! +Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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 :code:`3x10` par une matrice :code:`10x1`. Rappelons que -:code:`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 -:code:`3x1`. Par commodité, nous utiliserons - :code:`np.random.randn()` pour créer le tableau :code:`3x10` et :code:`np.arange()` pour créer le tableau :code:`10x1` : +Voici un exemple rapide de calcul matriciel en Python : multiplions une matrice :code:`3x10` par une matrice :code:`10x1`. Rappelons que :code:`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 :code:`3x1`. Par commodité, nous utiliserons :code:`np.random.randn()` pour créer le tableau :code:`3x10` et :code:`np.arange()` pour créer le tableau :code:`10x1` : .. code-block:: python A = np.random.randn(3,10) # 3x10 @@ -119,12 +106,7 @@ résultante a la même taille que les dimensions externes, soit Après avoir effectué des calculs matriciels, votre résultat pourrait ressembler à ceci : :code:`[[ 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 :code:`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 : :code:`[ 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 :code:`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. +Lors de la programmation de calculs matriciels, la meilleure vérification consiste à afficher les dimensions (avec :code:`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 : @@ -515,10 +497,7 @@ Essayons de faire varier l'angle d'arrivée (AoA) de -90 à +90 degrés au lieu À 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). +À 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). ******************* Diagramme de rayonnement @@ -588,15 +567,7 @@ La première largeur de faisceau nul (FNBW), la largeur du lobe principal d'un p \text{FNBW} \approx \frac{4}{N_r} \text{ [radians]} \qquad \text{when } d = \lambda/2 -Utilisons le code précédent, mais augmentons :code:`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 : +Utilisons le code précédent, mais augmentons :code:`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 : .. image:: ../_images/doa_quiescent_beamwidth.svg :align: center @@ -611,10 +582,7 @@ Quand d n'est pas égal à λ/2 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. +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. .. image:: ../_images/doa_d_is_large_animation.gif :scale: 100 % @@ -622,26 +590,7 @@ de toute façon l'image miroir de la partie supérieure. :alt: Animation de la direction d'arrivée (DOA) illustrant ce qui se produit lorsque la distance d est bien supérieure à la moitié de la longueur d'onde -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 :ref:`sampling-chapter`, 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 à : +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 :ref:`sampling-chapter`, 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 à : .. math:: @@ -708,15 +657,24 @@ En dessous de λ/4, il n'est plus possible de distinguer les deux trajets, et le 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 : +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 : + + + + + + + +********************** +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. + + From 2808167874b2713cecc75dcebdf0f2ea349341c8 Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Tue, 24 Mar 2026 10:55:49 +0100 Subject: [PATCH 4/9] Update of pyqt.rst --- content-fr/pyqt.rst | 243 +++++++++++++++++++------------------------- 1 file changed, 102 insertions(+), 141 deletions(-) diff --git a/content-fr/pyqt.rst b/content-fr/pyqt.rst index ecf48247..6a584093 100644 --- a/content-fr/pyqt.rst +++ b/content-fr/pyqt.rst @@ -10,28 +10,7 @@ Dans ce chapitre, nous apprenons à créer des interfaces graphiques utilisateur 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 `, 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 :code:`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 +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 `, 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 :code:`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 ! @@ -84,7 +63,7 @@ Avant d'explorer les différents widgets Qt, examinons la structure d'une applic app = QApplication([]) window = MainWindow() window.show() # les fenêtres sont cachées par défaut - app.exec() # Start the event loop + 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 :code:`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 :code:`clicked` connecté à une fonction de rappel qui affiche « beep » dans la console. @@ -95,22 +74,8 @@ 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 :code:`__init__` de la classe :code:`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 :code:`run`) en -continu. Nous n'utilisons pas de boucle :code:`while True`, car le -fonctionnement interne de PyQt exige que la fonction :code:`run` se termine et redémarre périodiquement. Pour ce faire, le signal -:code:`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 :code:`run` de ce même thread. Il est également - nécessaire d'initialiser le thread de worker dans le code de - :code:`MainWindow`, ce qui implique la création d'un nouveau - :code:`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 -:code:`MainWindow`, tandis que le code orienté SDR/DSP se trouve dans la -fonction :code:`run` du thread de travail. - +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 :code:`run`) en continu. Nous n'utilisons pas de boucle :code:`while True`, car le fonctionnement interne de PyQt exige que la fonction :code:`run` se termine et redémarre périodiquement. Pour ce faire, le signal :code:`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 :code:`run` de ce même thread. Il est également nécessaire d'initialiser le thread de worker dans le code de :code:`MainWindow`, ce qui implique la création d'un nouveau :code:`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 :code:`MainWindow`, tandis que le code orienté SDR/DSP se trouve dans la fonction :code:`run` du thread de travail. .. code-block:: python @@ -118,7 +83,7 @@ fonction :code:`run` du thread de travail. from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton import time - # Non-IHM opérations (notamment SDR) néccessitant d'être lancées dans un thread spéaré. + # opérations Non-IHM (notamment SDR) néccessitant d'être lancées dans un thread spéaré. class SDRWorker(QObject): end_of_run = pyqtSignal() @@ -164,22 +129,7 @@ Essayez d'exécuter le code ci-dessus ; vous devriez voir « Starting run()» s' Signaux et slots ************************* -Dans l'exemple précédent, nous avons utilisé le signal -:code:`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 -:code:`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. +Dans l'exemple précédent, nous avons utilisé le signal :code:`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 :code:`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. ************************* @@ -225,19 +175,19 @@ PyQtGraph est une bibliothèque basée sur PyQt et NumPy qui offre des capacité self.canvas.axes.grid(True) self.setCentralWidget(self.canvas) - # Setup a timer to trigger the redraw by calling update_plot. + # Configurez une minuterie pour déclencher le redessin en appelant update_plot self.timer = QtCore.QTimer() - self.timer.setInterval(0) # causes the timer to start immediately - self.timer.timeout.connect(self.update_plot) # causes the timer to start itself again automatically + 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() # used for benchmarking + 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() # Trigger the canvas to update and redraw. - print('FPS:', 1/(time.time()-self.start_t)) # got ~42 FPS on an i9-10900K + 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: @@ -250,18 +200,18 @@ PyQtGraph est une bibliothèque basée sur PyQt et NumPy qui offre des capacité self.time_plot_curve = self.time_plot.plot([]) self.setCentralWidget(self.time_plot) - # Setup a timer to trigger the redraw by calling update_plot. + # Configurez une minuterie pour déclencher le redessin en appelant update_plot. self.timer = QtCore.QTimer() - self.timer.setInterval(0) # causes the timer to start immediately - self.timer.timeout.connect(self.update_plot) # causes the timer to start itself again automatically + 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() # used for benchmarking + 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)) # got ~42 FPS on an i9-10900K + 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([]) @@ -272,10 +222,7 @@ PyQtGraph est une bibliothèque basée sur PyQt et NumPy qui offre des capacité -Pour ce qui est d'utiliser PyQtGraph, nous l'importons avec -:code:`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 :code:`__init__` de :code:`MainWindow`). +Pour ce qui est d'utiliser PyQtGraph, nous l'importons avec :code:`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 :code:`__init__` de :code:`MainWindow`). .. code-block:: python @@ -301,8 +248,7 @@ Dispositions Dans les exemples précédents, nous avons utilisé :code:`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 :code:`QHBoxLayout`, :code:`QVBoxLayout`, :code:`QGridLayout` et :code:`QFormLayout`. :code:`QHBoxLayout` et :code:`QVBoxLayout` disposent les widgets horizontalement et verticalement, respectivement. :code:`QGridLayout` les dispose sous forme de grille, et :code:`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 :code:`__init__` de votre :code:`MainWindow` : +Pour créer une nouvelle mise en page et y ajouter des widgets, essayez d'ajouter ce qui suit dans la méthode :code:`__init__` de votre :code:`MainWindow` : .. code-block:: python @@ -343,11 +289,19 @@ Pour notre analyseur de spectre, nous utiliserons :code:`QGridLayout` pour la mi inner_layout = QHBoxLayout() layout.addLayout(inner_layout) + ******************* :code:`QPushButton` ******************* -The first actual widget we will cover is the :code:`QPushButton`, which is a simple button that can be clicked. We have already seen how to create a :code:`QPushButton` and connect its :code:`clicked` signal to a callback function. The :code:`QPushButton` has a few other signals, including :code:`pressed`, :code:`released`, and :code:`toggled`. The :code:`toggled` signal is emitted when the button is checked or unchecked, and is useful for creating toggle buttons. The :code:`QPushButton` also has a few properties, including :code:`text`, :code:`icon`, and :code:`checkable`. The :code:`QPushButton` also has a method called :code:`click()` which simulates a click on the button. For our SDR spectrum analyzer application we will be using buttons to trigger an auto-range for plots, using the current data to calculate the y limits. Because we have already used the :code:`QPushButton`, we won't go into more detail here, but you can find more information in the `QPushButton documentation `_. +Le premier widget que nous allons aborder est le :code:`QPushButton`, un simple bouton cliquable. Nous avons déjà vu comment créer un :code:`QPushButton` et associer son signal :code:`clicked` à une fonction de rappel. Le :code:`QPushButton` possède d'autres signaux, notamment :code:`pressed`, :code:`released` et :code:`toggled`. Le signal :code:`toggled` est émis lorsque le bouton est activé ou désactivé, et est utile pour créer des boutons à bascule. Le :code:`QPushButton` possède également plusieurs propriétés, dont :code:`text`, :code:`icon` et :code:`checkable`. Enfin, le :code:`QPushButton` possède une méthode appelée :code:`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 :code:`QPushButton`, nous n'entrerons pas dans les détails ici. Vous trouverez plus d'informations dans la `documentation de QPushButton : `_. + + +*************** +:code:`QSlider` +*************** +Le :code:`QSlider` est un widget qui permet à l'utilisateur de sélectionner une valeur dans une plage de valeurs. Le :code:`QSlider` possède plusieurs propriétés, notamment :code:`minimum`, :code:`maximum`, :code:`value` et :code:`orientation`. Le composant :code:`QSlider` possède également plusieurs signaux, notamment :code:`valueChanged`, :code:`sliderPressed` et :code:`sliderReleased`. Il dispose aussi d'une méthode :code:`setValue()` qui permet de définir la valeur du curseur ; nous l'utiliserons fréquemment. La documentation de :code:`QSlider` est disponible ici : ``_. + *************** :code:`QSlider` @@ -355,59 +309,62 @@ The first actual widget we will cover is the :code:`QPushButton`, which is a sim The :code:`QSlider` is a widget that allows the user to select a value from a range of values. The :code:`QSlider` has a few properties, including :code:`minimum`, :code:`maximum`, :code:`value`, and :code:`orientation`. The :code:`QSlider` also has a few signals, including :code:`valueChanged`, :code:`sliderPressed`, and :code:`sliderReleased`. The :code:`QSlider` also has a method called :code:`setValue()` which sets the value of the slider, we will be using this a lot. The documentation page for `QSlider is here `_. -For our spectrum analyzer application we will be using :code:`QSlider`'s to adjust the center frequency and gain of the SDR. Here is the snippet from the final application code that creates the gain slider: +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 : .. code-block:: python - # Gain slider with label + # Slider de gain avec étiquette gain_slider = QSlider(Qt.Orientation.Horizontal) - gain_slider.setRange(0, 73) # min and max, inclusive. interval is always 1 - gain_slider.setValue(50) # initial value + 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) # for visual purposes only + 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()) # initialize the label + update_gain_label(gain_slider.value()) # initialisation du label layout.addWidget(gain_slider, 5, 0) layout.addWidget(gain_label, 5, 1) -One very important thing to know about :code:`QSlider` is it uses integers, so by setting the range from 0 to 73 we are allowing the slider to choose integer values between those numbers (inclusive of start and end). The :code:`setTickInterval(2)` is purely a visual thing. It is for this reason that we will use kHz as the units for the frequency slider, so that we can have granularity down to the 1 kHz. -Halfway into the code above you'll notice we create a :code:`QLabel`, which is just a text label for display purposes, but in order for it to display the current value of the slider we must create a slot (i.e., callback function) that updates the label. We connect this callback function to the :code:`sliderMoved` signal, which is automatically emitted whenever the slider is moved. We also call the callback function once to initialize the label with the current value of the slider (50 in our case). We also have to connect the :code:`sliderMoved` signal to a slot that lives within the worker thread, which will update the gain of the SDR (remember, we don't like to manage the SDR or do DSP in the main GUI thread). The callback function that defines this slot will be discussed later. +Il est très important de savoir que :code:`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 :code:`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 :code:`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 :code:`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 :code:`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. + ***************** :code:`QComboBox` ***************** +Le :code:`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 :code:`currentText`, :code:`currentIndex` et :code:`count`. Il dispose également de signaux tels que :code:`currentTextChanged`, :code:`currentIndexChanged` et :code:`activated`. Enfin, il possède une méthode :code:`addItem()` pour ajouter un élément à la liste et une méthode :code:`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 :code:`QComboBox` est disponible ici : ``_. -The :code:`QComboBox` is a dropdown-style widget that allows the user to select an item from a list of items. The :code:`QComboBox` has a few properties, including :code:`currentText`, :code:`currentIndex`, and :code:`count`. The :code:`QComboBox` also has a few signals, including :code:`currentTextChanged`, :code:`currentIndexChanged`, and :code:`activated`. The :code:`QComboBox` also has a method called :code:`addItem()` which adds an item to the list, and :code:`insertItem()` which inserts an item at a specific index, although we will not be using them in our spectrum analyzer example. The documentation page for `QComboBox is here `_. - -For our spectrum analyzer application we will be using :code:`QComboBox` to select the sample rate from a list we pre-define. At the beginning of our code we define the possible sample rates using :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. Within the :code:`MainWindow`'s :code:`__init__` we create the :code:`QComboBox` as follows: +Pour notre application d'analyseur de spectre, nous utiliserons un :code:`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 :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. Dans la méthode :code:`__init__` de la fenêtre principale, nous créons le :code:`QComboBox` comme suit : .. code-block:: python - # Sample rate dropdown using QComboBox + # 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) # must give it the index, not string + 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()) # initialize the 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) -The only real difference between this and the slider is the :code:`addItems()` where you give it the list of strings to use as options, and :code:`setCurrentIndex()` which sets the starting value. + +La seule véritable différence entre ceci et le curseur est le :code:`addItems()` où vous lui donnez la liste des chaînes à utiliser comme options, et :code:`setCurrentIndex()` qui définit la valeur de départ. + **************** -Lambda Functions +Lonctions lambda **************** -Recall in the above code where we did: +Rappelez-vous dans le code ci-dessus où nous avons fait : .. code-block:: python @@ -415,7 +372,8 @@ Recall in the above code where we did: sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz") sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label) -We are creating a function that has only a single line of code inside of it, then passing that function (functions are objects too!) to :code:`connect()`. To simplify things, let's rewrite this code pattern using basic Python: + +Nous créons une fonction ne contenant qu'une seule ligne de code, puis nous passons cette fonction (les fonctions sont aussi des objets !) à :code:`connect()`. Pour simplifier, réécrivons ce modèle de code en utilisant du Python de base : .. code-block:: python @@ -423,21 +381,23 @@ We are creating a function that has only a single line of code inside of it, the print(x) y.call_that_takes_in_function_obj(my_function) -In this situation, we have a function that only has one line of code inside of it, and we only reference that function once; when we are setting the :code:`connect` callback. In these situations we can use a lambda function, which is a way to define a function in a single line. Here is the above code rewritten using a lambda 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 :code:`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 : .. code-block:: python y.call_that_takes_in_function_obj(lambda x: print(x)) -If you have never used a lambda function before, this might seem foreign, and you certainly don't need to use them, but it gets rid of two lines of code and makes the code more concise. The way it works is, the temporary argument name comes from after "lambda", and then everything after the colon is the code that will operate on that variable. It supports multiple arguments as well, using commas, or even no arguments by using :code:`lambda : `. As an exercise, try rewriting the :code:`update_sample_rate_label` function above using a lambda function. +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 : :code:`lambda : `. À titre d'exercice, essayez de réécrire la fonction :code:`update_sample_rate_label` ci-dessus en utilisant une fonction lambda. + *********************** -PyQtGraph's PlotWidget +Le widget de tracé de PyQtGraph *********************** -PyQtGraph's :code:`PlotWidget` is a PyQt widget used to produce 1D plots, similar to Matplotlib's :code:`plt.plot(x,y)`. We will be using it for the time and frequency (PSD) domain plots, although it is also good for IQ plots (which our spectrum analyzer does not contain). For those curious, PlotWidget is a subclass of PyQt's `QGraphicsView `_ which is a widget for displaying the contents of a `QGraphicsScene `_, which is a surface for managing a large number of 2D graphical items in Qt. But the important thing to know about PlotWidget is that it is simply a widget containing a single `PlotItem `_, so from a documentation perspective you're better off just referring to the PlotItem docs: ``_. A PlotItem contains a ViewBox for displaying the data we want to plot, as well as AxisItems and labels for displaying the axes and title, as you may expect. +Le widget :code:`PlotWidget` de PyQtGraph permet de générer des graphiques 1D, à l'instar de :code:`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 `_ de PyQt, qui est un widget permettant d'afficher le contenu d'une `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 `_. Du point de vue de la documentation, il est donc préférable de se référer directement à la documentation de PlotItem : ``_. 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 :code:`__init__` de :code:`MainWindow`) : -The simplest example of using a PlotWidget is as follows (which must be added inside of the :code:`MainWindow`'s :code:`__init__`): .. code-block:: python @@ -445,7 +405,7 @@ The simplest example of using a PlotWidget is as follows (which must be added in plotWidget = pg.plot(title="My Title") plotWidget.plot(x, y) -where x and y are typically numpy arrays just like with Matplotlib's :code:`plt.plot()`. However, this represents a static plot where the data never changes. For our spectrum analyzer we want to update the data inside of our worker thread, so when we initialize our plot we don't even need to pass it any data yet, we just have to set it up. Here is how we initialize the Time Domain plot in our spectrum analyzer app: +où x et y sont généralement des tableaux NumPy, comme avec la fonction :code:`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 : .. code-block:: python @@ -457,7 +417,7 @@ where x and y are typically numpy arrays just like with Matplotlib's :code:`plt. time_plot_curve_q = time_plot.plot([]) layout.addWidget(time_plot, 1, 0) -You can see we are creating two different plots/curves, one for I and one for Q. The rest of the code should be self-explanatory. To be able to update the plot, we need to create a slot (i.e., callback function) within the :code:`MainWindow`'s :code:`__init__`: +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 :code:`__init__` de la fenêtre principale. .. code-block:: python @@ -465,49 +425,52 @@ You can see we are creating two different plots/curves, one for I and one for Q. time_plot_curve_i.setData(samples.real) time_plot_curve_q.setData(samples.imag) -We will connect this slot to the worker thread's signal that is emitted when new samples are available, as shown later. -The final thing we will do in the :code:`MainWindow`'s :code:`__init__` is to add a couple buttons to the right of the plot that will trigger an auto-range of the plot. One will use the current min/max, and another will set the range to -1.1 to 1.1 (which is the ADC limits of many SDRs, plus a 10% margin). We will create an inner layout, specifically QVBoxLayout, to vertically stack these two buttons. Here is the code to add the buttons: +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 :code:`__init__` de :code:`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 :code:`QVBoxLayout`, pour empiler verticalement ces deux boutons. Voici le code permettant d'ajouter les boutons : + .. code-block:: python - # Time plot auto range buttons + # 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 + 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) -And what it ultimately looks like: +Et voici à quoi cela ressemble au final : .. image:: ../_images/pyqt_time_plot.png :scale: 50 % :align: center - :alt: PyQtGraph Time Plot + :alt: Graphique temporel PyQtGraph + +Nous utiliserons un modèle similaire pour le graphique du domaine fréquentiel (PSD). -We will use a similar pattern for the frequency domain (PSD) plot. ********************* -PyQtGraph's ImageItem +ImageItem de PyQtGraph ********************* -A spectrum analyzer is not complete without a waterfall (a.k.a. real-time spectrogram), and for that we will use PyQtGraph's ImageItem, which renders images with 1, 3 or 4 "channels". One channel just means you give it a 2D array of floats or ints, which then uses a lookup table (LUT) to apply a colormap and ultimately create the image. Alternatively, you can give it RGB (3 channels) or RGBA (4 channels). We will calculate our spectrogram as a 2D numpy array of floats, and pass it to the ImageItem directly. We will pick a colormap, and even make use of the built-in functionality for showing a graphical LUT that can display our data's value distribution and how the colormap is applied. +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. -The actual initialization of the waterfall plot is fairly straightforward, we use a PlotWidget as the container (so that we can still have our x and y axis displayed) and then add an ImageItem to it: +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. .. code-block:: python # Waterfall plot waterfall = pg.PlotWidget(labels={'left': 'Time [s]', 'bottom': 'Frequency [MHz]'}) - imageitem = pg.ImageItem(axisOrder='col-major') # this arg is purely for performance + 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) -The slot/callback associated with updating the waterfall data, which goes in :code:`MainWindow`'s :code:`__init__`, is as follows: +Le slot/callback associé à la mise à jour des données en cascade, qui se trouve dans :code:`MainWindow`'s :code:`__init__`, est le suivant : .. code-block:: python @@ -518,68 +481,69 @@ The slot/callback associated with updating the waterfall data, which goes in :co self.spectrogram_min = mean - 2*sigma # save to window state self.spectrogram_max = mean + 2*sigma -Where spectrogram will be a 2D numpy array of floats. In addition to setting the image data, we will calculate a min and max for the colormap, based on the mean and variance of the data, which we will use later. The last part of the GUI code for the spectrogram is creating the colorbar, which also sets the colormap used: +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. .. code-block:: python # 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 + 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) -The second line is important, it is what ultimately connects this colorbar to the ImageItem. This code is also where we choose the colormap, and set the starting levels (-30 dB to +20 dB in our case). Within the worker thread code you will see how the spectrogram 2D array is calculated/stored. Below is a screenshot of this part of the GUI, showing the incredible built-in functionality of the colorbar and LUT display, note that the sideways bell-shaped curve is the distribution of spectrogram values, which is very useful to see. +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. .. image:: ../_images/pyqt_spectrogram.png :scale: 50 % :align: center - :alt: PyQtGraph Spectrogram and colorbar + :alt: Spectrogramme et colorbar PyQtGraph *********************** Worker Thread *********************** -Recall towards the beginning of this chapter we learned how to create a separate thread, using a class we called SDRWorker with a run() function. This is where we will put all of our SDR and DSP code, with the exception of initialization of the SDR which we will do globally for now. The worker thread will also be responsible for updating the three plots, by emitting signals when new samples are available, to trigger the callback functions we have already created in :code:`MainWindow`, which ultimately updates the plots. The SDRWorker class can be split up into three sections: +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 :code:`MainWindow`, qui mettent finalement à jour les graphiques. La classe SDRWorker se divise en trois sections : -#. :code:`init()` - used to initialize any state, such as the spectrogram 2D array -#. PyQt Signals - we must define our custom signals that will be emitted -#. PyQt Slots - the callback functions that are triggered by GUI events like a slider moving -#. :code:`run()` - the main loop that runs nonstop +#. :code:`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 +#. :code:`run()` - la boucle principale qui s'exécute en continu *********************** -PyQt Signals +Signaux PyQt *********************** -In the GUI code we didn't have to define any Signals, because they were built into the widgets we were using, like :code:`QSlider`s :code:`valueChanged`. Our SDRWorker class is custom, and any Signals we want to emit must be defined before we start calling run(). Here is the code for the SDRWorker class, which defines four signals we will be using, and their corresponding data types: +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 :code:`valueChanged` de :code:`QSlider`. Notre classe :code:`SDRWorker` est personnalisée, et tous les signaux que nous souhaitons émettre doivent être définis avant d'appeler :code:`run()`. Voici le code de la classe :code:`SDRWorker`, qui définit quatre signaux que nous utiliserons, ainsi que leurs types de données correspondants : .. code-block:: python - # PyQt Signals + # 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 + end_of_run = pyqtSignal() # se produit plusieurs fois par seconde -The first three signals send a single object; a numpy array. The last signal does not send any object with it. You can also send multiple objects at a time, simply use commas between data types, but we don't need to do that for our application here. Anywhere within run() we can emit a signal to the GUI thread, using just one line of code, for example: +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 :code:`run()`, nous pouvons émettre un signal vers le thread d'interface graphique en une seule ligne de code, par exemple : .. code-block:: python self.time_plot_update.emit(samples) -There is one last step to make all of the signals/slots connections- in the GUI code (comes at the very end of :code:`MainWindow`'s :code:`__init__`) we must connect the worker thread's signals to the GUI's slots, for example: +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 :code:`__init__` de :code:`MainWindow`), nous devons connecter les signaux du thread de travail aux slots de l’interface graphique, par exemple : .. code-block:: python - worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback + worker.time_plot_update.connect(time_plot_callback) # connection du signal à la fonction d'appel (callback) + +Rappelez-vous que :code:`worker` est l'instance de la classe :code:`SDRWorker` créée dans le code de l'interface graphique. Nous connectons ici le signal du thread de travail, :code:`time_plot_update`, à l'emplacement de l'interface graphique, :code:`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. -Remember that :code:`worker` is the instance of the SDRWorker class that we created in the GUI code. So what we are doing above is connecting the worker thread's signal called :code:`time_plot_update` to the GUI's slot called :code:`time_plot_callback` that we defined earlier. Now is a good time to go back and review the code snippets we have shown so far, and see how they all fit together, to ensure you understand how the GUI and worker thread are communicating, as it is a crucial part of PyQt programming. *********************** -Worker Thread Slots +Slots des Worker Threads *********************** -The worker thread's slots are the callback functions that are triggered by GUI events, like the gain slider moving. They are pretty straightforward, for example, this slot updates the SDR's gain value to the new value chosen by the slider: +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 : .. code-block:: python @@ -591,7 +555,7 @@ The worker thread's slots are the callback functions that are triggered by GUI e Worker Thread Run() *********************** -The :code:`run()` function is where all the fun DSP part happens! In our application, we will start each run function by receiving a set of samples from the SDR (or simulating some samples if you don't have an SDR). +La fonction :code:`run()` est l'endroit où se déroule toute la partie DSP intéressante ! Dans notre application, chaque fonction :code:`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). .. code-block:: python @@ -612,9 +576,9 @@ The :code:`run()` function is where all the fun DSP part happens! In our applic ... -As you can see, for the simulated example, we generate a tone with some white noise, and then truncate the samples from -1 to +1. +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. -Now for the DSP! We know we will need to take the FFT for the frequency domain plot and spectrogram. It turns out that we can simply use the PSD for that set of samples as one row of the spectrogram, so all we have to do is shift our spectrogram/waterfall up by a row, and add the new row to the bottom (or top, doesn't matter). For each of the plot updates, we emit the signal which contains the updated data to plot. We also signal the end of the :code:`run()` function so that the GUI thread immediately starts another call to :code:`run()` again. Overall, it's actually not much code: +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 :code:`run()` afin que le thread de l'interface graphique lance immédiatement un nouvel appel à :code:`run()`. Au final, le code est plutôt court. .. code-block:: python @@ -633,7 +597,8 @@ Now for the DSP! We know we will need to take the FFT for the frequency domain self.end_of_run.emit() # emit the signal to keep the loop going # end of run() -Note how we don't send the entire batch of samples to the time plot, because it would be too many points to show, instead we only send the first 500 samples (configurable at the top of the script, not shown here). For the PSD plot, we use a running average of the PSD, by storing the previous PSD and adding 1% of the new PSD to it. This is a simple way to smooth out the PSD plot. Note that it doesn't matter the order you call :code:`emit()` for the signals, they could have all just as easily gone at the end of :code:`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 :code:`emit()` pour les signaux est indifférent ; ils auraient tout aussi bien pu être tous placés à la fin de la fonction :code:`run()`. + *********************** Exemple final : Code complet @@ -641,11 +606,7 @@ 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 :code:`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 `_. +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 `_. .. image:: ../_images/pyqt_animation.gif :scale: 100 % From 6f226488b40cefec996b91bac030ba37c687a06f Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Tue, 7 Apr 2026 13:34:41 +0200 Subject: [PATCH 5/9] Correcting issues concerning math and python codes not displaying correctly --- content-fr/doa.rst | 63 ++++++++++----------------------------------- content-fr/pyqt.rst | 2 +- 2 files changed, 14 insertions(+), 51 deletions(-) diff --git a/content-fr/doa.rst b/content-fr/doa.rst index d86f2519..e1d3e717 100644 --- a/content-fr/doa.rst +++ b/content-fr/doa.rst @@ -196,7 +196,7 @@ bande de base : :math:`x(t)` ; il est émis à une fréquence porteuse : x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} .. math:: - \mathrm{where} \quad \Delta t = d_m \sin(\theta) / c + \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. @@ -224,32 +224,27 @@ x[n] e^{-2j \pi f_c \Delta t} 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 : :math:`\lambda = \frac{c}{f_c}`, ou inversement, :math:`f_c = \frac{c}{\lambda}`. En remplaçant ces valeurs, on obtient : .. math:: - x[n] e^{-2j \pi d_m \sin(\theta) / \lambda} + 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, :math:`d`, comme une fraction de longueur d'onde (plutôt qu'en mètres). La valeur la plus courante de :math:`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 :math:`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, :math:`d` (sans l'indice :math:`m`) représente la distance normalisée et est égal à :math:`d = d_m / \lambda`. Cela signifie que nous pouvons simplifier l'équation ci-dessus comme suit : .. math:: - -x[n] e^{-2j \pi d \sin(\theta)} + 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 : .. math:: - -x[n] e^{-2j \pi d k \sin(\theta)} + 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 : .. math:: - -x[n] e^{2j \pi d k \sin(\theta)} + 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 :code:`Nr` éléments du tableau, de :math:`k = 0, 1, ... , N-1` : .. math:: - - -\begin{bmatrix} + \begin{bmatrix} e^{2j \pi d (0) \sin(\theta)} \\ @@ -264,9 +259,7 @@ e^{2j \pi d (N_r - 1) \sin(\theta)} \\ où :math:`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é :math:`s` et :code:`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 :math:`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 : .. math:: - -s = - + s = \begin{bmatrix} 1 \\ @@ -301,7 +294,6 @@ 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 : .. code-block:: python - import numpy as np import matplotlib.pyplot as plt @@ -318,9 +310,9 @@ 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 :code:`s` (voir le code ci-dessous) justifie tous les -calculs précédents. -.. code-block:: python +calculs précédents. +.. code-block:: python 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.) @@ -341,7 +333,6 @@ une transposition (imaginez une rotation de 90 degrés) afin que les dimensions internes de la multiplication matricielle correspondent. .. code-block:: python - s = s.reshape(-1,1) # modifie s en vecteur colonne print(s.shape) # 3x1 tx = tx.reshape(1,-1) # modifie tx en vecteur ligne @@ -353,7 +344,6 @@ dimensions internes de la multiplication matricielle correspondent. À ce stade, :code:`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 :code:`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 :code:`.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. .. code-block:: python - 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 @@ -375,7 +365,6 @@ car un signal AWGN (Arbitrary White Gaussion Noise = Bruit blanc gaussien arbitraire) avec déphasage reste un signal AWGN). .. code-block:: python - n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) X = X + 0.1*n # X et n sont tous les 2 de taille 3x10000 @@ -387,38 +376,16 @@ 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 :code:`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, :math:`w`. Ces pondérations peuvent -être appliquées au signal entrant :code:`X` grâce à la simple équation -suivante : :math:`w^H X`, ou, en Python, à :code:`w.conj().T @ -X`. Dans l'exemple ci-dessus, :code:`X` est une matrice -:code:`3x10000`, mais après application des pondérations, il ne reste qu'une matrice :code:`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 :code:`w` doit être un tableau -unidimensionnel pour un réseau linéaire uniforme ; dans notre exemple -à trois éléments, :code:`w` est un tableau :code:`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 : :math:`\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 ! +Nous allons maintenant traiter ces échantillons :code:`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, :math:`w`. Ces pondérations peuvent être appliquées au signal entrant :code:`X` grâce à la simple équation suivante : :math:`w^H X`, ou, en Python, à :code:`w.conj().T @ X`. Dans l'exemple ci-dessus, :code:`X` est une matrice :code:`3x10000`, mais après application des pondérations, il ne reste qu'une matrice :code:`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 :code:`w` doit être un tableau unidimensionnel pour un réseau linéaire uniforme ; dans notre exemple à trois éléments, :code:`w` est un tableau :code:`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 : :math:`\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 ! .. math:: w_{conv} = e^{2j \pi d k \sin(\theta)} -or in Python: +ou in Python: .. code-block:: 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 @@ -428,7 +395,6 @@ où :code:`Nr` est le nombre d'éléments de notre réseau linéaire uniforme a Mais comment déterminer l'angle d'intérêt :code:`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 :code:`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 :code:`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). .. code-block:: python - theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 thetas différents compris entre -180 et +180 degrés results = [] @@ -459,7 +425,6 @@ Nous avons trouvé notre signal ! Vous commencez sans doute à comprendre le pri Si vous préférez visualiser les résultats de la direction d'arrivée sur un diagramme polaire, utilisez le code suivant : .. code-block:: python - 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 @@ -508,13 +473,11 @@ Les graphiques présentés jusqu'à présent correspondent aux résultats de la Rappelons que notre vecteur de pointage, que nous voyons régulièrement, .. code-block:: python - 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 : .. code-block:: python - Nr = 3 d = 0.5 N_fft = 512 diff --git a/content-fr/pyqt.rst b/content-fr/pyqt.rst index 6a584093..8f2129a9 100644 --- a/content-fr/pyqt.rst +++ b/content-fr/pyqt.rst @@ -649,7 +649,7 @@ Code complet : 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) # antialiasing filter bandwidth + 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 From 20b204be1f3b92ca973d007658bf8be530c594c6 Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Mon, 13 Apr 2026 21:04:23 +0200 Subject: [PATCH 6/9] Creating hackrf.rst for French in content-fr directory --- content-fr/doa.rst | 723 +++++++++++++++++++++++++++++++++++++----- content-fr/hackrf.rst | 280 ++++++++++++++++ content-fr/pyqt.rst | 6 - 3 files changed, 916 insertions(+), 93 deletions(-) create mode 100644 content-fr/hackrf.rst diff --git a/content-fr/doa.rst b/content-fr/doa.rst index e1d3e717..5a8d6c23 100644 --- a/content-fr/doa.rst +++ b/content-fr/doa.rst @@ -53,11 +53,7 @@ Un exemple concret pour chaque type est présenté ci-dessous : .. image:: ../_images/beamforming_examples.svg :align: center :target: ../_images/beamforming_examples.svg - :alt: Exemples de réseaux à commande phase comprenant un réseau - PESA (Passive electronically scanned array), un réseau AESA - (Active electronically scanned array), un réseau hybride, - soit un Raytheon MIM-104 Patriot Radar, un Radal - Multi-Mission israélien ELM-2084 , Un terminal utilisateur Starlink Dishy + :alt: Exemples de réseaux à commande phase comprenant un réseau PESA (Passive electronically scanned array), un réseau AESA (Active electronically scanned array), un réseau hybride, soit un Raytheon MIM-104 Patriot Radar, un Radal Multi-Mission israélien ELM-2084 , Un terminal utilisateur Starlink Dishy 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. @@ -87,12 +83,13 @@ Python présente de nombreux avantages par rapport à MATLAB : il est gratuit et 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 :code:`a = np.array([2,3,4,5])` ou convertir une liste en vecteur avec :code:`mylist = [2, 3, 4, 5]` puis :code:`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 :code:`a.T`, ne le transformera pas en vecteur colonne ! -Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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. +Pour convertir un vecteur :code:`a` en vecteur colonne, utilisez :code:`a = a.reshape(-1,1)`. Le paramètre :code:`-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 :code:`3x10` par une matrice :code:`10x1`. Rappelons que :code:`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 :code:`3x1`. Par commodité, nous utiliserons :code:`np.random.randn()` pour créer le tableau :code:`3x10` et :code:`np.arange()` pour créer le tableau :code:`10x1` : .. code-block:: python + A = np.random.randn(3,10) # 3x10 B = np.arange(10) # Tableau 1D de longueur 10 B = B.reshape(-1,1) # 10x1 @@ -209,17 +206,15 @@ Lorsque le récepteur ou le SDR effectue la conversion de fréquence pour recevo = 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 :math:`t` par :maht:`nT`, où :math:`T` est la période d'échantillonnage et :math:`n` prend simplement les valeurs 0, 1, 2, 3… En substituant ces valeurs, on obtient : :math:`x(nT - Δt) e^{-2j π f_c Δt}`. Or, :math:`nT` est tellement supérieur à `Δt` que l'on peut négliger le premier terme :math:`Δt` et obtenir : :math:`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. +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 :math:`t` par :math:`nT`, où :math:`T` est la période d'échantillonnage et :math:`n` prend simplement les valeurs 0, 1, 2, 3… En substituant ces valeurs, on obtient : :math:`x(nT - Δt) e^{-2j π f_c Δt}`. Or, :math:`nT` est tellement supérieur à `Δt` que l'on peut négliger le premier terme :math:`Δt` et obtenir : :math:`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 :math:`\Delta t` : .. math:: - -x[n] e^{-2j \pi f_c \Delta t} + x[n] e^{-2j \pi f_c \Delta t} .. math:: - -= x[n] e^{-2j \pi f_c d_m \sin(\theta) / c} + = 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 : :math:`\lambda = \frac{c}{f_c}`, ou inversement, :math:`f_c = \frac{c}{\lambda}`. En remplaçant ces valeurs, on obtient : @@ -245,42 +240,31 @@ Nous pouvons représenter cela sous forme matricielle en réarrangeant simplemen .. math:: \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} + 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} où :math:`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é :math:`s` et :code:`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 :math:`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 : .. math:: - 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} + 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} 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 :math:`2\pi\sin(\theta)` exprimé sous la forme :math:`\psi`, auquel cas le vecteur directeur serait simplement :math:`e^{jd\psi}`, qui est la forme plus générale (nous n'utiliserons cependant pas cette forme). En Python, `s` s'écrit : .. code-block:: python - -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 + + 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 :math:`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. @@ -294,6 +278,7 @@ 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 : .. code-block:: python + import numpy as np import matplotlib.pyplot as plt @@ -313,6 +298,7 @@ vecteur de direction :code:`s` (voir le code ci-dessous) justifie tous les calculs précédents. .. code-block:: python + 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.) @@ -321,29 +307,22 @@ calculs précédents. 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 :code:`s` et :code:`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 -:code:`ourarray.reshape(-1,1)`. Nous effectuons ensuite la multiplication -matricielle, indiquée par le symbole :code:`@`. Nous devons également -convertir :code:`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. +Pour appliquer le vecteur directeur, nous devons effectuer une multiplication matricielle de :code:`s` et :code:`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 :code:`ourarray.reshape(-1,1)`. Nous effectuons ensuite la multiplication matricielle, indiquée par le symbole :code:`@`. Nous devons également convertir :code:`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. .. code-block:: python + 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, :code:`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 :code:`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 :code:`.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. .. code-block:: python + 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 @@ -383,9 +362,10 @@ Nous allons commencer par l'approche de formation de faisceau « classiqu .. math:: w_{conv} = e^{2j \pi d k \sin(\theta)} -ou in Python: +ou en Python: .. code-block:: 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 @@ -395,16 +375,15 @@ où :code:`Nr` est le nombre d'éléments de notre réseau linéaire uniforme a Mais comment déterminer l'angle d'intérêt :code:`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 :code:`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 :code:`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). .. code-block:: python + 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 + X_weighted = w.conj().T @ X # application des poids. rappelez-vous X est 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 @@ -425,6 +404,7 @@ Nous avons trouvé notre signal ! Vous commencez sans doute à comprendre le pri Si vous préférez visualiser les résultats de la direction d'arrivée sur un diagramme polaire, utilisez le code suivant : .. code-block:: python + 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 @@ -473,38 +453,40 @@ Les graphiques présentés jusqu'à présent correspondent aux résultats de la Rappelons que notre vecteur de pointage, que nous voyons régulièrement, .. code-block:: python -np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) + + 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 : .. code-block:: python -Nr = 3 -d = 0.5 -N_fft = 512 -theta_degrees = 20 # il n'y a pas de SOI, 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() + + 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() .. image:: ../_images/doa_quiescent.svg :align: center @@ -522,13 +504,13 @@ Pour les plus curieux, il existe des équations permettant d'approximer la large .. math:: - \text{HPBW} \approx \frac{1.8}{N_r\cos(\theta)} \text{ [radians]} \qquad \text{when } d = \lambda/2 + \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 :math:`\frac{2\lambda}{N_rd}` [1], ce qui, pour un espacement d'une demi-longueur d'onde, se simplifie en : .. math:: - \text{FNBW} \approx \frac{4}{N_r} \text{ [radians]} \qquad \text{when } d = \lambda/2 + \text{FNBW} \approx \frac{4}{N_r} \text{ [radians]} \qquad \text{lorsque } d = \lambda/2 Utilisons le code précédent, mais augmentons :code:`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 : @@ -558,9 +540,9 @@ Comme vous pouvez le constater, outre l'ambiguïté à 180 degrés évoquée pr .. math:: - \text{spatial sampling rate} \geq 2 \text{ [samples/cycle]} \cdot \frac{2\pi/\lambda \text{ [radians/meter]}}{2\pi \text{ [radians/cycle]}} + \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{spatial sampling rate} \geq 2/\lambda \text{ [samples/meter]} + \text{fréquence d'échantillonnage spatial} \geq 2/\lambda \text{ [échantillons/metre]} ou en terme de distance entre les éléments, :math:`d`, ce qui correspond essentiellement à des mètres par échantillon spatial : @@ -622,9 +604,58 @@ 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 : +.. code-block:: python + tapering = np.random.uniform(0, 1, Nr) # atténuation aléatoire + w *= tapering + +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. +.. image:: ../_images/spatial_tapering_animation.gif + :scale: 80 % + :align: center + :alt: Atténuation spatiale utilisant des valeurs aléatoires pour ajuster l'amplitude des pondérations + +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 : + +.. code-block:: python + tapering = np.hamming(Nr) # fonction fenêtre Hamming + w *= tapering + +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 : + +.. image:: ../_images/spatial_tapering_animation2.gif + :scale: 80 % + :align: center + :alt: Pondération spatiale utilisant une fenêtre de Hamming pour ajuster l'amplitude des poids + + +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, :math:`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 : + +.. image:: ../_images/windows.svg + :align: center + :target: ../_images/windows.svg + + +****************************** +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. +.. raw:: html + +
+
+ Element     Magnitude (Gain)                  Phase +
+ + @@ -640,4 +671,522 @@ Les techniques de formation de faisceaux adaptatives se divisent en deux catégo 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 : + +.. math:: + w_{mvdr} = \frac{R^{-1} s}{s^H R^{-1} s} + +Le vecteur :math:`s` est le vecteur de direction correspondant à la direction souhaitée et a été présenté au début de ce chapitre. :math:`R` est l'estimation de la matrice de covariance spatiale basée sur nos échantillons reçus, obtenue à l'aide de :math:`R = np.cov(X)` ou calculée manuellement en multipliant :math:`X` par sa transposée conjuguée complexe, c'est-à-dire :math:`R = X X^H`. La matrice de covariance spatiale est une matrice de taille :math:`Nr` x :math:`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 :math:`R` varie au fil du temps, les poids conservent leur amplitude. + +.. raw:: html +
+ Pour ceux qui s'intéressent à la dérivation du MVDR, voir le développement suivant : + +**Sortie du beamforming** - La sortie du beamformer utilisant un vecteur de pondération :math:`\mathbf{w}` est donnée par : + +.. math:: + 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 :math:`\theta_0`. Formellement, le problème peut être exprimé comme suit : + +.. math:: + + \min_{\mathbf{w}} \, \mathbf{w}^H \mathbf{R} \mathbf{w} \quad \text{subject to} \quad \mathbf{w}^H \mathbf{s} = 1 + +où : + +* :math:`\mathbf{R} = E[\mathbf{X}\mathbf{X}^H]` est la matrice de covariance des signaux reçus +* :math:`\mathbf{s}` est le vecteur de direction vers la direction du signal souhaité :math:`\theta_0` + +**Méthode Lagrangienne** - Introduisons un multiplieur lagrangien :math:`\lambda` et construisons le lagrangien : + +.. math:: + + 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 à :math:`\mathbf{w^H}` et en annulant la dérivée, on obtient : + +.. math:: + + \frac{\partial L}{\partial \mathbf{w}^*} = 2\mathbf{R}\mathbf{w} - \lambda \mathbf{s} = 0 + + \mathbf{w} = \lambda \mathbf{s} \mathbf{{R^{-1}}} + + +Pour résoudre :math:`\lambda`, appliquons la contrainte :math:`\mathbf{w}^H \mathbf{s} = 1`: + +.. math:: + + \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}} + +.. raw:: html +
+ 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 :code:`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 :math:`\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 : + +.. code-block:: python + + # 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 : + +.. code-block:: python + + 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 : + +.. image:: ../_images/doa_capons.svg + :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 : + +.. code-block:: python + + Nr = 8 # 8 éléments + theta1 = 20 / 180 * np.pi # conversion en radians + theta2 = 25 / 180 * np.pi + theta3 = -40 / 180 * np.pi + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta1)).reshape(-1,1) # 8x1 + 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) + # Nous utiliserons 3 fréquences différentes. 1xN + tonalité1 = np.exp(2j*np.pi*0.01e6*t).reshape(1,-1) + tonalité2 = np.exp(2j*np.pi*0.02e6*t).reshape(1,-1) + tonalité3 = np.exp(2j*np.pi*0.03e6*t).reshape(1,-1) + X = s1@tone1 + s2@tone2 + 0.1 * s3@tone3 # notez que la dernière valeur représente 1/10e de la puissance + n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) + X = X + 0.05*n # 8xN + +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 : + +.. image:: ../_images/doa_capons2.svg + :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 : + +.. image:: ../_images/doa_complex_scenario.svg + :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 : + +.. math:: + + P_{mvdr} = \frac{1}{N} \sum_{n=0}^{N-1} \left| w^H_{mvdr} r_n \right|^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 : + +.. math:: + + P_{mvdr} & = E \left( \left| w^H_{mvdr} X_n \right| ^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 : + +.. code-block:: python + 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 :code:`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). + +********************* +Matrice de covariance +********************** + +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 : +:math:`\mathrm{cov}(X) = E \left[ (X - E[X])(X - E[X])^H \right]` + +for wireless signals at baseband, :math:`E[X]` is typically zero or very close to zero, so this simplifies to: + +:math:`\mathrm{cov}(X) = E[X X^H]` + +Given a limited number of IQ samples, :math:`\boldsymbol{X}`, we can estimate this covariance, which we will denote as :math:`\hat{R}`: + +.. math:: + \hat{R} = \frac{\boldsymbol{X} \boldsymbol{X}^H}{N} + = \frac{1}{N} \sum^N_{n=1} X_n X_n^H + +where :math:`N` is the number of samples (not the number of elements). In Python this looks like: + +:code:`R = (X @ X.conj().T)/X.shape[1]` + +Alternatively, we can use the built-in NumPy function: + +:code:`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: + +.. code-block:: python + + [[ 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. + +********************* +Formateur de faisceaux LCMV +********************** + +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 : + +.. math:: + w_{lcmv} = R^{-1} C [C^H R^{-1} C]^{-1} f + +où :math:`C` est une matrice comprenant les vecteurs de direction des SOI et des interférents correspondants, et :math:`f` est le vecteur de réponse souhaité. Le vecteur :math:`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 :math:`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 :math:`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 à :math:`f`. + +L'implémentation de LCMV en Python est très similaire à celle de MVDR, mais nous devons spécifier :math:`C`, composé de plusieurs vecteurs de direction potentiels, et :math:`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 :math:`f` est initialisé à :math:`[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. + +.. code-block:: python + + # 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 :code:`w` à l'aide de la méthode FFT présentée précédemment : + +.. image:: ../_images/lcmv_beam_pattern.svg + :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. + +.. raw:: html +
+ Pour le code complet, développez cette section + +.. code-block:: python + + # 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() + +.. image:: ../_images/doa_quiescent.svg + :align: center + :target: ../_images/doa_quiescent.svg + +.. raw:: html + +
+ +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é :code:`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. + +.. code-block:: python + + 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... + +.. image:: ../_images/lcmv_beam_pattern_spread.svg + :align: center + :target: ../_images/lcmv_beam_pattern_spread.svg + :alt: Exemple de diagramme de rayonnement lors de l'utilisation du formateur de faisceau LCMV avec un faisceau étalé et un point d'annulation étalé. + +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. + +****************** +Orientation du point d'annulation +******************* + +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 :code:`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 : + +.. math:: + + 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. + +****************************** +Simulation d'interférences à large bande +******************************* + +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 :code:`R` obtenue en sommant les contributions de chaque source de bruit à large bande. La matrice racine carrée :code:`A` est ensuite calculée, et l'ensemble d'échantillons :code:`X` est généré en « colorant » un bruit gaussien complexe standard avec :code:`A`. Un paramètre clé est :code:`fractional_bw`, qui correspond à la bande passante du signal de bruit divisée par sa fréquence centrale. Lorsque :code:`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 :code:`X`. + +.. code-block:: python + + 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é. + +.. image:: ../_images/doa_covariance_method_1.svg + :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. + +.. image:: ../_images/doa_covariance_method_2.svg + :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 : + +.. code-block:: python + + 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 :ref:`2d-beamforming-chapter`. + +************************ +Conclusion et références +************************* + +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 : `_. + +* 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| raw:: html +
diff --git a/content-fr/hackrf.rst b/content-fr/hackrf.rst new file mode 100644 index 00000000..7d804f09 --- /dev/null +++ b/content-fr/hackrf.rst @@ -0,0 +1,280 @@ +.. _hackrf-chapter: + +#################### +HackRF One en Python +#################### + +Le `HackRF One `_ de Great Scott Gadgets est un SDR USB 2.0 qui peut émettre ou recevoir de 1 MHz à 6 GHz et possède une fréquence d'échantillonnage de 2 à 20 MHz. Lancé en 2014, il a bénéficié de plusieurs améliorations mineures au fil des ans. C'est l'un des rares SDR économiques capables d'émettre jusqu'à 1 MHz, ce qui le rend idéal pour les applications HF (par exemple, la radioamateur) et les applications à plus haute fréquence. Sa puissance d'émission maximale de 15 dBm est également supérieure à celle de la plupart des autres SDR, pour plus de détails sur la puissance d'émissionallez allez voir `cette page `_ . Il utilise un fonctionnement half-duplex, ce qui signifie qu'il est soit en mode émission, soit en mode réception à tout moment, et il utilise un convertisseur analogique-numérique/numérique-analogique 8 bits. + +.. image:: ../_images/hackrf1.jpeg + :scale: 60 % + :align: center + :alt: HackRF One + +******************************** +HackRF Architecture +******************************** + +Le HackRF est basé sur la puce Analog Devices MAX2839, un émetteur-récepteur de 2,3 GHz à 2,7 GHz. Conçue initialement pour le WiMAX, elle est associée à une puce frontale RF MAX5864 (qui intègre essentiellement le CAN et le CNA) et à un synthétiseur/VCO large bande RFFC5072 (utilisé pour la conversion de fréquence du signal). Cela contraste avec la plupart des autres SDR économiques qui utilisent une seule puce appelée RFIC. Hormis le réglage de la fréquence générée par le RFFC5072, tous les autres paramètres que nous ajusterons, tels que l'atténuation et le filtrage analogique, seront gérés par le MAX2839. Au lieu d'utiliser un FPGA ou un système sur puce (SoC) comme de nombreux SDR, le HackRF utilise un circuit logique programmable complexe (CPLD) qui sert de simple logique d'interface, et un microcontrôleur, le LPC4320 basé sur ARM, qui gère tout le traitement numérique du signal (DSP) embarqué et l'interface USB avec l'hôte (transfert d'échantillons IQ dans les deux sens et contrôle des paramètres du SDR). Le magnifique schéma fonctionnel suivant, tiré de Great Scott Gadgets, illustre l'architecture de la dernière version du HackRF One : + +.. image:: ../_images/hackrf_block_diagram.webp + :align: center + :alt: Schéma fonctionnel du HackRF One + :target: ../_images/hackrf_block_diagram.webp + +Le HackRF One est hautement extensible et personnalisable. À l'intérieur du boîtier en plastique se trouvent quatre connecteurs (P9, P20, P22, and P28). Les détails sont `disponibles ici `_. Notez que 8 broches GPIO et 4 entrées ADC sont sur le connecteur P20, tandis que les interfaces SPI, I2C, et UART sont sur le connecteur P22. Le connecteur P28 peut être utilisé pour déclencher/synchroniser les opérations avec un autre appareil (par exemple un commutateur TR, un amplificateur externe ou un autre HackRF), via l'entrée et la sortie de déclenchement, avec un déali inférieur à une période d'échantillonnage. + +.. image:: ../_images/hackrf2.jpeg + :scale: 50 % + :align: center + :alt: Circuit imprimé du HackRF One + +L'horloge utilisée pour l'oscillateur local (LO) et le convertisseur analogique-numérique (ADC/DAC) provient soit de l'oscillateur intégré de 25 MHz, soit d'une référence externe de 10 MHz fournie via un connecteur SMA. Quelle que soit l'horloge utilisée, le HackRF génère un signal d'horloge de 10 MHz sur CLKOUT ; un signal carré standard de 3,3 V et 10 MHz conçu pour une charge à haute impédance. Le port CLKIN est conçu pour recevoir un signal carré similaire de 10 MHz et 3,3 V, et le HackRF utilisera l'horloge d'entrée au lieu du cristal interne lorsqu'un signal d'horloge est détecté (notez que la transition vers ou depuis CLKIN n'a lieu qu'au début d'une opération d'émission ou de réception). + +******************************** +Configuration matérielle et logicielle +******************************** + +Le processus d'installation du logiciel comporte deux étapes : nous installerons d'abord la bibliothèque principale HackRF de Great Scott Gadgets, puis l'API Python. + +Installation de la bibliothèque du HackRF +############################# + +Le code suivant a été testé et fonctionne sous Ubuntu 22.04 (avec le hachage de commit 17f3943 de mars 2025) : + +.. code-block:: bash + + git clone https://github.com/greatscottgadgets/hackrf.git + cd hackrf + git checkout 17f3943 + cd host + mkdir build + cd build + cmake .. + make + sudo make install + sudo ldconfig + sudo cp /usr/local/bin/hackrf* /usr/bin/. + +Après avoir installé :code:`hackrf` vous pourrez exécuter les utilitaires suivants : +* :code:`hackrf_info` - Lire les informations du périphérique HackRF, telles que le numéro de série et la version du firmware. +* :code:`hackrf_transfer` - Envoyer et recevoir des signaux via HackRF. Les fichiers d'entrée/sortie sont des échantillons en quadrature de signaux 8 bits. +* :code:`hackrf_sweep` - Analyseur de spectre en ligne de commande. +* :code:`hackrf_clock` - Lire et écrire la configuration d'entrée et de sortie d'horloge. +* :code:`hackrf_operacake` - Configurer le commutateur d'antenne Opera Cake connecté à HackRF. +* :code:`hackrf_spiflash` - Outil permettant d'écrire un nouveau firmware sur HackRF. Voir : Mise à jour du firmware. +* :code:`hackrf_debug` - Lire et écrire les registres et autres paramètres de configuration bas niveau pour le débogage. + + Si vous utilisez Ubuntu via WSL, côté Windows, vous devrez transférer le périphérique USB HackRF vers WSL. Pour cela, commencez par installer la dernière version de l'`utilitaire usbipd (fichier msi `_) (ce guide suppose que vous disposez de usbipd-win 4.0.0 ou version ultérieure), puis ouvrez PowerShell en mode administrateur et exécutez : + +.. code-block:: bash + + usbipd list + + usbipd bind --busid 1-10 + usbipd attach --wsl --busid 1-10 + +Du côté WSL, vous devriez pouvoir exécuter :code:`lsusb` et voir un nouvel élément nommé :code:`Great Scott Gadgets HackRF One`. Notez que vous pouvez ajouter l'option :code:`--auto-attach` à la commande :code:`usbipd attach` si vous souhaitez une reconnexion automatique. + +Enfin, vous devez ajouter les règles udev à l'aide de la commande suivante : + +.. code-block:: bash + + echo 'ATTR{idVendor}=="1d50", ATTR{idProduct}=="6089", SYMLINK+="hackrf-one-%k", MODE="660", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/53-hackrf.rules + sudo udevadm trigger + +Débranchez puis rebranchez votre HackRF One (et réexécutez la commande :code:`usbipd attach`). Notez que j'ai rencontré des problèmes d'autorisations avec l'étape suivante jusqu'à ce que j'utilise `WSL USB Manager `_ côté Windows, pour gérer le transfert vers WSL, qui gère apparemment aussi les règles udev. + + +Que vous soyez sous Linux natif ou WSL, vous devriez maintenant pouvoir exécuter :code:`hackrf_info` et voir quelque chose comme : + +.. code-block:: bash + + hackrf_info version: git-17f39433 + libhackrf version: git-17f39433 (0.9) + Found HackRF + Index: 0 + Serial number: 00000000000000007687865765a765 + Board ID Number: 2 (HackRF One) + Firmware Version: 2024.02.1 (API:1.08) + Part ID Number: 0xa000cb3c 0x004f4762 + Hardware Revision: r10 + Hardware appears to have been manufactured by Great Scott Gadgets. + Hardware supported by installed firmware: HackRF One + +Effectuons également un enregistrement IQ de la bande FM, d'une largeur de 10 MHz centrée sur 100 MHz, et nous enregistrerons 1 million d'échantillons : + +.. code-block:: bash + + hackrf_transfer -r out.iq -f 100000000 -s 10000000 -n 1000000 -a 0 -l 30 -g 50 + +Cet utilitaire produit un fichier binaire IQ d'échantillons int8 (2 octets par échantillon IQ), qui devrait peser 2 Mo dans notre cas. Si vous êtes curieux, vous pouvez lire l'enregistrement du signal en Python à l'aide du code suivant : + +.. code-block:: python + + import numpy as np + samples = np.fromfile('out.iq', dtype=np.int8) + samples = samples[::2] + 1j * samples[1::2] + print(len(samples)) + print(samples[0:10]) + print(np.max(samples)) + +Si votre valeur maximale est de 127 (ce qui signifie que vous avez saturé le CAN), alors abaissez les deux valeurs de gain à la fin de la commande. + + +Installation de l'API Python +######################### + +Enfin, nous devons installer les `bindings Python HackRF One `_, maintenues par `GvozdevLeonid `_. Elles ont été testées et fonctionnent correctement sous Ubuntu 22.04 le 11/04/2024 avec la dernière version de la branche principale. + +.. code-block:: bash + + sudo apt install libusb-1.0-0-dev + pip install python_hackrf==1.2.7 + +Nous pouvons tester l'installation ci-dessus en exécutant le code suivant. S'il n'y a pas d'erreurs (il n'y aura donc aucune sortie), tout devrait fonctionner correctement ! + +.. code-block:: python + + from python_hackrf import pyhackrf # type: ignore + pyhackrf.pyhackrf_init() + sdr = pyhackrf.pyhackrf_open() + sdr.pyhackrf_set_sample_rate(10e6) + sdr.pyhackrf_set_antenna_enable(False) + sdr.pyhackrf_set_freq(100e6) + sdr.pyhackrf_set_amp_enable(False) + sdr.pyhackrf_set_lna_gain(30) # LNA gain - 0 dB à 40 dB par pas de 8 dB + sdr.pyhackrf_set_vga_gain(50) # VGA gain - 0 dB à 62 dB par pas de 2 dB + sdr.pyhackrf_close() + +Pour un test concret de réception d'échantillons, consultez l'exemple de code ci-dessous. + +******************************** +Gain Tx et Rx +******************************** + +Côté réception +############ + +Le HackRF One possède côté réception, 3 étages de gain différents : + +* RF (:code:`amp`, soit 0 dB soit 11 dB) +* IF (:code:`lna`, de 0 dB à 40 dB par pas de 8 dB) +* baseband (:code:`vga`, de 0 dB à 62 dB par pas de 2 dB) + +Pour la réception de la plupart des signaux, il est recommandé de désactiver l’amplificateur RF (0 dB), sauf si le signal est extrêmement faible et qu’aucun signal fort n’est présent à proximité. Le gain FI (LNA) est l’étage de gain le plus important à régler pour optimiser le rapport signal/bruit tout en évitant la saturation du CAN ; c’est le premier bouton à ajuster. Le gain de bande de base peut être laissé à une valeur relativement élevée, par exemple, nous le laisserons à 50 dB. + +Côté transmission +############# + +Côté émission, on trouve deux étages de gain : + +* RF [soit 0 dB soit 11 dB] +* IF [de 0 dB à 47 dB par pas de 1 dB] + +Vous souhaiterez probablement activer l'amplificateur RF, puis vous pourrez ajuster le gain IF en fonction de vos besoins. + +************************************************** +Réception d'échantillons IQ en Python avec le HackRF +************************************************** + +Actuellement, le package Python :code:`python_hackrf` ne comprend aucune fonction pratique pour la réception d'échantillons. Il s'agit simplement d'un ensemble de liaisons Python qui correspondent à l'API C++ du HackRF. Pour recevoir facilement des données IQ, nous devons utiliser une quantité de code non négligeable. Le package Python est configuré pour utiliser une fonction de rappel afin de recevoir davantage d'échantillons. Cette fonction doit être initialisée, mais elle sera automatiquement appelée dès que de nouveaux échantillons seront disponibles en provenance du HackRF. +Cette fonction de rappel doit toujours prendre trois arguments spécifiques et doit renvoyer :code:`0` si nous souhaitons recevoir un autre ensemble d'échantillons. Dans le code ci-dessous, à chaque appel de notre fonction de rappel, nous convertissons les échantillons au type complexe de NumPy, les mettons à l'échelle de -1 à +1, puis les stockons, dans un tableau :code:`samples` plus grand. + +Après l'exécution du code ci-dessous, si sur votre graphique temporel, les échantillons atteignent les limites de l'ADC (-1 et +1), réduisez alors :code:`lna_gain` de 3 dB jusqu'à ce que les limites ne soient clairement plus atteintes. + +.. code-block:: python + + from python_hackrf import pyhackrf # type: ignore + import matplotlib.pyplot as plt + import numpy as np + import time + + # These settings should match the hackrf_transfer example used in the textbook, and the resulting waterfall should look about the same + recording_time = 1 # seconds + center_freq = 100e6 # Hz + sample_rate = 10e6 + baseband_filter = 7.5e6 + lna_gain = 30 # 0 to 40 dB in 8 dB steps + vga_gain = 50 # 0 to 62 dB in 2 dB steps + + pyhackrf.pyhackrf_init() + sdr = pyhackrf.pyhackrf_open() + + allowed_baseband_filter = pyhackrf.pyhackrf_compute_baseband_filter_bw_round_down_lt(baseband_filter) # calculate the supported bandwidth relative to the desired one + + sdr.pyhackrf_set_sample_rate(sample_rate) + sdr.pyhackrf_set_baseband_filter_bandwidth(allowed_baseband_filter) + sdr.pyhackrf_set_antenna_enable(False) # It seems this setting enables or disables power supply to the antenna port. False by default. the firmware auto-disables this after returning to IDLE mode + + sdr.pyhackrf_set_freq(center_freq) + sdr.pyhackrf_set_amp_enable(False) # False by default + sdr.pyhackrf_set_lna_gain(lna_gain) # LNA gain - 0 to 40 dB in 8 dB steps + sdr.pyhackrf_set_vga_gain(vga_gain) # VGA gain - 0 to 62 dB in 2 dB steps + + print(f'center_freq: {center_freq} sample_rate: {sample_rate} baseband_filter: {allowed_baseband_filter}') + + num_samples = int(recording_time * sample_rate) + samples = np.zeros(num_samples, dtype=np.complex64) + last_idx = 0 + + def rx_callback(device, buffer, buffer_length, valid_length): # this callback function always needs to have these four args + global samples, last_idx + + accepted = valid_length // 2 + accepted_samples = buffer[:valid_length].astype(np.int8) # -128 to 127 + accepted_samples = accepted_samples[0::2] + 1j * accepted_samples[1::2] # Convert to complex type (de-interleave the IQ) + accepted_samples /= 128 # -1 to +1 + samples[last_idx: last_idx + accepted] = accepted_samples + + last_idx += accepted + + return 0 + + sdr.set_rx_callback(rx_callback) + sdr.pyhackrf_start_rx() + print('is_streaming', sdr.pyhackrf_is_streaming()) + + time.sleep(recording_time) + + sdr.pyhackrf_stop_rx() + sdr.pyhackrf_close() + pyhackrf.pyhackrf_exit() + + samples = samples[100000:] # get rid of the first 100k samples just to be safe, due to transients + + fft_size = 2048 + num_rows = len(samples) // 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(samples[i * fft_size:(i+1) * fft_size]))) ** 2) + extent = [(center_freq + sample_rate / -2) / 1e6, (center_freq + sample_rate / 2) / 1e6, len(samples) / sample_rate, 0] + + plt.figure(0) + plt.imshow(spectrogram, aspect='auto', extent=extent) # type: ignore + plt.xlabel("Frequency [MHz]") + plt.ylabel("Time [s]") + + plt.figure(1) + plt.plot(np.real(samples[0:10000])) + plt.plot(np.imag(samples[0:10000])) + plt.xlabel("Samples") + plt.ylabel("Amplitude") + plt.legend(["Real", "Imaginary"]) + + plt.show() + + +Lorsque vous utilisez une antenne capable de recevoir la bande FM, vous devriez obtenir un résultat similaire à celui-ci, avec plusieurs stations FM visibles sur le graphique en cascade : + +.. image:: ../_images/hackrf_time_screenshot.png + :align: center + :scale: 50 % + :alt: Graphique temporel des échantillons prélevés sur HackRF + + +.. image:: ../_images/hackrf_freq_screenshot.png + :align: center + :scale: 50 % + :alt: Spectrogramme (frequence en fonction du temps) des échantillons extraits du HackRF + diff --git a/content-fr/pyqt.rst b/content-fr/pyqt.rst index 8f2129a9..22cfb225 100644 --- a/content-fr/pyqt.rst +++ b/content-fr/pyqt.rst @@ -303,12 +303,6 @@ Le premier widget que nous allons aborder est le :code:`QPushButton`, un simple Le :code:`QSlider` est un widget qui permet à l'utilisateur de sélectionner une valeur dans une plage de valeurs. Le :code:`QSlider` possède plusieurs propriétés, notamment :code:`minimum`, :code:`maximum`, :code:`value` et :code:`orientation`. Le composant :code:`QSlider` possède également plusieurs signaux, notamment :code:`valueChanged`, :code:`sliderPressed` et :code:`sliderReleased`. Il dispose aussi d'une méthode :code:`setValue()` qui permet de définir la valeur du curseur ; nous l'utiliserons fréquemment. La documentation de :code:`QSlider` est disponible ici : ``_. -*************** -:code:`QSlider` -*************** - -The :code:`QSlider` is a widget that allows the user to select a value from a range of values. The :code:`QSlider` has a few properties, including :code:`minimum`, :code:`maximum`, :code:`value`, and :code:`orientation`. The :code:`QSlider` also has a few signals, including :code:`valueChanged`, :code:`sliderPressed`, and :code:`sliderReleased`. The :code:`QSlider` also has a method called :code:`setValue()` which sets the value of the slider, we will be using this a lot. The documentation page for `QSlider is here `_. - 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 : .. code-block:: python From 9db8183893636fe74ab60cc0d01d48937d906a1d Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Tue, 14 Apr 2026 22:01:20 +0200 Subject: [PATCH 7/9] Creation of hackrf.rst --- content-fr/doa.rst | 328 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 313 insertions(+), 15 deletions(-) diff --git a/content-fr/doa.rst b/content-fr/doa.rst index 5a8d6c23..63a61ecb 100644 --- a/content-fr/doa.rst +++ b/content-fr/doa.rst @@ -55,7 +55,7 @@ Un exemple concret pour chaque type est présenté ci-dessous : :target: ../_images/beamforming_examples.svg :alt: Exemples de réseaux à commande phase comprenant un réseau PESA (Passive electronically scanned array), un réseau AESA (Active electronically scanned array), un réseau hybride, soit un Raytheon MIM-104 Patriot Radar, un Radal Multi-Mission israélien ELM-2084 , Un terminal utilisateur Starlink Dishy -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. +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 (Uniform Circular Array). 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. ******************* @@ -824,21 +824,23 @@ Si l'on remplace la sommation par l'opérateur d'espérance et que l'on substitu 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 : .. code-block:: python - 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() + + 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 :code:`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). -********************* + +********************** Matrice de covariance ********************** - + 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 : @@ -875,7 +877,8 @@ Remarquez que les éléments diagonaux sont réels et sensiblement identiques. E 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. -********************* + +********************** Formateur de faisceaux LCMV ********************** @@ -1019,7 +1022,7 @@ Il existe un cas d'utilisation particulier de LCMV auquel vous avez peut-être d 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. -****************** +******************* Orientation du point d'annulation ******************* @@ -1029,75 +1032,370 @@ Les poids pour la suppression des zéros sont calculés en partant d'un formateu .. math:: - w_{\text{new}} = w_{\text{orig}} - \frac{w_{\text{null}}^H w_{\text{orig}}}{w_{\text{null}}^H w_{\text{null}}} w_{\text{null}} + w_{\text{new}} = w_{\text{orig}} - \frac{w_{\text{null}}^H w_{\text{orig}}}{w_{\text{null}}^H w_{\text{null}}} w_{\text{null}} + +où :math:`w_{\text{null}}` représente le vecteur de direction dans la direction du point nul que l'on souhaite ajouter à :math:`w_{\text{orig}}`. Les pondérations sont mises à jour en soustrayant le vecteur de direction du point nul, mis à l'échelle, des pondérations actuelles. Le facteur d'échelle est calculé en projetant les pondérations actuelles sur le vecteur de direction du point nul, puis en divisant par la projection de ce vecteur sur lui-même. Cette opération est ensuite répétée pour chaque direction de point nul (:math:`w_{\text{orig}}` correspond initialement aux pondérations de formation de faisceau conventionnelles, mais est mis à jour après l'ajout de chaque point nul). Le processus complet se présente comme suit : +.. math:: + + & \text{1:} \qquad w_{\text{orig}} = e^{2j \pi d k \sin(\theta_{SOI})} \qquad + + & \text{2:} \qquad w_{\text{null}} = e^{2j \pi d k \sin(\theta_{null})} \qquad + & \text{3:} \qquad w_{\text{new}} = w_{\text{orig}} - \frac{w_{\text{null}}^H w_{\text{orig}}}{w_{\text{null}}^H w_{\text{null}}} w_{\text{null}} + + & \text{4:} \qquad w_{\text{orig}} = w_{\text{new}} \qquad \qquad \qquad + + & \text{5:} \qquad \text{Aller à 2: pour ajouter le prochain élément nul} +Simulons un tableau de 8 éléments et insérons quatre éléments nuls : +.. code-block:: python + + d = 0.5 + Nr = 8 + theta_soi = 30 / 180 * np.pi # convert to radians + nulls_deg = [-60, -30, 0, 60] # degrees + nulls_rad = np.asarray(nulls_deg) / 180 * np.pi + # Start out with conventional beamformer pointed at theta_soi + w = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_soi)).reshape(-1,1) + # Loop through nulls + for null_rad in nulls_rad: + # weights equal to steering vector in target null direction + w_null = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(null_rad)).reshape(-1,1) + # scaling_factor (complex scalar) for w at nulled direction + scaling_factor = w_null.conj().T @ w / (w_null.conj().T @ w_null) + print("scaling_factor:", scaling_factor, scaling_factor.shape) + # Update weights to include the null + w = w - w_null @ scaling_factor # sidelobe-canceler equation + # Plot beam pattern + N_fft = 1024 + w_padded = np.concatenate((w.squeeze(), np.zeros(N_fft - Nr))) # zero pad to N_fft elements to get more resolution in the FFT + w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # magnitude of fft in dB + w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak + theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # Map the FFT bins to angles in radians + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.plot(theta_bins, w_fft_dB) + # Add dots where nulls and SOI are + for null_rad in nulls_rad: + ax.plot([null_rad], [0], 'or') + ax.plot([theta_soi], [0], 'og') + ax.set_theta_zero_location('N') # make 0 degrees point up + ax.set_theta_direction(-1) # increase clockwise + ax.set_thetagrids(np.arange(-90, 105, 15)) # it's in degrees + ax.set_rlabel_position(55) # Move grid labels away from other labels + ax.set_thetamin(-90) # only show top half + ax.set_thetamax(90) + ax.set_ylim([-40, 1]) # because there's no noise, only go down -40 dB + plt.show() +On obtient le diagramme de rayonnement suivant. Vous remarquerez peut-être des zones sans interférence à des endroits non spécifiés ; c’est normal et dû au nombre limité d’éléments. Avec un nombre d’éléments insuffisant, il se peut également que les zones sans interférence ou le faisceau ne soient pas positionnés exactement comme prévu, ou que le diagramme ne réponde pas du tout aux critères en raison d’un manque de degrés de liberté (nombre d’éléments moins 1). +.. image:: ../_images/null_steering.svg + :align: center + :target: ../_images/null_steering.svg + :alt: Example of null steering beamforming +******************* +MUSIC +******************* +Nous allons maintenant aborder un autre type de formateur de faisceau. Tous les précédents appartenaient à la catégorie « retard et sommation », mais nous allons maintenant explorer les méthodes de « sous-espace ». Celles-ci consistent à diviser le sous-espace du signal et le sous-espace du bruit, ce qui implique d'estimer le nombre de signaux reçus par le réseau pour obtenir un bon résultat. La classification multiple de signaux (MUSIC) est une méthode de sous-espace très répandue qui consiste à calculer les vecteurs propres de la matrice de covariance (une opération gourmande en ressources de calcul). Nous divisons les vecteurs propres en deux groupes : le sous-espace du signal et le sous-espace du bruit, puis nous projetons les vecteurs de direction dans le sous-espace du bruit et nous orientons le faisceau vers les zéros. Cela peut paraître complexe au premier abord, ce qui explique en partie pourquoi MUSIC semble parfois relever de la magie noire ! +L'équation fondamentale de MUSIC est la suivante : +.. math:: + \hat{\theta} = \mathrm{argmax}\left(\frac{1}{s^H V_n V^H_n s}\right) +où :math:`V_n` est la liste des vecteurs propres du sous-espace de bruit mentionnée précédemment (une matrice 2D). On la détermine en calculant d'abord les vecteurs propres de :math:`R`, ce qui se fait simplement avec :code:`w, v = np.linalg.eig(R)` en Python, puis en divisant les vecteurs (:code:`w`) en fonction du nombre de signaux que l'on estime reçus par le réseau. Il existe une astuce pour estimer ce nombre de signaux, que nous aborderons plus loin, mais il doit être compris entre 1 et :code:`Nr - 1`. Autrement dit, lors de la conception d'un réseau, le nombre d'éléments doit être supérieur de 1 au nombre de signaux attendus. Il est important de noter que, dans l'équation ci-dessus, V_n ne dépend pas du vecteur de direction s ; nous pouvons donc le précalculer avant de parcourir l'angle θ. Voici le code MUSIC complet : +.. code-block:: python + num_expected_signals = 3 # Try changing this! + + # part that doesn't change with theta_i + R = np.cov(X) # Calcul de la matrice de covariance gives a Nr x Nr covariance matrix + w, v = np.linalg.eig(R) # Décomposition en valeurs propres, v[:,i] est le vecteur propre correspondant à la valeur propre w[i] + eig_val_order = np.argsort(np.abs(w)) # find order of magnitude of eigenvalues + v = v[:, eig_val_order] # sort eigenvectors using this order + # We make a new eigenvector matrix representing the "noise subspace", it's just the rest of the eigenvalues + V = np.zeros((Nr, Nr - num_expected_signals), dtype=np.complex64) + for i in range(Nr - num_expected_signals): + V[:, i] = v[:, i] + + theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # -180 to +180 degrees + results = [] + for theta_i in theta_scan: + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_i)) # Steering Vector + s = s.reshape(-1,1) + metric = 1 / (s.conj().T @ V @ V.conj().T @ s) # The main MUSIC equation + metric = np.abs(metric.squeeze()) # take magnitude + metric = 10*np.log10(metric) # convert to dB + results.append(metric) + + results /= np.max(results) # normalize +En appliquant cet algorithme au scénario complexe que nous avons utilisé, nous obtenons les résultats très précis suivants, qui démontrent la puissance de MUSIC : +.. image:: ../_images/doa_music.svg + :align: center + :target: ../_images/doa_music.svg + :alt: Exemple de direction d'arrivée (DOA) avec l'algorithme de formation de faisceaux MUSIC +Et si l'on ignorait le nombre de signaux présents ? Il existe une astuce : trier les amplitudes des valeurs propres par ordre décroissant et les représenter graphiquement (en dB, cela peut être utile). +.. code-block:: python + plot(10*np.log10(np.abs(w)),'.-') +.. image:: ../_images/doa_eigenvalues.svg + :align: center + :target: ../_images/doa_eigenvalues.svg +Les valeurs propres associées au sous-espace de bruit seront les plus petites et tendront toutes vers la même valeur. On peut donc considérer ces faibles valeurs comme un « plancher de bruit », et toute valeur propre supérieure à ce plancher représente un signal. Ici, on observe clairement la réception de trois signaux, et l'algorithme MUSIC doit être ajusté en conséquence. Si le nombre d'échantillons IQ à traiter est faible ou si le rapport signal/bruit (SNR) des signaux est faible, leur nombre peut être moins évident. N'hésitez pas à expérimenter en ajustant :code:`num_expected_signals` entre 1 et 7. Vous constaterez qu'une sous-estimation entraînera la perte de signaux, tandis qu'une surestimation n'aura qu'un impact mineur sur les performances. +Une autre expérience intéressante à tenter avec MUSIC consiste à déterminer la distance angulaire minimale à laquelle deux signaux peuvent arriver tout en conservant leur distinction ; les techniques de sous-espace sont particulièrement performantes dans ce cas. L'animation ci-dessous illustre un exemple, avec un signal à 18 degrés et un autre dont l'angle d'arrivée varie lentement. +.. image:: ../_images/doa_music_animation.gif + :scale: 100 % + :align: center +*** +LMS +*** +Le formateur de faisceau LMS (Least Mean Squares) est un formateur de faisceau à faible complexité introduit par Bernard Widrow. Il se distingue des autres formateurs de faisceau présentés jusqu'ici par deux aspects : 1) il requiert la connaissance du signal d'intérêt (SOI), ou au moins d'une partie de celui-ci (par exemple, une séquence de synchronisation, des signaux pilotes, etc.) ; 2) il est itératif, ce qui signifie que les pondérations sont affinées au fil d'un certain nombre d'itérations. Son fonctionnement repose sur la minimisation de l'erreur quadratique moyenne entre le signal désiré (le SOI) et la sortie du formateur de faisceau (c'est-à-dire les pondérations appliquées aux échantillons reçus). L'implémentation classique du LMS consiste à traiter chaque échantillon reçu comme une nouvelle étape du processus itératif, en appliquant les pondérations actuelles à cet échantillon et en calculant l'erreur. Cette erreur sert ensuite à affiner les pondérations, et le processus se répète. Le formateur de faisceau LMS peut être utilisé aussi bien pour la formation de faisceaux analogiques que numériques. L'algorithme LMS est défini par l'équation suivante : +.. math:: + w_{n+1} = w_n + \mu \underbrace{\left(y_n - w_{n}^H x_n\right)^*}_{erreur} x_n + +où :math:`w_n` représente le vecteur de poids à l'itération/échantillon :math:`n`, :math:`\mu` est le pas d'intégration, :math:`x_n` est l'échantillon reçu à :math:`n`, :math:`y_n` est la valeur attendue à cette itération (c'est-à-dire le SOI connu), et est le conjugué complexe. Ne vous laissez pas impressionner par :math:`w_{n}^H x_n`, il s'agit simplement de l'application des poids actuels au signal d'entrée, ce qui correspond à l'équation standard de formation de faisceau. Le pas d'intégration :math:`\mu` contrôle la vitesse de convergence des poids vers leurs valeurs optimales. Une petite valeur :math:`\mu` de ce pas entraînera une convergence lente (par exemple, vous risquez de ne pas atteindre les poids optimaux avant la disparition du signal connu), tandis qu'une grande valeur peut engendrer une instabilité de l'algorithme. L'algorithme LMS est un outil puissant pour la formation de faisceaux adaptative, mais il présente certaines limitations. Il nécessite un SOI connu, qui n'est pas toujours disponible en pratique, et une synchronisation temporelle et fréquentielle est nécessaire dans le cadre du processus LMS afin que le modèle du SOI soit aligné avec les échantillons reçus. +Dans l'exemple de code Python ci-dessous, nous simulons un réseau à 8 éléments avec un signal d'intérêt (SOI) composé d'un code Gold répétitif transmis en BPSK. Les codes Gold sont utilisés en 5G et GPS et possèdent d'excellentes propriétés de corrélation croisée, ce qui les rend idéaux pour les signaux de synchronisation. La simulation inclut également deux sources d'interférence tonale, à 60° et -50°. Notez que cette simulation ne prend pas en compte les décalages temporels ou fréquentiels ; si tel était le cas, une synchronisation au SOI serait nécessaire dans le cadre du processus LMS (c'est-à-dire une formation de faisceau conjointe avec synchronisation). L'animation suivante illustre le balayage de l'angle d'arrivée du SOI et la représentation du diagramme de rayonnement généré par LMS après 10 000 échantillons. Observez comment LMS maintient le gain vers le SOI à 0 dB (sauf en présence d'une source d'interférence), tout en créant des zéros au niveau des sources d'interférence. +.. image:: ../_images/doa_lms_animation.gif + :scale: 100 % + :align: center +.. code-block:: python + # Scénario + sample_rate = 1e6 + d = 0.5 # espacement d'une demi longueur d'onde + N = 100000 # nombre d'échantillons à simuler + Nr = 8 # éléments + theta_soi = 20 / 180 * np.pi # conversion en radians + theta2 = 60 / 180 * np.pi + theta3 = -50 / 180 * np.pi + t = np.arange(N)/sample_rate # vecteur temps + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_soi)).reshape(-1,1) # 8x1 + 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) + + # SOI est un gold_code, répété , de longueur 127 + gold_code = np.array([-1, 1, 1, -1, 1, 1, 1, 1, -1, -1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, 1, 1, 1, -1, -1, 1, 1, -1, -1, 1, -1, 1, -1, -1, 1, -1, -1, -1, -1, -1, -1, 1, 1, -1, 1, -1, 1, -1, 1, 1, -1, -1, -1, -1, 1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, -1, -1, 1, 1, 1, -1, 1, -1, -1, -1, 1, 1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1, -1, -1, -1, 1, 1]) + soi_samples_per_symbol = 8 + soi = np.repeat(gold_code, soi_samples_per_symbol) + num_sequence_repeats = int(N / soi.shape[0]) + 1 # nombre de fois où répéter la séquence pour N échantillons + soi = np.tile(soi, num_sequence_repeats)[:N] # répétition de la séquence pour remplir le temps simulé, puis tronquez-la. + soi = soi.reshape(1, -1) # 1xN + + # Interférences, par exemple brouilleurs de tonalité, provenant de différentes directions + tone2 = np.exp(2j*np.pi*0.02e6*t).reshape(1,-1) + tone3 = np.exp(2j*np.pi*0.03e6*t).reshape(1,-1) + + # simulation du signal reçu + r = s1 @ soi + s2 @ tone2 + s3 @ tone3 + n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) + r = r + 0.5*n # 8xN + + # LMS, ne connaissant pas la direction du SOI mais connaissant le signal SOI lui-même + mu = 0.5e-5 # taille du pas LMS + w_lms = np.zeros((Nr, 1), dtype=np.complex128) # commencer par des zéros + + # Boucle sur les échantillons reçus + error_log = [] + for i in range(N): + r_sample = r[:, i].reshape(-1, 1) # 8x1 + soi_sample = soi[0, i] # scalar + y = w_lms.conj().T @ r_sample # application des poids + y = y.squeeze() # conversion en scalaire + error = soi_sample - y + error_log.append(np.abs(error)**2) + w_lms += mu * np.conj(error) * r_sample # Les poids restent de taille 8x1 + + w_lms /= np.linalg.norm(w_lms) # normalisation des poids + plt.plot(error_log) + plt.xlabel('Iteration') + plt.ylabel('Erreur des Moindre carrés') + plt.show() + # Tracer le diagramme de rayonnement comme indiqué précédemment +Essayez de modifier :code:`theta_soi`, la quantité de bruit (c'est-à-dire :code:`0.5*n`) et la taille du pas :code:`mu` pour voir comment l'algorithme LMS fonctionne. +******************************* +Données d'entraînement +******************************* + +Dans le cadre du traitement d'antennes, le concept d'« entraînement » consiste à établir la matrice de covariance R avant l'apparition potentielle d'une source d'intérêt (SOI). Cette approche est particulièrement utile en radar, où, la plupart du temps, aucune SOI n'est présente et où le processus de détection repose sur le test d'une série d'angles pour vérifier sa présence. Le calcul de R avant l'apparition de la SOI permet de calculer les pondérations, à l'aide de méthodes telles que MVDR, en ne considérant dans la matrice de covariance que les interférences et le bruit ambiant. Ainsi, MVDR ne risque pas de placer un zéro à proximité de la direction de la SOI. Les pondérations sont ensuite appliquées au signal reçu pour déterminer si la SOI est présente à cet angle. + +Pour illustrer l'intérêt des données d'entraînement, nous appliquerons MVDR à un enregistrement provenant d'une antenne réelle à 16 éléments (utilisant la plateforme QUAD-MxFE d'Analog Devices). Nous commencerons par effectuer une analyse MVDR classique, en utilisant l'intégralité du signal reçu pour calculer R et les pondérations. Nous utiliserons ensuite un enregistrement distinct, effectué avant l'activation du SOI, pour calculer R et les pondérations. + +Ces enregistrements ont été réalisés à une fréquence radio de 3,3 GHz, avec un réseau d'antennes espacées de 0,045 mètre, soit d = 0,495. Une fréquence d'échantillonnage de 30 MHz a été utilisée. Nous désignerons les trois signaux par A, B et C. Le signal C correspond au SOI, tandis que les signaux A et B représentent les interférences. Par conséquent, nous avons besoin d'un enregistrement contenant uniquement les séquences A et B afin de créer les données d'entraînement, sans que A et B ne se déplacent entre l'acquisition des données d'entraînement et l'enregistrement incluant C. Vous trouverez ci-dessous les liens vers les deux enregistrements nécessaires : + +https://github.com/777arc/777arc.github.io/raw/master/3p3G_A_B.npy + +https://github.com/777arc/777arc.github.io/raw/master/3p3G_A_B_C.npy + +Commençons par effectuer une reconstruction multivariée (MVDR) classique avec l'enregistrement A_B_C. Nous pouvons charger cet enregistrement, au format :code:`np.save()`, contenant un tableau 2D. La première dimension correspond au nombre d'éléments du tableau, et la seconde au nombre d'échantillons. + +.. code-block:: python + + import matplotlib.pyplot as plt + import numpy as np + + # Array params + center_freq = 3.3e9 + sample_rate = 30e6 + d = 0.045 * center_freq / 3e8 + print("d:", d) + + # Incluant les trois signaux, nous appellerons C notre SOI + filename = '3p3G_A_B_C.npy' + X = np.load(filename) + Nr = X.shape[0] + +Nous allons ensuite effectuer une analyse DOA de base avec MVDR, afin d'identifier les angles d'arrivée des trois signaux : + +.. code-block:: python + + # Perform DOA to find angle of arrival of C + theta_scan = np.linspace(-1*np.pi/2, np.pi/2, 10000) # between -90 and +90 degrees + results = [] + R = X @ X.conj().T # Calc covariance matrix. gives a Nr x Nr covariance matrix of the samples + Rinv = np.linalg.pinv(R) # pseudo-inverse tends to work better than a true inverse + for theta_i in theta_scan: + a = np.exp(2j * np.pi * d * np.arange(X.shape[0]) * np.sin(theta_i)) # steering vector in the desired direction theta_i + a = a.reshape(-1,1) # make into a column vector + power = 1/(a.conj().T @ Rinv @ a).squeeze() # MVDR power equation + power_dB = 10*np.log10(np.abs(power)) # power in signal, in dB so its easier to see small and large lobes at the same time + results.append(power_dB) + results -= np.max(results) # normalize to 0 dB at peak + +Dans ce cas précis, il est plus simple d'utiliser un diagramme rectangulaire plutôt qu'un diagramme polaire. Nous avons nommé les signaux A, B et C. + +.. image:: ../_images/DOA_without_training.svg + :align: center + :target: ../_images/DOA_without_training.svg + :alt: DOA sans données d'entraînement + +Ensuite, si nous voulons appeler C notre SOI et utiliser MVDR pour créer des pondérations qui annuleront A et B tout en préservant C, nous devons connaître l'angle d'arrivée exact de C. Nous allons le faire en utilisant un argmax sur les résultats DOA que nous venons de créer, mais seulement après avoir annulé les angles correspondant à A et B (nous faisons cela en fixant les 60 % supérieurs de nos résultats DOA à une valeur très faible). + +.. code-block:: python + + # Pull out angle of C, after zeroing out the angles that include the interferers + results_temp = np.array(results) + results_temp[int(len(results)*0.4):] = -9999*np.ones(int(len(results)*0.6)) + max_angle = theta_scan[np.argmax(results_temp)] # radians + print("max_angle:", max_angle) + +Il s'avère que C vaut -0,3407 radians ; c'est donc cette valeur qu'il faut utiliser pour calculer les pondérations MVDR. Vous avez déjà effectué cette opération à maintes reprises, il s'agit simplement de l'équation MVDR. + +.. code-block:: python + + # Calcul des poids MVDR + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(max_angle)) # steering vector in the desired direction theta + s = s.reshape(-1,1) # make into a column vector + w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # MVDR/Capon equation + +Enfin, traçons le diagramme de rayonnement des pondérations MVDR que nous venons de calculer, ainsi que les résultats DOA obtenus précédemment, et une ligne verte pointillée à :code:`max_angle`: + +.. raw:: html + +
+ Expand this for the plotting code (it's nothing new) + +.. code-block:: python + + # Calcul du modèle de faisceau + w = w.squeeze() + N_fft = 2048 + w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero padding à N_fft élémentspour améliorer la résolution de la FFT + w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # amplitude of fft in dB + w_fft_dB -= np.max(w_fft_dB) # normalisation du maximum à 0 dB + theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # Conversion des échantillons de la FFT en angles en radians + + # Tracer le diagramme de rayonnement et les résultats de la direction d'arrivée + plt.plot(theta_bins * 180 / np.pi, w_fft_dB) # ASSUREZ-VOUS D'UTILISER LE RADIAN POUR LES REPRESENTATIONS POLAIRES + plt.plot(theta_scan * 180 / np.pi, results, 'r') + plt.vlines(ymax=np.max(results), ymin=np.min(results) , x=max_angle*180/np.pi, color='g', linestyle='--') + plt.xlabel("Angle [deg]") + plt.ylabel("Amplitude [dB]") + plt.title("Diagramme de faisceau et résultats de DOA, sans formation") + plt.grid() + plt.show() +.. raw:: html +
+.. image:: ../_images/DOA_without_training_pattern.svg + :align: center + :target: ../_images/DOA_without_training_pattern.svg + :alt: DOA sans données d'entraînement, DOA et diagramme de faisceau MVDR +Nous avons réussi à créer des zéros aux points A et B. Au point C (ligne pointillée verte), nous n'observons pas de zéro, ni de lobe principal apparent ; il s'agit plutôt d'un lobe réduit. Ceci est dû en partie à l'absence quasi totale d'énergie provenant des directions autres que A, B et C. Par conséquent, même si certains lobes sont visibles (par exemple autour de -70, 25 et 40 degrés), ils sont négligeables car aucun signal ne provient de cette direction. Une autre raison de la faible intensité du lobe en C est que le lobe principal est en quelque sorte en conflit avec les zéros qui auraient été créés par le MVDR si nous n'avions pas été pointés précisément dans cette direction. Cela étant dit, il serait souhaitable d'avoir un lobe principal marqué à notre position :code:`max_angle`, et pour ce faire, nous devrons utiliser des **données d'entraînement**. +Nous allons maintenant charger l'enregistrement des points A et B uniquement, afin de créer les données d'entraînement. Dans une situation radar, cela équivaut à calculer :code:`R` avant de transmettre une impulsion radar (idéalement, très peu de temps avant). +.. code-block:: python + # Load "training data" which is just A and B, then calc Rinv + filename = '3p3G_A_B.npy' + X_A_B = np.load(filename) + R_training = X_A_B @ X_A_B.conj().T # Calc covariance matrix + Rinv_training = np.linalg.pinv(R_training) +Cette fois, la principale différence réside dans l'utilisation de :code:`Rinv_training` pour le calcul des poids MVDR. Nous réutiliserons :code:`max_angle`, valeur déjà déterminée. Ainsi, nous orientons le signal vers C sans pour autant l'intégrer au signal reçu utilisé pour le calcul de :code:`R` et :code:`R_inv`. +.. code-block:: python + # Calcul des poids MVDR en utilisant Rinv_training + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(max_angle)) # Vecteur de direction dans la direction souhaitée θ + s = s.reshape(-1,1) # Conversion en vecteur colonne (taille 3x1) + w = (Rinv_training @ s)/(s.conj().T @ Rinv_training @ s) # équation MVDR/Capon +En utilisant la même méthode de représentation graphique, on obtient : +.. image:: ../_images/DOA_with_training.svg + :align: center + :target: ../_images/DOA_with_training.svg + :alt: DOA avec données d'entraînement, DOA et diagramme de faisceau MVDR 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. -****************************** +******************************* Simulation d'interférences à large bande ******************************* @@ -1174,7 +1472,7 @@ Enfin, il est conseillé de balayer de 0 à 360 degrés, et non seulement de -90 Pour les réseaux 2D (par exemple, rectangulaires), consultez le chapitre :ref:`2d-beamforming-chapter`. -************************ +************************* Conclusion et références ************************* @@ -1189,4 +1487,4 @@ L'ensemble du code Python, y compris celui utilisé pour générer les figures e .. |br| raw:: html -
+
From fe66e6d8e6fdcfb2a35c8c2013347bd94c1a9309 Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Tue, 14 Apr 2026 22:47:34 +0200 Subject: [PATCH 8/9] Adding the hackrf chapter in index-fr.rst --- index-fr.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/index-fr.rst b/index-fr.rst index 6e962e2e..4ca1018d 100644 --- a/index-fr.rst +++ b/index-fr.rst @@ -12,6 +12,7 @@ by :ref:`Dr. Marc Lichtman` content-fr/frequency_domain content-fr/sampling content-fr/digital_modulation + content-fr/hackrf content-fr/pluto content-fr/usrp content-fr/noise From f00bf2121b251cd76803e1b5105116332d982fc8 Mon Sep 17 00:00:00 2001 From: Vincent SZYMANSKI Date: Wed, 15 Apr 2026 21:15:03 +0200 Subject: [PATCH 9/9] Adding 2 new files French translation : rtlsdr.rst and bladerf.rst in content-fr --- content-fr/bladerf.rst | 411 +++++++++++++++++++++++++++++++++++++++++ content-fr/rtlsdr.rst | 220 ++++++++++++++++++++++ 2 files changed, 631 insertions(+) create mode 100644 content-fr/bladerf.rst create mode 100644 content-fr/rtlsdr.rst diff --git a/content-fr/bladerf.rst b/content-fr/bladerf.rst new file mode 100644 index 00000000..25e2ca65 --- /dev/null +++ b/content-fr/bladerf.rst @@ -0,0 +1,411 @@ +.. _bladerf-chapter: + +################## +BladeRF en Python +################## + +Le bladeRF 2.0 (également appelé bladeRF 2.0 micro) de Nuand `Nuand `_ est un SDR (récepteur audio numérique) USB 3.0 doté de deux canaux de réception, deux canaux d'émission, une bande passante ajustable de 47 MHz à 6 GHz et une capacité d'échantillonnage jusqu'à 61 MHz, voire 122 MHz après modification. Il utilise le circuit intégré RF AD9361, tout comme l'USRP B210 et le PlutoSDR, offrant ainsi des performances RF similaires. Sorti en 2021, le bladeRF 2.0 conserve un format compact de 2,5" x 4,5" et est disponible en deux tailles de FPGA (xA4 et xA9). Bien que ce chapitre soit consacré au bladeRF 2.0, une grande partie du code est également applicable au bladeRF original, `sorti en 2013 `_. + + +.. image:: ../_images/bladeRF_micro.png + :scale: 35 % + :align: center + :alt: Photo glamour du BladeRF 2.0 + +******************************** +Architecture du bladeRF +******************************** + +De manière générale, le bladeRF 2.0 repose sur le circuit intégré RF AD9361, associé à un FPGA Cyclone V (49 kLE :code:`5CEA4` ou 301 kLE :code:`5CEA9`), et un contrôleur USB 3.0 Cypress FX3 doté d'un cœur ARM9 cadencé à 200 MHz et d'un firmware personnalisé. Le schéma fonctionnel du bladeRF 2.0 est présenté ci-dessous : + +.. image:: ../_images/bladeRF-2.0-micro-Block-Diagram-4.png + :scale: 80 % + :align: center + :alt: Schéma fonctionnel de bladeRF 2.0 + +Le FPGA contrôle le circuit intégré RF, effectue le filtrage numérique et formate les paquets pour leur transfert via USB (entre autres). Le code source de l'image FPGA, disponible à l'adresse ``_, est écrit en VHDL et nécessite le logiciel de conception gratuit Quartus Prime Lite pour compiler des images personnalisées. Des images précompilées sont disponibles `ici : `_. + +Le code source du firmware Cypress FX3 (disponible à l'adresse ``_) est open source et inclut le code permettant de : + +1. Charger l'image FPGA +2. Transférer les échantillons IQ entre le FPGA et l'hôte via USB 3.0 +3. Contrôler les E/S du FPGA via UART + +Du point de vue du flux de signal, il existe deux canaux de réception et deux canaux d'émission. Chaque canal possède une entrée/sortie basse et haute fréquence vers le circuit intégré RF (RFIC), selon la bande utilisée. C'est pourquoi un commutateur électronique RF unipolaire bidirectionnel (SPDT) est nécessaire entre le RFIC et les connecteurs SMA. Le circuit de polarisation intégré fournit environ 4,5 V CC sur le connecteur SMA et permet d'alimenter un amplificateur externe ou d'autres composants RF. Ce décalage CC supplémentaire se situe côté RF du SDR et n'interfère donc pas avec le fonctionnement de base en réception/émission. + +JTAG est une interface de débogage permettant de tester et de vérifier les conceptions pendant leur développement. + +À la fin de ce chapitre, nous aborderons l'oscillateur VCTCXO, la PLL et le port d'extension. + +******************************** +Configuration matérielle et logicielle +******************************** + +Ubuntu (ou Ubuntu dans WSL) +############################# + +Sur Ubuntu et autres systèmes basés sur Debian, vous pouvez installer le logiciel bladeRF avec les commandes suivantes : + +.. code-block:: bash + + sudo apt update + sudo apt install cmake python3-pip libusb-1.0-0 + cd ~ + git clone --depth 1 https://github.com/Nuand/bladeRF.git + cd bladeRF/host + mkdir build && cd build + cmake .. + make -j8 + sudo make install + sudo ldconfig + cd ../libraries/libbladeRF_bindings/python + sudo python3 setup.py install + +Cela installera la bibliothèque libbladerf, les liaisons Python, les outils en ligne de commande bladeRF, le programme de téléchargement du firmware et celui du flux de bits FPGA. Pour vérifier la version de la bibliothèque installée, utilisez la commande :code:`bladerf-tool version` (ce guide a été rédigé avec la version 2.5.0 de libbladerf). + +Si vous utilisez Ubuntu via WSL, vous devrez, côté Windows, rediriger le périphérique USB bladeRF vers WSL. Pour cela, installez d'abord la dernière version de l'utilitaire usbipd (fichier MSI :` `_) (ce guide suppose que vous disposez de usbipd-win 4.0.0 ou version ultérieure), puis ouvrez PowerShell en mode administrateur et exécutez la commande suivante : + +.. code-block:: bash + + usbipd list + # (Trouvez le BUSID étiqueté bladeRF 2.0 et remplacez-le dans la commande ci-dessous.) + usbipd bind --busid 1-23 + usbipd attach --wsl --busid 1-23 + +Sous WSL, vous devriez pouvoir exécuter la commande :code:`lsusb` et voir un nouvel élément nommé :code:`Nuand LLC bladeRF 2.0 micro`. Notez que vous pouvez ajouter l'option :code:`--auto-attach` à la commande :code:`usbipd attach` pour activer la reconnexion automatique. + +(Cette étape peut être inutile.) Sous Linux natif et sous WSL, il est nécessaire d'installer les règles udev afin d'éviter les erreurs de permissions. + + +.. code-block:: + + sudo nano /etc/udev/rules.d/88-nuand.rules + +et collez la ligne suivante : +.. code-block:: + + ATTRS{idVendor}=="2cf0", ATTRS{idProduct}=="5250", MODE="0666" + +Pour enregistrer et quitter nano, utilisez : Ctrl+O, puis Entrée, puis Ctrl+X. Pour actualiser udev, exécutez : + +.. code-block:: bash + + sudo udevadm control --reload-rules && sudo udevadm trigger + +Si vous utilisez WSL et que le message d'erreur suivant s'affiche :code:`Échec de l'envoi de la requête de rechargement : Aucun fichier ou répertoire de ce type`, cela signifie que le service udev n'est pas en cours d'exécution et que vous devrez exécuter la commande :code:`sudo nano /etc/wsl.conf` et ajouter les lignes suivantes : + +.. code-block:: bash + + [boot] + command="service udev start" + +Redémarrez ensuite WSL à l'aide de la commande suivante dans PowerShell en tant qu'administrateur : :code:`wsl.exe --shutdown`. + +Débranchez puis rebranchez votre bladeRF (les utilisateurs de WSL devront la reconnecter), et testez les autorisations avec : + +.. code-block:: bash + + bladerf-tool probe + bladerf-tool info + +and you'll know it worked if you see your bladeRF 2.0 listed, and you **don't** see :code:`Found a bladeRF via VID/PID, but could not open it due to insufficient permissions`. If it worked, note reported FPGA Version and Firmware Version. + +(Optionally) Install the latest firmware and FPGA images (v2.4.0 and v0.15.0 respectively when this guide was written) using: + +.. code-block:: bash + + cd ~/Downloads + wget https://www.nuand.com/fx3/bladeRF_fw_latest.img + bladerf-tool flash_fw bladeRF_fw_latest.img + + # for xA4 use: + wget https://www.nuand.com/fpga/hostedxA4-latest.rbf + bladerf-tool flash_fpga hostedxA4-latest.rbf + + # for xA9 use: + wget https://www.nuand.com/fpga/hostedxA9-latest.rbf + bladerf-tool flash_fpga hostedxA9-latest.rbf + +Unplug and plug in your bladeRF to cycle power. + +Now we will test its functionality by receiving 1M samples in the FM radio band, at 10 MHz sample rate, to a file /tmp/samples.sc16: + +.. code-block:: bash + + bladerf-tool rx --num-samples 1000000 /tmp/samples.sc16 100e6 10e6 + +a couple :code:`Hit stall for buffer` is expected, but you'll know if it worked if you see a 4MB /tmp/samples.sc16 file. + +Lastly, we will test the Python API with: + +.. code-block:: bash + + python3 + import bladerf + bladerf.BladeRF() + exit() + +You'll know it worked if you see something like :code:`)>` and no warnings/errors. + +Windows and macOS +################### + +For Windows users (who do not prefer to use WSL), see https://github.com/Nuand/bladeRF/wiki/Getting-Started%3A-Windows, and for macOS users, see https://github.com/Nuand/bladeRF/wiki/Getting-started:-Mac-OSX. + +******************************** +bladeRF Python API Basics +******************************** + +To start with, let's poll the bladeRF for some useful information, using the following script. **Do not name your script bladerf.py** or it will conflict with the bladeRF Python module itself! + +.. code-block:: python + + from bladerf import _bladerf + import numpy as np + import matplotlib.pyplot as plt + + sdr = _bladerf.BladeRF() + + print("Device info:", _bladerf.get_device_list()[0]) + print("libbladeRF version:", _bladerf.version()) # v2.5.0 + print("Firmware version:", sdr.get_fw_version()) # v2.4.0 + print("FPGA version:", sdr.get_fpga_version()) # v0.15.0 + + rx_ch = sdr.Channel(_bladerf.CHANNEL_RX(0)) # give it a 0 or 1 + print("sample_rate_range:", rx_ch.sample_rate_range) + print("bandwidth_range:", rx_ch.bandwidth_range) + print("frequency_range:", rx_ch.frequency_range) + print("gain_modes:", rx_ch.gain_modes) + print("manual gain range:", sdr.get_gain_range(_bladerf.CHANNEL_RX(0))) # ch 0 or 1 + +For the bladeRF 2.0 xA9, the output should look something like: + +.. code-block:: python + + Device info: Device Information + backend libusb + serial f80a27b1010448dfb7a003ef7fa98a59 + usb_bus 2 + usb_addr 5 + instance 0 + libbladeRF version: v2.5.0 ("2.5.0-git-624994d") + Firmware version: v2.4.0 ("2.4.0-git-a3d5c55f") + FPGA version: v0.15.0 ("0.15.0") + sample_rate_range: Range + min 520834 + max 61440000 + step 2 + scale 1.0 + + bandwidth_range: Range + min 200000 + max 56000000 + step 1 + scale 1.0 + + frequency_range: Range + min 70000000 + max 6000000000 + step 2 + scale 1.0 + + gain_modes: [, , , , ] + + manual gain range: Range + min -15 + max 60 + step 1 + scale 1.0 + +The bandwidth parameter sets the filter used by the SDR when performing the receive operation, so we typically set it to be equal or slightly less than the sample_rate/2. The gain modes are important to understand: the SDR uses either a manual gain mode, where you provide the gain in dB, or automatic gain control (AGC), which has three different settings (fast, slow, hybrid). For applications such as spectrum monitoring, manual gain is advised so you can see when signals come and go. For applications such as receiving a specific signal you expect to exist, AGC is more useful because it automatically adjusts the gain to allow the signal to fill the analog-to-digital converter (ADC). + +To set the main parameters of the SDR, we can add the following code: + +.. code-block:: python + + sample_rate = 10e6 + center_freq = 100e6 + gain = 50 # -15 to 60 dB + num_samples = int(1e6) + + rx_ch.frequency = center_freq + rx_ch.sample_rate = sample_rate + rx_ch.bandwidth = sample_rate/2 + rx_ch.gain_mode = _bladerf.GainMode.Manual + rx_ch.gain = gain + +******************************** +Receiving Samples in Python +******************************** + +Next, we will work off the previous code block to receive 1M samples in the FM radio band, at 10 MHz sample rate, just like we did before. Any antenna on the RX1 port should be able to receive FM, since it is so strong. The code below shows how the bladeRF synchronous stream API works; it must be configured and a receive buffer must be created, before the receiving begins. The :code:`while True:` loop will continue to receive samples until the number of samples requested is reached. The received samples are stored in a separate numpy array, so that we can process them after the loop finishes. + +.. code-block:: python + + # Setup synchronous stream + sdr.sync_config(layout = _bladerf.ChannelLayout.RX_X1, # or RX_X2 + fmt = _bladerf.Format.SC16_Q11, # int16s + num_buffers = 16, + buffer_size = 8192, + num_transfers = 8, + stream_timeout = 3500) + + # Create receive buffer + bytes_per_sample = 4 # don't change this, it will always use int16s + buf = bytearray(1024 * bytes_per_sample) + + # Enable module + print("Starting receive") + rx_ch.enable = True + + # Receive loop + x = np.zeros(num_samples, dtype=np.complex64) # storage for IQ samples + num_samples_read = 0 + while True: + if num_samples > 0 and num_samples_read == num_samples: + break + elif num_samples > 0: + num = min(len(buf) // bytes_per_sample, num_samples - num_samples_read) + else: + num = len(buf) // bytes_per_sample + sdr.sync_rx(buf, num) # Read into buffer + samples = np.frombuffer(buf, dtype=np.int16) + samples = samples[0::2] + 1j * samples[1::2] # Convert to complex type + samples /= 2048.0 # Scale to -1 to 1 (it is using a 12-bit ADC) + x[num_samples_read:num_samples_read+num] = samples[0:num] # Store buf in samples array + num_samples_read += num + + print("Stopping") + rx_ch.enable = False + print(x[0:10]) # look at first 10 IQ samples + print(np.max(x)) # if this is close to 1, you are overloading the ADC, and should reduce the gain + +A few :code:`Hit stall for buffer` is expected at the end. The last number printed shows the maximum sample received; you will want to adjust your gain to try to get that value around 0.5 to 0.8. If it is 0.999 that means your receiver is overloaded/saturated and the signal is going to be distorted (it will look smeared throughout the frequency domain). + +In order to visualize the received signal, let's display the IQ samples using a spectrogram (see :ref:`spectrogram-section` for more details on how spectrograms work). Add the following to the end of the previous code block: + +.. code-block:: python + + # Create spectrogram + fft_size = 2048 + num_rows = len(x) // fft_size # // is an integer division which rounds down + 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) + extent = [(center_freq + sample_rate/-2)/1e6, (center_freq + sample_rate/2)/1e6, len(x)/sample_rate, 0] + plt.imshow(spectrogram, aspect='auto', extent=extent) + plt.xlabel("Frequency [MHz]") + plt.ylabel("Time [s]") + plt.show() + +.. image:: ../_images/bladerf-waterfall.svg + :align: center + :target: ../_images/bladerf-waterfall.svg + :alt: bladeRF spectrogram example + + +Chaque ligne verticale ondulée représente un signal radio FM. L'origine des pulsations à droite reste inconnue ; même en baissant le gain, elles persistent. + +******************************** +Transmission d'échantillons en Python +******************************** + +Le processus de transmission d'échantillons avec la bladeRF est très similaire à la réception. La principale différence réside dans la génération des échantillons à transmettre, suivie de leur écriture sur la bladeRF à l'aide de la méthode :code:`sync_tx`, capable de traiter l'ensemble du lot d'échantillons en une seule fois (jusqu'à environ 4 milliards d'échantillons). Le code ci-dessous illustre la transmission d'une tonalité simple, répétée 30 fois. La tonalité est générée avec NumPy, puis mise à l'échelle entre -2048 et 2048 pour s'adapter au convertisseur numérique-analogique (CNA) 12 bits. Elle est ensuite convertie en octets (représentant des entiers 16 bits) et utilisée comme tampon de transmission. L'API de flux synchrone est utilisée pour la transmission des échantillons, et la boucle :code:`while True:` assure la transmission jusqu'à ce que le nombre de répétitions souhaité soit atteint. Si vous souhaitez transmettre des échantillons à partir d'un fichier, utilisez simplement :code:`samples = np.fromfile('yourfile.iq', dtype=np.int16)` (ou tout autre type de données) pour lire les échantillons, puis convertissez-les en octets à l'aide de :code:`samples.tobytes()`, en tenant compte de la plage de -2048 à 2048 du CNA. + +.. code-block:: python + + from bladerf import _bladerf + import numpy as np + + sdr = _bladerf.BladeRF() + tx_ch = sdr.Channel(_bladerf.CHANNEL_TX(0)) # Donnez-lui un 0 ou un 1 + + sample_rate = 10e6 + center_freq = 100e6 + gain = 0 # de -15dB à 60 dB pour transmettre, commencez par une faible valeur et augmentez-la progressivement, en veillant à ce que l'antenne soit bien connectée. + num_samples = int(1e6) + repeat = 30 # nombre de fois pour répéter notre signal + print('duration of transmission:', num_samples/sample_rate*repeat, 'seconds') + + # Générer des échantillons IQ à transmettre (dans ce cas, une simple tonalité) + t = np.arange(num_samples) / sample_rate + f_tone = 1e6 + samples = np.exp(1j * 2 * np.pi * f_tone * t) # will be -1 to +1 + samples = samples.astype(np.complex64) + samples *= 2048.0 # Scale to -1 to 1 (it is using a 12-bit DAC) + samples = samples.view(np.int16) + buf = samples.tobytes() # Convertir nos échantillons en octets et les utiliser comme tampon de transmission + + tx_ch.frequency = center_freq + tx_ch.sample_rate = sample_rate + tx_ch.bandwidth = sample_rate/2 + tx_ch.gain = gain + +# Configurer le flux synchrone + sdr.sync_config(layout=_bladerf.ChannelLayout.TX_X1, # ou TX_X2 + fmt=_bladerf.Format.SC16_Q11, # int16s + num_buffers=16, + buffer_size=8192, + num_transfers=8, + stream_timeout=3500) + + print("Démarrage de la transmission!") + repeats_remaining = repeat - 1 + tx_ch.enable = True + while True: + sdr.sync_tx(buf, num_samples) # write to bladeRF + print(repeats_remaining) + if repeats_remaining > 0: + repeats_remaining -= 1 + else: + break + + print("Arrêt de la transmission") + tx_ch.enable = False + +Quelques erreurs de type :code:`Hit stall for buffer` à la fin sont normales. + +Pour transmettre et recevoir simultanément, il faut utiliser des threads. Vous pouvez par exemple utiliser l'exemple de Nuand : `txrx.py `_, qui fait exactement cela. + +*********************************** +Oscillateurs, PLLs, and étalonnage +*********************************** + +Tous les SDR à conversion directe (y compris ceux basés sur l'AD9361, comme l'USRP B2X0, l'Analog Devices Pluto et le bladeRF) utilisent un oscillateur unique pour fournir une horloge stable à l'émetteur-récepteur RF. Tout décalage ou gigue de fréquence produit par cet oscillateur se traduit par un décalage et une gigue de fréquence dans le signal reçu ou émis. Cet oscillateur est intégré, mais peut être stabilisé par un signal carré ou sinusoïdal externe injecté dans le bladeRF via un connecteur U.FL sur la carte. + +La carte bladeRF embarque un `oscillateur Abracon VCTCXO `_ (oscillateur à compensation de température commandé en tension) cadencé à 38,4 MHz. La compensation de température lui confère une grande stabilité sur une large plage de températures. La commande en tension permet d'ajuster finement la fréquence de l'oscillateur grâce à un niveau de tension spécifique. Sur la bladeRF, cette tension est fournie par un convertisseur numérique-analogique (CNA) 10 bits externe, représenté en vert dans le schéma fonctionnel ci-dessous. Ce système permet un réglage précis de la fréquence de l'oscillateur par logiciel, et c'est ainsi que l'on calibre (ou ajuste) le VCTCXO de la bladeRF. Heureusement, les lames RF sont calibrées en usine, comme nous le verrons plus loin dans cette section, mais si vous disposez de l'équipement de test nécessaire, vous pouvez toujours affiner cette valeur, surtout au fil des années et de la dérive de la fréquence de l'oscillateur. + +.. image:: ../_images/bladeRF-2.0-micro-Block-Diagram-4-oscillator.png + :scale: 80 % + :align: center + :alt: Schéma fonctionnel du bladeRF 2.0 + +Lorsqu'une référence de fréquence externe est utilisée (pouvant atteindre pratiquement n'importe quelle fréquence jusqu'à 300 MHz), le signal de référence est directement injecté dans la boucle à verrouillage de phase (PLL) `Analog Devices ADF4002 `_ intégrée à la carte bladeRF. Cette PLL se synchronise sur le signal de référence et envoie un signal à l'oscillateur VCTCXO (représenté en bleu ci-dessus) proportionnel à la différence de fréquence et de phase entre l'entrée de référence (mise à l'échelle) et la sortie du VCTCXO. Une fois la PLL synchronisée, ce signal entre la PLL et le VCTCXO constitue une tension continue stable qui maintient la sortie du VCTCXO à 38,4 MHz (en supposant que la référence soit correcte) et synchronisée en phase avec l'entrée de référence. Pour utiliser une référence externe, vous devez activer :code:`clock_ref` (via Python ou l'interface de ligne de commande) et définir la fréquence de référence d'entrée (:code:`refin_freq`), qui est de 10 MHz par défaut. L'utilisation d'une référence externe permet notamment une meilleure précision de fréquence et la possibilité de synchroniser plusieurs SDR sur la même référence. + +Chaque valeur de réglage du convertisseur numérique-analogique (CNA) VCTCXO de bladeRF est calibrée en usine à 1 Hz près à 38,4 MHz à température ambiante. Vous pouvez consulter la valeur de calibration d'usine en saisissant votre numéro de série sur la page `_ (trouvez votre numéro de série sur la carte ou à l'aide de l'outil :code:`bladerf-tool probe`). Selon Nuand, une carte neuve devrait présenter une précision largement inférieure à 0,5 ppm, et probablement plus proche de 0,1 ppm. Si vous disposez d'un équipement de test pour mesurer la précision de fréquence ou si vous souhaitez la rétablir à la valeur d'usine, vous pouvez utiliser les commandes suivantes : + +.. code-block:: bash + + $ bladeRF-cli -i + bladeRF> flash_init_cal 301 0x2049 + +Remplacez :code:`301` par la taille de votre bladeRF et :code:`0x2049` par la valeur de réglage DAC VCTCXO au format hexadécimal. Un redémarrage est nécessaire pour que la modification soit prise en compte. + +*********************************** +Échantillonnage à 122 MHz +*********************************** + +À venir! + +*********************************** +Ports d'extension +*********************************** + +Le bladeRF 2.0 est dotée d'un port d'extension utilisant un connecteur BSH-030. Plus d'informations sur l'utilisation de ce port prochainement ! + +******************************** +Pour en savoir plus +******************************** + +#. `bladeRF Wiki `_ +#. `Nuand's txrx.py example `_ diff --git a/content-fr/rtlsdr.rst b/content-fr/rtlsdr.rst new file mode 100644 index 00000000..ef44c2c7 --- /dev/null +++ b/content-fr/rtlsdr.rst @@ -0,0 +1,220 @@ +.. _rtlsdr-chapter: + +#################### +RTL-SDR en Python +#################### + +Le RTL-SDR est de loin le SDR le plus abordable, à environ 40 €, et un excellent choix pour débuter. Bien qu'il ne permette que la réception et que sa bande passante soit limitée à environ 1,75 GHz, il offre de nombreuses applications. Dans ce chapitre, nous apprendrons à configurer le logiciel RTL-SDR et à utiliser son API Python. + +.. image:: ../_images/rtlsdrs.svg + :align: center + :target: ../_images/rtlsdrs.svg + :alt: Exemples de RTL-SDR + +******************************** +Contexte du RTL-SDR +******************************** + +Le RTL-SDR a vu le jour vers 2010, lorsque certains ont découvert qu'il était possible de pirater des dongles DVB-T bon marché équipés de la puce Realtek RTL2832U. Le DVB-T est une norme de télévision numérique principalement utilisée en Europe. L'intérêt du RTL2832U résidait dans l'accès direct aux échantillons IQ bruts, permettant ainsi de concevoir un SDR (récepteur audio numérique) polyvalent. + +La puce RTL2832U intègre le convertisseur analogique-numérique (CAN) et le contrôleur USB, mais elle doit être associée à un tuner RF. Parmi les tuners les plus courants, on trouve les Rafael Micro R820T et R828D, ainsi que l'Elonics E4000. La plage de fréquences réglables dépend du tuner et se situe généralement entre 50 et 1700 MHz. La fréquence d'échantillonnage maximale, quant à elle, est déterminée par le RTL2832U et le bus USB de votre ordinateur. Elle est généralement d'environ 2,4 MHz, sans perte significative d'échantillons. Notez que ces tuners sont extrêmement bon marché et présentent une très faible sensibilité RF. L'ajout d'un amplificateur à faible bruit (LNA) et d'un filtre passe-bande est donc souvent nécessaire pour recevoir des signaux faibles. + +Le RTL2832U utilise toujours des échantillons 8 bits ; l'ordinateur hôte recevra donc deux octets par échantillon IQ. Les RTL-SDR haut de gamme sont généralement équipés d'un oscillateur à température contrôlée (TCXO) en remplacement de l'oscillateur à quartz, moins coûteux, ce qui assure une meilleure stabilité de fréquence. Une autre option est le circuit de polarisation (bias-T), un circuit intégré fournissant environ 4,5 V CC sur le connecteur SMA. Ce circuit permet d'alimenter facilement un LNA externe ou d'autres composants RF. Ce décalage CC supplémentaire se situe côté RF du SDR et n'interfère donc pas avec le fonctionnement de réception. + +Pour ceux qui s'intéressent à la direction d'arrivée (DOA) ou à d'autres applications de formation de faisceaux, le `KrakenSDR `_ est un SDR à cohérence de phase composé de cinq RTL-SDR partageant un oscillateur et une horloge d'échantillonnage. + +******************************* +Installation du logiciel +******************************* + +Ubuntu (ou Ubuntu sous WSL) +############################### + +Sur Ubuntu 20, 22 et autres systèmes basés sur Debian, vous pouvez installer le logiciel RTL-SDR avec la commande suivante. + +.. code-block:: bash + + sudo apt install rtl-sdr + +Cela va installer la bibliothèque librtlsdr , et les outils en lignes de commande suivants :code:`rtl_sdr`, :code:`rtl_tcp`, :code:`rtl_fm`, and :code:`rtl_test`. + +Ensuite, installez le wrapper Python pour librtlsdr en utilisant : + +.. code-block:: bash + + sudo pip install pyrtlsdr + +Si vous utilisez Ubuntu via WSL, téléchargez sous Windows la dernière version de `Zadig `_ et exécutez-la pour installer le pilote « WinUSB » pour le RTL-SDR (il peut y avoir deux interfaces Bulk-In ; dans ce cas, installez « WinUSB » sur les deux). Débranchez puis rebranchez le RTL-SDR une fois l'installation de Zadig terminée. + +Ensuite, vous devrez configurer WSL pour qu'il prenne en charge le périphérique USB du RTL-SDR. Pour cela, installez d'abord la dernière version de l'utilitaire usbipd (`fichier MSI `_) (ce guide suppose que vous disposez de usbipd-win 4.0.0 ou version ultérieure), puis ouvrez PowerShell en mode administrateur et exécutez la commande suivante : + +.. code-block:: bash + + # (unplug RTL-SDR) + usbipd list + # (plug in RTL-SDR) + usbipd list + # (find the new device and substitute its index in the command below) + usbipd bind --busid 1-5 + usbipd attach --wsl --busid 1-5 + +Du côté WSL, vous devriez pouvoir exécuter la commande :code:`lsusb` et voir un nouvel élément nommé RTL2838 DVB-T ou un nom similaire. + +Si vous rencontrez des problèmes d'autorisation (par exemple, le test ci-dessous ne fonctionne qu'avec :code:`sudo`), vous devrez configurer des règles udev. Commencez par exécuter :code:`lsusb` pour trouver l'ID du RTL-SDR, puis créez le fichier :code:`/etc/udev/rules.d/10-rtl-sdr.rules` avec le contenu suivant, en remplaçant :code:`idVendor` et :code:`idProduct` par ceux de votre RTL-SDR si nécessaire : + +.. code-block:: + + SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666" + +Pour actualiser udev, exécutez : + +.. code-block:: bash + + sudo udevadm control --reload-rules + sudo udevadm trigger + +Si vous utilisez WSL et que le message d'erreur suivant s'affiche :code:`Failed to send reload request: No such file or directory`, cela signifie que le service udev n'est pas en cours d'exécution et que vous devrez exécuter la commande :code:`sudo nano /etc/wsl.conf` et ajouter les lignes suivantes : + +.. code-block:: bash + + [boot] + command="service udev start" + + +Redémarrez ensuite WSL à l'aide de la commande suivante dans PowerShell en tant qu'administrateur : :code:`wsl.exe --shutdown`. + +Il peut également être nécessaire de débrancher puis de rebrancher le RTL-SDR (pour WSL, vous devrez relancer la commande :code:`usbipd attach`). + + +Windows +################### + +For Windows users, see https://www.rtl-sdr.com/rtl-sdr-quick-start-guide/. + +******************************** +Test du RTL-SDR +******************************** + +Si l'installation du logiciel a fonctionné, vous devriez pouvoir exécuter le test suivant, qui réglera le RTL-SDR sur la bande radio FM et enregistrera 1 million d'échantillons dans un fichier nommé :code:`recording.iq` dans :code:`/tmp`. + +.. code-block:: bash + + rtl_sdr /tmp/recording.iq -s 2e6 -f 100e6 -n 1e6 + +Si vous obtenez le message :code:`No supported devices found`, même après avoir ajouté :code:`sudo` au début de la commande, Linux ne détecte pas le RTL-SDR. Si la détection fonctionne avec :code:`sudo`, il s'agit d'un problème de configuration udev. Essayez de redémarrer l'ordinateur après avoir suivi les instructions de configuration udev ci-dessus. Vous pouvez également utiliser :code:`sudo` pour toutes les opérations, y compris l'exécution de Python. + +Vous pouvez tester la capacité de Python à détecter le RTL-SDR à l'aide du script suivant : + +.. code-block:: python + + from rtlsdr import RtlSdr + + sdr = RtlSdr() + sdr.sample_rate = 2.048e6 # Hz + sdr.center_freq = 100e6 # Hz + sdr.freq_correction = 60 # PPM + sdr.gain = 'auto' + + print(len(sdr.read_samples(1024))) + sdr.close() + +qui devrait afficher : + +.. code-block:: bash + + Found Rafael Micro R820T tuner + [R82XX] PLL not locked! + 1024 + +******************************** +Code Python RTL-SDR +******************************** + +Le code ci-dessus constitue un exemple d'utilisation basique du RTL-SDR en Python. Les sections suivantes détaillent les différents paramètres et astuces d'utilisation. + +Prévenir les dysfonctionnements du RTL-SDR +################################################ + +À la fin de notre script, ou une fois l'acquisition des échantillons terminée, nous appellerons :code:`sdr.close()`. Cela permettra d'éviter que le RTL-SDR ne se bloque et nécessite d'être débranché/rebranché. Malgré l'utilisation de :code:`close()`, un blocage peut survenir ; vous le constaterez si le RTL-SDR se bloque pendant l'appel à :code:`read_samples()`. Dans ce cas, vous devrez débrancher et rebrancher le RTL-SDR, et éventuellement redémarrer votre ordinateur. Si vous utilisez WSL, vous devrez reconnecter le RTL-SDR à l'aide de usbipd. + +Réglage du gain +################## + +En définissant :code:`sdr.gain = 'auto'`, vous activez le contrôle automatique du gain (CAG). Le RTL-SDR ajustera alors le gain de réception en fonction des signaux reçus, afin d'optimiser la capacité du convertisseur analogique-numérique (CAN) 8 bits sans le saturer. Dans de nombreuses situations, comme la réalisation d'un analyseur de spectre, il est utile de maintenir le gain à une valeur constante, ce qui implique un réglage manuel. Le gain du RTL-SDR n'est pas réglable en continu ; vous pouvez consulter la liste des valeurs de gain valides avec :code:`print(sdr.valid_gains_db)`. Si vous définissez un gain qui ne figure pas dans cette liste, le système choisira automatiquement la valeur autorisée la plus proche. Vous pouvez vérifier le gain actuel avec :code:`print(sdr.gain)`. Dans l'exemple ci-dessous, le gain est réglé à 49,6 dB et 4 096 échantillons sont reçus, puis représentés dans le domaine temporel : + +.. code-block:: python + + from rtlsdr import RtlSdr + import numpy as np + import matplotlib.pyplot as plt + + sdr = RtlSdr() + sdr.sample_rate = 2.048e6 # Hz + sdr.center_freq = 100e6 # Hz + sdr.freq_correction = 60 # PPM + print(sdr.valid_gains_db) + sdr.gain = 49.6 + print(sdr.gain) + + x = sdr.read_samples(4096) + sdr.close() + + plt.plot(x.real) + plt.plot(x.imag) + plt.legend(["I", "Q"]) + plt.savefig("../_images/rtlsdr-gain.svg", bbox_inches='tight') + plt.show() + +.. image:: ../_images/rtlsdr-gain.svg + :align: center + :target: ../_images/rtlsdr-gain.svg + :alt: RTL-SDR manual gain example + +Il y a quelques points à noter. Les 2 000 premiers échantillons environ semblent avoir une faible puissance de signal, car ils représentent des transitoires. Il est recommandé de les ignorer à chaque exécution de script, par exemple en utilisant :code:`sdr.read_samples(2048)` et en ne traitant pas la sortie. Par ailleurs, pyrtlsdr renvoie les échantillons sous forme de nombres à virgule flottante, compris entre -1 et +1. Bien qu'il utilise un convertisseur analogique-numérique 8 bits et produise des valeurs entières, pyrtlsdr effectue une division par 127.0 pour simplifier les calculs. + + +Fréquences d'échantillonnage autorisées +############################################ + +La plupart des récepteurs RTL-SDR nécessitent une fréquence d'échantillonnage comprise entre 230 et 300 kHz, ou entre 900 et 3,2 MHz. Notez que les fréquences élevées, en particulier supérieures à 2,4 MHz, peuvent ne pas permettre d'obtenir 100 % des échantillons via la connexion USB. Si vous spécifiez une fréquence d'échantillonnage non prise en charge, l'erreur suivante s'affichera : :code:`rtlsdr.rtlsdr.LibUSBError: Error code -22: Could not set sample rate to 899000 Hz`. Lors de la configuration d'une fréquence d'échantillonnage autorisée, le message de la console affichera la fréquence exacte ; cette valeur peut également être obtenue en appelant la fonction :code:`sdr.sample_rate`. Certaines applications peuvent tirer parti d'une valeur plus précise pour leurs calculs. + +À titre d'exercice, nous allons configurer la fréquence d'échantillonnage à 2,4 MHz et créer un spectrogramme de la bande radio FM : + +.. code-block:: python + + # ... + sdr.sample_rate = 2.4e6 # Hz + # ... + + fft_size = 512 + num_rows = 500 + x = sdr.read_samples(2048) # get rid of initial empty samples + x = sdr.read_samples(fft_size*num_rows) # get all the samples we need for the spectrogram + 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) + extent = [(sdr.center_freq + sdr.sample_rate/-2)/1e6, + (sdr.center_freq + sdr.sample_rate/2)/1e6, + len(x)/sdr.sample_rate, 0] + plt.imshow(spectrogram, aspect='auto', extent=extent) + plt.xlabel("Frequency [MHz]") + plt.ylabel("Time [s]") + plt.show() + +.. image:: ../_images/rtlsdr-waterfall.svg + :align: center + :target: ../_images/rtlsdr-waterfall.svg + :alt: RTL-SDR waterfall (aka spectrogram) example + +Réglage PPM +############## + +Pour ceux qui s'intéressent au réglage PPM, sachez que chaque récepteur RTL-SDR présente un léger décalage/erreur de fréquence, dû au faible coût des puces de tuner et à l'absence d'étalonnage. Ce décalage de fréquence est relativement linéaire (et non constant) sur l'ensemble du spectre. On peut donc le corriger en saisissant une valeur PPM (parties par million). Par exemple, si vous syntonisez sur 100 MHz et que vous réglez le PPM sur 25, le signal reçu sera décalé vers le haut de 100 x 10⁶ / (1 x 10⁶ * 25) = 2500 Hz. L'impact de l'erreur de fréquence est plus important pour les signaux plus étroits. Cela dit, de nombreux signaux modernes intègrent une étape de synchronisation de fréquence qui corrige tout décalage de fréquence sur l'émetteur, le récepteur ou dû à l'effet Doppler. + +******************************** +Pour en savoir plus +******************************** + +#. `RTL-SDR.com's About Page `_ +#. https://hackaday.com/2019/07/31/rtl-sdr-seven-years-later/ +#. https://osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr