fradiodb : utiliser les données des sites radio en open-data
L’ANFR met en open-data un certain nombre de données publiques sur les sites radio en France métropolitaine et outre-mers. Le site Cartoradio est bien connu, et on peut aussi consulter des données sur data.anfr.fr - et ces deux sites sont clairement les moyens les plus commodes pour la plupart des gens. Mais pour ceux qui aiment faire du code et de l’analyse je voudrais ici parler des données brutes disponibles sur data.gouv.fr, en espérant que ce post puisse faciliter leur usage.
Ces données sont constituées de deux archives zip contenant les fichiers suivant :
- {
SUP_ANTENNE.txt
,SUP_BANDE.txt
,SUP_EMETTEUR.txt
,SUP_STATION.txt
,SUP_SUPPORT.txt
} pour la première (“Tables supports antennes emetteurs bandes”) - {
SUP_EXPLOITANT.txt
,SUP_NATURE.txt
,SUP_PROPRIETAIRE.txt
,SUP_TYPE_ANTENNE.txt
} pour la seconde (“Tables de reference”)
Ces fichiers .txt sont en réalité des fichiers CSV, et les objets qu’ils décrivent ont les relations suivantes
SUPPORT.txt
décrit les supports e.g. pylônes radio (type, adresse, coordonnées GPS, etc.). Un même site géographique peut avoir plusieurs supports. Les propriétaires de support sont décrits dansSUP_PROPRIETAIRE.txt
, et les types de support sont décrits dansSUP_NATURE.txt
(e.g. pylôle autostable, etc.)- Chaque support peut héberger plusieurs antennes (
SUP_ANTENNE.txt
). Le type d’antenne (e.g. parabole, dipôle, etc.) est décrit dansSUP_TYPE_ANTENNE.txt
- Un émetteur (
SUP_EMETTEUR.txt
) peut être relié à plusieurs antennes (e.g. station de base multi-bandes), mais une antenne peut aussi être reliée à plusieurs émetteurs (MORAN, radômes multi-bandes…) - Chaque émetteur est associé à une station (
SUP_STATION.txt
), mais en cas de RAN sharing un émetteur peut être associé à plusieurs stations. Une station appartient à un opérateur (SUP_EXPLOITANT.txt
) - Enfin, un émetteur émet et reçoit dans une ou plusieurs bandes (
SUP_BANDE.txt
)
Le lecteur qui aura un petit peu parcouru ma page Github sait que j’apprécie particulièrement SQLite. Mais tel que les CSV se présentent, il est utile de les retravailler lors de l’import car
- Il y a différentes petites choses peu commodes à mes yeux, par exemple
- La plupart des CSV ont un encodage UTF8 mais SUP_SUPPORT.txt a un encodage iso8859-1
- Les noms des champs ne sont pas toujours très agréables à taper
- Certaies données sont dispersées sur plusieurs champs : par exemple les coordonnées sont sous forme de 4 champs (degré/minute/seconde/orientation) pour la latitude comme pour la longitude, ce qui n’est pas commode à manipuler (notons que l’unité la plus petite étant la seconde d’arc, cela signifie une précision d’environ 30 mètres). Autre exemple : l’adresse d’un site est répartie sur 4 champs.
- Les bandes de fréquences ne sont pas dans une unité homogène (ce qui ne facilite pas leur comparaison)
- SQLite ne convertit pas automatiquement le type de données et importe tout en texte (Pandas peut convertir les données dans une certaine mesure)
- Il y a de la redondance dans certains CSV et si on se contente de les importer tels-quels dans sqlite ils ne sont pas en forme normale, et en particulier sur plusieurs tables le champ supposé être la clé primaire n’est pas unique, par exemple
- SUP_ID n’est pas unique (
select * from sup_support where sup_id in (select sup_id from sup_support group by sup_id having count(sup_id)>1) order by sup_id
) : on note par exemple qu’un champsta_nm_anfr
est présent, or un support héberge souvent plusieurs stations (ce qui fait se répéter des lignes quasi-identiques avec le même sup_id et un champ sta_nm_anfr pour seule différence). La suppression de ce champ résoud presque le problème (il reste encore quelques lignes redondantes à fusionner) et n’entraine pas vraiment de perte d’information puisque l’information permettant d’associer stations et supports est encore présente ailleurs. Notons que (pour des raisons historiques) une station peut aussi avoir des équipements sur plusieurs supports voisins avec des sup_id différents - AER_ID n’est pas une clé primaire dans sup_antenne (pour la même raison que précédemment i.e. présence du champ
sta_nm_anfr
). Par ailleurs une station peut être associée à plusieurs antennes et une antenne peut être associée à plusieurs stations - Les chaînes de caractère désignant la technologie employée sont répétées à chaque entrée au lieu d’être regroupées dans une table
- Les bandes de fréquences sont également extrêmement redondantes
- SUP_ID n’est pas unique (
Le script fradiodb vise précisément à pré-traiter ces données pour les importer dans une base SQLite facile d’emploi, avec des contraintes d’unicité sur les clés primaires, des champs convertis dans le bon type, et quelques commodités (e.g. bitmask sur les technos présentes sur un site pour facilement sélectionner tous les sites déployant un sous-ensemble de technos donné). Il n’a besoin que de Python (pas de dépendance sur Pandas ou Numpy par exemple, donc une installation Python légère suffit et le plus gros du travail est fait dans le fichier schema.sql). Pour l’utiliser, la CLI est simple : python fradiodb.py -i "mabase.db"
(le script suppose que les .zip sont déjà téléchargés et présents dans un sous-dossier anfr/
). La base de données fait environ 100 Mo (ou 20 Mo compressé en 7z), et son schéma est le suivant après traitement (Une flèche matérialise une relation FK->PK i.e. une cardinalité n:1) :
OK, ensuite on en fait quoi ?
Une fois qu’on a notre base de données sqlite, on peut commencer à jouer un peu avec. On peut bien sûr commencer par la parcourir avec un outil tel que sqlitestudio. Le repository git fradiodb
inclue un fichier Jupyter qui fournit quelques exemples de requêtes dans le but de générer des cartes avec Geopandas et Leaflet (mais les férus de QGIS feront sûrement tout aussi bien). Son rendu HTML est disponible ici (avec des cartes interactives, mais très lourdes donc ce qui suit plus bas vise à en donner un premier aperçu avec des screenshots).
Mes cartes générées via geopandas montrent tous les points sans clustering car je trouvais le rendu joli, mais cela génère beaucoup d’objets geojson donc la page est très longue à charger et très consommatrice de mémoire, i.e. peu utilisable en pratique et les data scientists de l’ANFR ne s’y sont pas trompés puisque leurs cartes sur data.anfr.fr sont performantes mais évidemment avec du clustering. Je présume qu’il y a peut-être moyen d’améliorer les performances de mes cartes en remplaçant le geojson par flatgeobuf, en utilisant le GPU pour le rendu e.g. [1], [2], [3] et je crois comprendre que OpenLayers pourrait être plus performant que Leaflet dans ce genre de contexte, mais je dois avouer que le tandem Geopandas/Leaflet est bien pratique pour générer des cartes en quelques lignes comme on va le voir plus bas !).
Contrairement au script fradiodb
plus haut, ce qui suit présuppose une installation Python scientifique avec numpy/Pandas/Geopandas. Par exemple vous pouvez commencer un nouveau workspace Jupyter avec
import os
import pandas as pd
import geopandas as gpd
import folium
import matplotlib as mpl
import sqlite3
DB='mabase.db'
cnx = sqlite3.connect(DB)
Pour filtrer les sites par techno, on peut (lorsque la requête a les jointures nécessaires) filtrer sur le champ system_id
.
N.B. on peut aussi utiliser les champs
tech_bitmask
(même si j’ai réalisé après coup que ce n’était pas une si bonne idée et il faut bien avoir en tête les effets en fonction de la table sur laquelle on regarde le bitmask, donc en première approche je recommand d’utilisersystem_id
). Pour utiliser le bitmask, on peut s’aider de la feuille de calculbitmask.ods
et insérer1
dans le champ ON/OFF sur les technologies souhaitées : les champsbitmask1
etbitmask2
sont ensuite utilisables dans une requête SQL (il y a 2 champs de bitmask car il y a plus de 64 technologies et sqlite ne supporte pas les entiers 128 bits. De plus si on veut s’aider de Excel ou Libreoffice il ne faut pas utiliser plus de 52 bits à la fois car tous les calculs sont faits en double float IEEE 754 donc la précision est limitée si on ajoute de grands entiers avec des petits…). Typiquement pour faire un OU logique sur les technos sélectionnées, on fera... and (tech_bitmask1 & XXX) > 0
afin de retenir l’ensemble des sites sur lesquels au moins 1 bit est à 1 i.e. qui contient au moins une des technos sélectionnées dans le masque. De même, il est possible d’exclure des technologies avec... and (tech_bitmask1 & YYY) = 0
. Les champstech_bitmask1
ettech_bitmask2
sont disponibles dans les tablesantennas/transmitters/supports/sites
(et les vues associées). Il faut évidemment choisir la table ou la vue en fonction du point de vue qu’on souhaite avoir (e.g.sites
pour avoir juste les emplacements sous forme de point,antennas
pour disposer de l’azimut, etc.)
Carte des sites 5G 3.5 GHz par opérateur
Affichons ci-dessous l’ensemble des sites 5G 3500 MHz par opérateur : il n’y a qu’une seule techno donc on peut simplement écrire where system_id=6
(6 étant l’ID de la techno “5G 3500” dans la table id_systems
).
def sortops(operator_list_str): # malheureusement pas de moyen commode de trier avant group_concat dans sqlite
operator_list_str = operator_list_str.replace(" TELECOM", "").replace(" MOBILE", "")
ops = operator_list_str.split(',')
if len(ops)==1: return operator_list_str
if len(ops)==2 and "SFR" in ops and "BOUYGUES" in ops: return "BOUYGES+SFR"
return f"_MIXED {len(ops)} ops"
cnx.create_function("sortops", 1, sortops)
query = """select lon, lat,
count(distinct operator) operator_count, sortops(group_concat(distinct operator)) operator_list,
max(ant_height) h_max,
cast(group_concat(distinct station_id) as text) stations,
cast(group_concat(distinct system) as text) tech_list,
sites.tech_bitmask1, sites.tech_bitmask2
from sites
inner join antennas on antennas.sup_id=supports.id
inner join supports on sites.id=supports.site_id
inner join transmitters on transmitters.antenna_id=antennas.id
inner join id_systems on transmitters.system_id=id_systems.id
inner join stations on stations.id=transmitters.station_id
inner join id_operators on id_operators.id=stations.operator_id
where system=='5G NR 3500' and lat>42 and lat<52 and lon>-5 and lon<10
group by sites.id;
"""
rop_sites = pd.read_sql_query(query, cnx)
gdf_5G = gpd.GeoDataFrame(rop_sites, geometry=gpd.points_from_xy(x=rop_sites.lon, y=rop_sites.lat), crs=4326)
cmap_ops = mpl.colors.ListedColormap(['magenta', 'blue', 'green', 'orange', 'red', 'silver', 'gray', 'black'])
gdf_5G.explore(column='operator_list', cmap=cmap_ops)
On voit clairement la “diagonale du vide”, le fait que les sites 5G sont concentrés dans les zones urbaines et le littoral. On observe aussi qu’en zone rurale les opérateurs ont des statégies un peu différentes et ne se déploient pas tous aux mêmes endroits (à l’exception de Bouygues+SFR dans le cadre des sites “crozon”).
Carte des sites IMT (2G…5G) en fonction de la meilleure technologie déployée
(N.B. comme expliqué dans le fichier Jupyter, ma classification sur cette carte est un peu personnelle et assez différente de la classification habituelle des opérateurs e.g. j’ai catégorisé la 5G 700 MHz en 4G et la 5G 1800/2100 MHz en 4G+ tout comme la 4G à 2.6 GHz. Tout ça est bien sûr discutable mais j’ai trouvé que ça permettait probablement de mieux coller aux capacités des sites - qui dépendent avant tout de la quantité de fréquences) : Avec cette terminologie, on voit clairement que les bandes de fréquences plus hautes (où sont les capacités les plus importantes) sont dans les zones denses et le littoral (ce qui est logique, pour ajuster la capacité aux densités de population). On voit aussi qu’il n’existe quasiment plus aucun site qui n’ait que de la 2G ou 3G (tous les sites ont au moins la 4G) ce qui est logique puisque l’extinction de la 2G/3G se profile.
Quand j’aurai le temps, je referai cette carte avec la somme des capacités de l’ensemble des bandes sur chaque site. Mais je pense que c’est assez représentatif car je présume qu’un site 5G 3500 héberge la plupart du temps aussi de la 4G 2600, 2100, 1800 MHz et les bandes basses… (tiens d’ailleurs puisque toutes les données sont disponibles ça pourrait s’analyser assez facilement :)
imt_tech_order = {'GSM 900': '2G', 'GSM 1800': '2G',
'UMTS 900': '3G', 'UMTS 2100': '3G',
'LTE 900': '4G', 'LTE 700': '4G', 'LTE 800': '4G', '5G NR 700': '4G',
'LTE 1800': '4G', 'LTE 2100': '4G', 'LTE 2600': '4G+',
'5G NR 1800': '4G+', '5G NR 2100': '4G+', '5G NR 3500': '5G'}
def best_tech(tech_list_str):
tech_list_index = [imt_tech_order[k] for k in tech_list_str.split(',') if k in imt_tech_order]
return max(tech_list_index)
cnx.create_function("best_tech", 1, best_tech)
query = """select lon, lat,
max(ant_height) h_max,
group_concat(distinct system) tech_list,
best_tech(group_concat(distinct system)) tech,
sites.tech_bitmask1, sites.tech_bitmask2
from sites
inner join antennas on antennas.sup_id=supports.id
inner join supports on sites.id=supports.site_id
inner join transmitters on transmitters.antenna_id=antennas.id
inner join id_systems on transmitters.system_id=id_systems.id
inner join stations on stations.id=transmitters.station_id
where (system like '5G%0' or system like 'LTE%0' or system like 'UMTS%' or system like 'GSM%0')
and (lat>42 and lat<52 and lon>-5 and lon<10)
group by sites.id;
"""
rop_sites = pd.read_sql_query(query, cnx)
gdf_5G = gpd.GeoDataFrame(rop_sites, geometry=gpd.points_from_xy(x=rop_sites.lon, y=rop_sites.lat), crs=4326)
cmap = mpl.colors.ListedColormap(['red', 'orange', 'darkkhaki', 'limegreen', 'darkgreen'])
gdf_5G.explore(column='tech', cmap=cmap) #RdYlGn brg
N.B. on peut s’y prendre autrement qu’avec le bitmask, par exemple en filtrant sur le champ system_id
à la source (et en fait ça évite certains problèmes).
On peut aussi filtrer les émetteurs par date de mise en service (toutes les dates sont stockées en integer au format unix epoch). Voyons par exemple ci-dessous à quoi ressemblait la carte plus haut au premier janvier 2019 : on va prendre la même requête que plus haut, avec ce petit ajout :
and transmitters.date_switchedon < strftime('%s', '2019-01-01')
On voit qu’il y avait encore beaucoup de sites 3G en 2019, et quelques sites 2G-only…
Et en 2015 ça donne ceci
and transmitters.date_switchedon < strftime('%s', '2015-01-01')
Je trouve que c’est assez bluffant de voir à quel point le paysage a changé en 10 ans (notamment en voyant le nombre considérable de sites 2G-only en 2015). Je mettrai peut-être ce post à jour avec un GIF animé ou un petit film montrant le déploiement mois après mois :)
Carte des sites GSM-R et GSM/UMTS/LTE 900 MHz
Imaginons qu’on souhaite récupérer l’ensemble des sites d’opérateurs télécoms 900 MHz et GSM-R un peu comme dans le rapport ECC 318 en affichant différemment les sites d’opérateurs et ceux de la SNCF. Pour éviter de faire des jointures comme précédemment, on va utiliser le bitmask pour sélectinner toutes les entrées de la table sites
dont le(s) bit(s) des technos sélectionnées est à 1 :
- dans le cas des réseaux mobiles, on va choisir {“GSM 900”, “LTE 900”, “UMTS 900”}, dont les ID sont respectivement {32, 41, 65} (et commencent à 1) ce qui donne bitmask1=232-1+241-1=1101659111424 et bitmask2=265-1-53=2048
- pour le RMR, seule la techno GSM-R (ID 33) est déployée pour le moment en attendant le le FRMCS) ce qui donne bitmask1=233-1=4294967296 (et bitmask2=0)
gsmr_sites = pd.read_sql_query("select * from sites where (tech_bitmask1 & 4294967296)>0", cnx)
gdf_gsmr = gpd.GeoDataFrame(gsmr_sites, geometry=gpd.points_from_xy(x=gsmr_sites.lon, y=gsmr_sites.lat), crs=4326)
rop_sites = pd.read_sql_query("""select * from sites
where ((tech_bitmask1 & 1101659111424)>0 or (tech_bitmask2 & 2048)>0)
and (lat>42 and lat<52 and lon>-5 and lon<9)""", cnx)
gdf_rop = gpd.GeoDataFrame(rop_sites, geometry=gpd.points_from_xy(x=rop_sites.lon, y=rop_sites.lat), crs=4326)
m=gdf_rop.explore(style_kwds=dict(color="black", weight=1, opacity=0.4, s=1))
gdf_gsmr.explore(m=m, style_kwds=dict(color="orange", weight=2, opacity=1, s=2))
on voit clairement sur la carte le tracé du GSM-R (en orange) suivant les lignes de chemin de fer, et la nature relativement uniformément dispersée des sites IMT 900 MHz.