# Vérifier si 'pacman' est installé; l'installer s'il manque
if (!requireNamespace("pacman", quietly = TRUE)) {
install.packages("pacman")
}
# Charger tous les packages requis avec pacman
pacman::p_load(
sf, # pour lire et manipuler les shapefiles
rio, # pour importer des fichiers Excel (remplace readxl)
dplyr, # pour manipuler les données
stringr, # pour nettoyer et formater les chaînes
ggplot2, # pour créer des graphiques
cli, # pour les sorties console stylisées
here # pour les chemins de fichiers multiplateformes
)
# sntutils contient plusieurs fonctions d'aide utiles pour la gestion
# et le nettoyage des données
if (!requireNamespace("sntutils", quietly = TRUE)) {
devtools::install_github("ahadi-analytics/sntutils")
}
# pour analyser les coordonnées
if (!requireNamespace("parzer", quietly = TRUE)) {
remotes::install_github("ropensci/parzer")
}Fusion des shapefiles avec des données tabulaires
Intermédiaire
Vue d’ensemble
Dans le cadre de la SNT, la dimension spatiale est centrale, car tout le processus vise à appuyer des évaluations et des recommandations adaptées aux niveaux infranationaux. L’objectif est de refléter les hétérogénéités locales de transmission du paludisme, de couverture des interventions et de capacité du système de santé. Pour cela, les données doivent être correctement reliées à des unités géographiques comme les districts ou les chiefdoms. Chaque intrant principal, des estimations de PfPR et indicateurs de surveillance de routine aux données environnementales et de campagne, est associé à un lieu. Notre capacité à stratifier la charge, évaluer la performance du programme et prioriser les actions dépend d’une liaison spatiale propre et cohérente.
La fusion de données tabulaires avec des limites spatiales associe des valeurs telles que l’incidence, la couverture ou les classifications de risque aux bonnes unités administratives. Ce processus permet la cartographie, la stratification et la prise de décision. De nombreuses étapes du flux de travail SNT dépendent de la fusion spatiale : les surfaces modélisées comme le PfPR et les covariables environnementales sont superposées aux limites administratives pendant la stratification; les indicateurs de routine issus de DHIS2 sont joints aux shapefiles de districts pour l’analyse de la charge; les données de campagne sont reliées aux zones géographiques pour repérer les lacunes de couverture. Une structure spatiale cohérente garantit que les données provenant de différentes sources s’intègrent correctement, soutient l’alignement entre intrants et produit des cartes, résumés et résultats de ciblage utilisables par le programme.
Les shapefiles constituent la base de toutes les composantes du processus SNT; il faut donc des méthodes fiables pour les joindre à d’autres jeux de données. Les différences de nommage, de formatage ou de précision des coordonnées provoquent souvent des fusions échouées ou inexactes. Cette section montre comment préparer les jeux de données pour la jointure, choisir les méthodes de jointure appropriées et valider les résultats. Ces techniques forment le socle des flux de travail d’analyse spatiale que nous utiliserons comme référence dans toute la bibliothèque de code.
Pour des informations de contexte sur les shapefiles et des liens vers tout le contenu sur les données spatiales de la bibliothèque de code SNT, y compris les rasters et les données ponctuelles, consultez la page Vue d’ensemble des données spatiales.
- Intégrer des données spatiales et tabulaires à l’aide de méthodes de jointure attributaire et fondée sur les coordonnées
- Résoudre les incohérences de noms, les problèmes de coordonnées et les désalignements de CRS par une validation systématique
- Appliquer des contrôles diagnostiques pour garantir une liaison spatiale complète et exacte
- Gérer les cas limites, notamment les jointures avec tampon, la conversion DMS et les problèmes de précision des limites
- Valider les résultats à l’aide de techniques de visualisation
- Enregistrer les sorties pour les analyses ultérieures
Comprendre les jointures spatiales
Avant d’analyser ou de visualiser des données dans les flux de travail SNT, nous devons les relier aux bonnes limites géographiques. Ce processus, appelé jointure spatiale, connecte des jeux de données tabulaires (comme des indicateurs de routine, des résultats d’enquêtes ou des registres d’établissements de santé) aux shapefiles qui définissent les régions, districts ou autres unités administratives. Une jointure exacte garantit que tous les indicateurs sont correctement alignés sur la géographie pour l’analyse et la cartographie. Cette section couvre les deux principaux types de jointures utilisés dans la SNT : les jointures attributaires et les jointures fondées sur les coordonnées. Elle explique quand utiliser chaque méthode et met en évidence les pièges courants. Ces méthodes de jointure fournissent la base de l’analyse spatiale dans tout ce guide.
Jointure par noms administratifs (jointures attributaires)
Dans la SNT, nous relions couramment les données tabulaires aux shapefiles en faisant correspondre les noms d’unités administratives, comme region, district ou chiefdom, au moyen d’une jointure attributaire. Cette méthode est nécessaire lorsque les jeux de données ne contiennent pas de coordonnées, car de nombreux indicateurs de routine, résultats d’enquêtes et résumés de campagnes sont organisés par nom. Lorsque les noms correspondent parfaitement entre les données tabulaires et le shapefile, les valeurs telles que l’incidence ou la couverture sont correctement rattachées aux unités géographiques, ce qui permet une analyse tenant compte de l’espace. De petites différences d’orthographe, de formatage ou de ponctuation peuvent rompre la jointure ou provoquer des correspondances incorrectes. Un alignement soigneux des noms garantit des résultats valides.
Quand utiliser les jointures attributaires
Utilisez cette méthode lorsque :
- Le jeu de données tabulaire ne contient pas de coordonnées spatiales
- Les noms administratifs sont relativement propres, standardisés et à jour
- Vous travaillez avec des données de routine ou d’enquête organisées par géographie
- Une table de correspondance existe pour résoudre les incohérences
Problèmes courants qui bloquent les jointures
Les jointures attributaires échouent souvent à cause d’incohérences subtiles dans la clé de jointure, c’est-à-dire la colonne utilisée pour faire correspondre les enregistrements entre jeux de données. Lorsque les noms utilisés comme clés de jointure diffèrent même légèrement entre les données tabulaires et le shapefile, les jointures échouent ou renvoient des résultats incorrects. Les problèmes courants qui compromettent les jointures incluent :
- Incohérences d’orthographe :
KailahunvsKialohun - Différences de casse :
BOvsBo - Espaces supplémentaires ou caractères invisibles :
BovsBoou doubles espaces - Ponctuation ou caractères spéciaux :
Bo.vsBo,Western-AreavsWestern Area - Accents:
BonthévsBonthe,PréfecturevsPrefecture - Abréviations et suffixes :
KonovsKono District,Western UrbanvsWestern Area Urban - Incohérences d’encodage : des noms visuellement identiques ne correspondent pas en raison de l’encodage sous-jacent des caractères, comme
UTF-8,Windows-1252ouISO-8859-1
Même lorsque les noms semblent corrects, des différences invisibles de formatage ou d’encodage peuvent rompre les jointures sans message explicite. Cela produit des zones manquantes sur les cartes, des résumés incomplets ou des sorties mal alignées. Une analyse spatiale fiable dans la SNT exige des noms propres et standardisés ainsi qu’une validation attentive des jointures pour garantir que tous les enregistrements correspondent correctement.
Jointure par coordonnées (jointures basées sur les coordonnées)
Une autre méthode pour relier des données tabulaires aux shapefiles dans la SNT utilise des coordonnées géographiques (des valeurs de latitude et longitude qui indiquent des emplacements précis). Cette jointure fondée sur les coordonnées relie chaque point de données à l’unité administrative dont la limite le contient physiquement. La méthode repose sur la position géographique. Chaque point doit se trouver à l’intérieur du polygone correspondant, comme un district ou un chiefdom, pour que la jointure réussisse. Si le point tombe à l’extérieur, même de peu à cause d’une erreur GPS ou d’une imprécision des limites, la jointure échoue ou classe mal l’emplacement. Des jointures exactes exigent des données de coordonnées bien alignées, des géométries valides et un système de référence de coordonnées (CRS) commun entre les jeux de points et de polygones.
Les jointures fondées sur les coordonnées sont nécessaires lorsque l’on travaille avec des données géoréférencées, comme les registres d’établissements de santé, les sites de surveillance environnementale ou les emplacements de grappes d’enquête. Comme elles reposent sur la position spatiale plutôt que sur la correspondance textuelle, elles évitent de nombreux problèmes observés dans les jointures par noms. Nous devons néanmoins assurer la validité spatiale et la cohérence entre les jeux de données.
Quand utiliser les jointures par coordonnées
Utilisez cette méthode lorsque :
- Le jeu de données tabulaire contient des valeurs valides de latitude et longitude
- Vous affectez des données ponctuelles (établissements, grappes d’enquête) à des zones administratives
- Les noms administratifs sont manquants, peu fiables ou formatés de manière incohérente
- Vous travaillez avec des sources spatiales brutes, comme des enquêtes coordonnées par GPS, des shapefiles avec entités ponctuelles ou des registres géolocalisés
Problèmes courants qui bloquent les jointures
Les jointures par coordonnées semblent simples, mais plusieurs problèmes subtils entraînent des échecs inattendus, des correspondances incorrectes ou des points exclus. Comprendre ces difficultés permet d’assurer une liaison spatiale exacte :
Incohérences de CRS (Coordinate Reference Systems) : l’erreur la plus fréquente survient lorsque les données ponctuelles et les shapefiles utilisent des systèmes de référence de coordonnées différents. Si l’un utilise des coordonnées latitude-longitude comme WGS84 (EPSG:4326) et l’autre des coordonnées projetées comme UTM, les jointures spatiales produisent des résultats incorrects ou manquants si les données ne sont pas correctement alignées.
Surveiller les doublons : si plusieurs points se trouvent au même emplacement (établissements superposés), la jointure produit des comptes gonflés. Envisagez une déduplication avant l’agrégation.
Lacunes du shapefile ou géométries invalides : des limites spatiales avec erreurs topologiques (fragments, lacunes ou chevauchements) provoquent des échecs de jointure. Si un point tombe dans l’une de ces erreurs, il ne sera affecté à aucune unité.
Points proches des limites : des points GPS réels se situent parfois près des limites administratives, et de petites différences entre la limite réelle et sa représentation numérique peuvent entraîner des classifications erronées. Cela se produit souvent dans les zones frontalières ou lorsque les polygones ont été simplifiés.
Coordonnées hors limites : certains points tombent entièrement en dehors de l’étendue du shapefile. Cela peut provenir de relevés GPS incorrects ou manquants comme lat=0 et long=0, de valeurs mal formées comme des coordonnées inversées, ou de fichiers de limites incompatibles, obsolètes ou agrégés.
Précision des coordonnées : l’arrondi ou la troncature des valeurs de latitude/longitude affecte le placement des points, surtout près d’unités petites ou de forme irrégulière.
Incohérence de datum : même lorsque les étiquettes CRS correspondent, les datums sous-jacents (WGS84 vs NAD83) peuvent différer légèrement, causant de petits décalages spatiaux qui influencent le polygone contenant un point.
Conseils pour réussir les jointures par coordonnées
Pour assurer des jointures spatiales exactes à partir des coordonnées, suivez ces bonnes pratiques :
Harmoniser le CRS : reprojetez toujours les données ponctuelles et le shapefile dans le même CRS avant d’effectuer la jointure. WGS84 (EPSG:4326) sert de standard pour les coordonnées GPS, mais les systèmes projetés comme UTM offrent une meilleure précision pour les analyses de petites zones.
Inspecter les points non appariés : après la jointure, recherchez les enregistrements qui n’ont correspondu à aucun polygone. Examinez-les individuellement ou visualisez-les sur une carte pour détecter des valeurs aberrantes ou des problèmes systématiques.
Utiliser les tampons avec prudence : si de nombreux points tombent juste à l’extérieur des limites, évaluez si un petit tampon autour des polygones est justifié. Les tampons aident à récupérer les points proches des limites, mais introduisent de l’ambiguïté dans les zones à limites denses.
Valider avec des données connues : lorsque des noms administratifs ou des métadonnées existent, comparez les jointures fondées sur les coordonnées avec les jointures par noms ou des listes de référence. Cela est particulièrement utile pour repérer des désalignements à grande échelle.
Standardiser le format des coordonnées : assurez-vous que toutes les valeurs lat/lon sont numériques, non nulles et exprimées en degrés décimaux avec une précision cohérente. Évitez les formats DMS (degrés-minutes-secondes) ou autres formats non standard.
Vérification visuelle : pour les sorties sensibles, cartographiez les données jointes et vérifiez visuellement que chaque point apparaît dans le bon polygone. C’est particulièrement important lors de la préparation de figures destinées aux décideurs.
Les jointures fondées sur les coordonnées sont des outils efficaces dans la boîte à outils SNT. Elles nous permettent de travailler directement avec des données spatiales brutes, de contourner les incohérences de noms et d’obtenir une agrégation géographique exacte. Lorsqu’elles sont appliquées avec soin, elles offrent une méthode fiable pour cartographier, résumer et interpréter des données localisées dans tout le flux de travail SNT.
Les jointures spatiales constituent une étape récurrente et fondamentale du flux de travail SNT. Après l’assemblage et le nettoyage des données, nous relions chaque jeu de données aux bonnes unités géographiques pour la stratification, la visualisation ou la modélisation. Sans jointures réussies, il est impossible de cartographier, d’agréger ou d’interpréter correctement les indicateurs par lieu. Que les données proviennent d’enquêtes, d’établissements de santé ou de systèmes de routine, nous devons d’abord les aligner spatialement avant toute analyse ou production de sortie significative. Cette section établit les bases des processus de jointure spatiale utilisés dans tout le guide.
Étape par étape
Cette section guide les analystes dans la fusion de données tabulaires avec des shapefiles et la production de différents types de cartes. Nous illustrons à la fois les jointures par noms et les jointures par coordonnées à l’aide de centroïdes d’établissements. L’exemple utilise les shapefiles de limites administratives de la Sierra Leone avec des données DHIS2, mais les mêmes principes s’appliquent à n’importe quelles limites spatiales et à tout jeu de données tabulaire. Pour confirmer que les jointures fonctionnent comme prévu, nous visualisons le nombre d’enfants de moins de 5 ans confirmés avec paludisme par adm3.
Étape 1 : Installer et charger les packages
Commencez par installer et charger les packages nécessaires au traitement des données spatiales, à la lecture de différents formats de fichiers, à la manipulation des données et à la visualisation.
Pour adapter le code:
- Ne modifiez rien dans le code ci-dessus
Installez les packages dans votre terminal s’ils ne sont pas déjà installés. Si vous avez besoin d’aide pour installer les packages, consultez la page Bien démarrer.
from pathlib import Path
import re
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pyreadr
from matplotlib import colors
from mpl_toolkits.axes_grid1 import make_axes_locatable
from pyprojroot import here
from sntutils.geo import prep_geonames
def read_rds(path):
"""Lire un fichier RDS à objet unique comme objet pandas."""
result = pyreadr.read_r(str(path))
return next(iter(result.values()))
def cli_header(message):
print(f"\n{message}")
def cli_info(message):
print(f"INFO: {message}")
def cli_success(message):
print(f"SUCCESS: {message}")
def cli_warning(message):
print(f"WARNING: {message}")
def anti_join(left, right, on):
"""Renvoyer les lignes de gauche sans clé correspondante à droite."""
right_keys = right[on].drop_duplicates()
return (
left.merge(right_keys, on=on, how="left", indicator=True)
.loc[lambda x: x["_merge"] == "left_only"]
.drop(columns="_merge")
)
def geometry_key(series):
"""Créer des clés WKB stables pour compter les géométries distinctes."""
return series.apply(lambda geom: geom.wkb if geom is not None else None)
def detect_dms_value(value):
"""Détecter les chaînes de coordonnées en degrés-minutes-secondes."""
return bool(re.search(r"[°º].*['′\"″]", str(value)))
def parse_coord(value):
"""Analyser des valeurs latitude/longitude décimales ou DMS en degrés décimaux."""
if pd.isna(value):
return np.nan
text = str(value).strip()
try:
return float(text)
except ValueError:
pass
# extraire d'abord l'indicateur d'hémisphère pour que le `\D*` gourmand
# du regex DMS principal ci-dessous ne l'absorbe pas. sans cela, des
# chaînes comme "11°8'37.08\"W" seraient analysées en +11,14 au lieu de
# -11,14, plaçant le point hors des limites de longitude du pays.
hemi_match = re.search(r"[NSEW]", text, flags=re.IGNORECASE)
hemi = hemi_match.group(0).upper() if hemi_match else ""
match = re.search(
r"(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)?\D*(\d+(?:\.\d+)?)?",
text,
flags=re.IGNORECASE
)
if not match:
return np.nan
deg = float(match.group(1))
minute = float(match.group(2) or 0)
second = float(match.group(3) or 0)
decimal = deg + minute / 60 + second / 3600
return -decimal if hemi in {"S", "W"} else decimal
def plot_validation_maps(data, value_col, method_col, title):
"""Créer des cartes de validation côte à côte avec une échelle de couleurs commune."""
methods = list(data[method_col].dropna().unique())
# protection contre une liste de méthodes vide pour éviter que
# matplotlib ne lève `Number of columns must be a positive integer,
# not 0`. Cela se produit lorsqu'un filtre en amont (par exemple un
# filtre d'année) supprime toutes les lignes.
if len(methods) == 0:
raise ValueError(
f"Aucune valeur trouvée dans `{method_col}` après dropna(); "
"vérifier qu'au moins une ligne survit aux filtres en amont."
)
fig, axes = plt.subplots(1, len(methods), figsize=(10, 6), constrained_layout=True)
if len(methods) == 1:
axes = [axes]
vmin = data[value_col].min(skipna=True) / 1000
vmax = data[value_col].max(skipna=True) / 1000
norm = colors.Normalize(vmin=vmin, vmax=vmax)
cmap = plt.get_cmap("magma_r")
for ax, method in zip(axes, methods):
# fixer l'aspect sur l'axe et désactiver le calcul d'aspect de
# geopandas pour qu'un sous-ensemble dégénéré ne déclenche pas
# `aspect must be finite and positive`
ax.set_aspect("equal")
subset = data.loc[data[method_col] == method].copy()
subset["plot_value"] = subset[value_col] / 1000
subset.plot(
ax=ax,
column="plot_value",
cmap=cmap,
norm=norm,
# matplotlib ne reconnaît pas le « grey20 » de R ; utiliser
# l'équivalent hexadécimal valide pour les deux moteurs
edgecolor="#333333",
linewidth=0.15,
missing_kwds={"color": "lightgrey"},
aspect=None,
)
ax.set_title(method, fontsize=10, fontweight="bold")
ax.set_axis_off()
fig.suptitle(title, fontsize=14)
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
fig.colorbar(
sm,
ax=axes,
orientation="horizontal",
fraction=0.04,
pad=0.05,
label="Cas confirmés (moins de 5 ans, milliers)",
)
return figPour adapter le code:
- Gardez ces imports et fonctions d’aide au début du flux de travail Python. Les chunks Python suivants les utilisent pour les imports RDS, les contrôles de correspondance, l’analyse des coordonnées et les cartes.
Lors de la fusion de données tabulaires avec des shapefiles, la réussite dépend d’une correspondance parfaite des clés de jointure entre les jeux de données. Avant de continuer :
- Identifier les colonnes de jointure : déterminez quelles colonnes contiennent les noms d’unités administratives dans le shapefile et dans les données tabulaires
- Standardiser les noms : nettoyez et standardisez les noms d’unités administratives pour garantir des correspondances exactes
- Vérifier l’exhaustivité : vérifiez que toutes les zones géographiques de l’analyse apparaissent dans les deux jeux de données
- Tester la fusion : examinez toujours le jeu de données fusionné pour confirmer que toutes les zones ont été correctement reliées
Remarque : si une valeur dans l’une ou l’autre colonne ne fusionne pas malgré tous les efforts diagnostiques, consultez l’équipe SNT pour obtenir des conseils.
Étape 2 : Importer et examiner les données
Importez les deux jeux de données et examinez leur structure afin d’identifier les clés de jointure appropriées. Cette étape importante permet de comprendre les données avant de tenter la fusion.
Commencez par importer le shapefile et les données tabulaires pertinentes. Pour plus d’efficacité, cet exemple utilise des données spatiales prétraitées enregistrées sous forme de fichiers RDS. En pratique, on commence généralement avec les composants bruts du shapefile (.shp, .shx, .dbf), comme montré dans les sections précédentes.
Afficher le code
# importer le shapefile
shp_adm3 <- readRDS(
here::here(
"1.1_foundational",
"1.1a_administrative_boundaries",
"processed",
"sle_spatial_adm3_2021.rds"
)
) |>
# garantir que la sortie reste un objet sf valide pour les jointures suivantes
sf::st_as_sf()
# chemin des données de surveillance
surv_path <- here::here("1.2_epidemiology", "1.2a_routine_surveillance")
# obtenir les données sur le paludisme
dhis2_df <- rio::import(
here::here(
surv_path,
"raw",
"clean_malaria_routine_data_final.rds"
)
)
# vérifier les données DHIS2 et le shapefile
cli::cli_h2("Données DHIS2")
dhis2_df |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)
cli::cli_h2("Shapefile Adm3")
shp_adm3 |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)Pour adapter le code:
- Lignes 3 à 8 et 18 à 22 : adaptez les chemins
here::here()à la structure de vos dossiers pour le shapefile et les données tabulaires à fusionner.
Afficher le code
# importer le shapefile
spat_path = here("1.1_foundational/1.1a_administrative_boundaries")
shp_adm3 = (
gpd.read_file(Path(spat_path) / "raw" / "Chiefdom2021.shp")
.assign(adm0="SIERRA LEONE")
.rename(columns={
"FIRST_REGI": "adm1",
"FIRST_DNAM": "adm2",
"FIRST_CHIE": "adm3",
})
[["adm0", "adm1", "adm2", "adm3", "geometry"]]
)
# chemin des données de surveillance
surv_path = here("1.2_epidemiology/1.2a_routine_surveillance")
# obtenir les données sur le paludisme
dhis2_df = read_rds(Path(surv_path) / "raw" / "clean_malaria_routine_data_final.rds")
# vérifier les données DHIS2 et le shapefile
cli_header("Données DHIS2")
dhis2_df[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)
cli_header("Shapefile Adm3")
shp_adm3[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)Pour adapter le code:
- Lignes 3 à 13 et 24 à 26 : adaptez les chemins
here()à la structure de vos dossiers pour le shapefile et les données tabulaires à fusionner.
En examinant l’extrait DHIS2 complet, le principal problème se situe au niveau adm1. Dans DHIS2, adm1 n’est pas la région comme dans le shapefile. Il répète plutôt le nom du district, tandis que adm2 porte la structure de conseil, comme District Council, City Council ou Municipal Council. À l’inverse, le shapefile suit une hiérarchie géographique : adm1 = région, adm2 = district, adm3 = chiefdom. Cette incohérence signifie que les données DHIS2 ne contiennent pas de niveau régional approprié, et que leurs noms sont programmatiques plutôt que purement géographiques.
Étape 3 : Identifier et corriger les incohérences de hiérarchie
La correction consiste à nettoyer et standardiser les noms en les convertissant en majuscules et en supprimant les suffixes comme District et Chiefdom. Une fois les noms harmonisés, le niveau régional peut être reconstruit en regroupant les districts dans leurs catégories supérieures correctes.
Remarque : l’incohérence de hiérarchie illustrée ici (adm1 qui répète les noms de districts) est propre aux données DHIS2 de la Sierra Leone. D’autres pays peuvent avoir des structures hiérarchiques différentes; examinez toujours la structure de vos propres données avant d’appliquer ces transformations.
Afficher le code
# nettoyage initial
dhis2_df <- dhis2_df |>
dplyr::mutate(
adm0 = toupper(adm0),
adm2 = toupper(adm1),
adm3 = toupper(adm3),
adm2 = stringr::str_replace(
adm2,
stringr::fixed(" DISTRICT"),
""
),
adm3 = stringr::str_replace(
adm3,
stringr::fixed(" CHIEFDOM"),
""
)
) |>
dplyr::mutate(
adm1 = dplyr::case_when(
adm2 %in% c("KAILAHUN", "KENEMA", "KONO") ~
"EASTERN",
adm2 %in% c("BOMBALI", "FALABA", "KOINADUGU", "TONKOLILI") ~
"NORTH EAST",
adm2 %in% c("KAMBIA", "KARENE", "PORT LOKO") ~
"NORTH WEST",
adm2 %in% c("BO", "BONTHE", "MOYAMBA", "PUJEHUN") ~
"SOUTHERN",
adm2 %in% c("WESTERN AREA RURAL", "WESTERN AREA URBAN") ~
"WESTERN"
)
)
# vérifier les données DHIS2 et le shapefile
cli::cli_h2("Données DHIS2 (nettoyage initial) ")
dhis2_df |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)
cli::cli_h2("Shapefile Adm3")
shp_adm3 |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)Pour adapter le code:
- Ligne 2 : assurez-vous que le dataframe d’entrée contient les colonnes requises (
adm0,adm1,adm3) avant d’appliquer la transformation. Sinon, ajustez les noms de colonnes en conséquence. - Lignes 4 à 16 : adaptez les règles
toupper()etstr_replace()si votre extrait DHIS2 utilise d’autres suffixes ou une autre capitalisation. Par exemple, d’autres pays peuvent utiliserMUNICIPALITYau lieu deDISTRICTouCHIEFDOM. - Lignes 19 à 30 : adaptez la correspondance
case_when()pour refléter la structure régionale de votre pays. La correspondance affichée est propre à la Sierra Leone et ne se généralise pas ailleurs. Remplacez-la par les regroupements district-région corrects pour votre contexte.
Afficher le code
region_lookup = {
"KAILAHUN": "EASTERN",
"KENEMA": "EASTERN",
"KONO": "EASTERN",
"BOMBALI": "NORTH EAST",
"FALABA": "NORTH EAST",
"KOINADUGU": "NORTH EAST",
"TONKOLILI": "NORTH EAST",
"KAMBIA": "NORTH WEST",
"KARENE": "NORTH WEST",
"PORT LOKO": "NORTH WEST",
"BO": "SOUTHERN",
"BONTHE": "SOUTHERN",
"MOYAMBA": "SOUTHERN",
"PUJEHUN": "SOUTHERN",
"WESTERN AREA RURAL": "WESTERN",
"WESTERN AREA URBAN": "WESTERN",
}
# nettoyage initial
dhis2_df = dhis2_df.copy()
dhis2_df["adm0"] = dhis2_df["adm0"].str.upper()
dhis2_df["adm2"] = dhis2_df["adm1"].str.upper().str.replace(" DISTRICT", "", regex=False)
dhis2_df["adm3"] = dhis2_df["adm3"].str.upper().str.replace(" CHIEFDOM", "", regex=False)
dhis2_df["adm1"] = dhis2_df["adm2"].map(region_lookup)
# vérifier les données DHIS2 et le shapefile
cli_header("Données DHIS2 (nettoyage initial)")
dhis2_df[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)
cli_header("Shapefile Adm3")
shp_adm3[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)Pour adapter le code:
- Ligne 25 : assurez-vous que le dataframe d’entrée contient les colonnes requises (
adm0,adm1,adm3) avant d’appliquer la transformation. Sinon, ajustez les noms de colonnes en conséquence. - Lignes 26 à 28 : adaptez les règles
.str.upper()et.str.replace()si votre extrait DHIS2 utilise d’autres suffixes ou une autre capitalisation. - Lignes 8 à 23 : adaptez la correspondance
region_lookuppour refléter la structure régionale de votre pays. La correspondance affichée est propre à la Sierra Leone et ne se généralise pas ailleurs.
Étape 4 : Joindre par noms administratifs (jointures attributaires)
Étape 4.1 : Évaluer si les deux jeux de données peuvent être joints
Les sorties de l’étape 3 montrent maintenant que les noms administratifs (adm0, adm1, adm2 et adm3) des deux jeux de données suivent un format cohérent : majuscules et organisation dans la même hiérarchie. Avec les régions au niveau supérieur, les districts au niveau intermédiaire et les chiefdoms au troisième niveau, les deux jeux de données sont désormais alignés. Cet alignement permet de tenter une jointure interne sur ces champs pour fusionner le shapefile avec le jeu de données DHIS2.
Afficher le code
# obtenir les noms administratifs distincts de DHIS2
dhis2_admins <-
dhis2_df |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# supprimer la géométrie pour les jointures par noms seulement
shp_names <-
shp_adm3 |>
sf::st_drop_geometry() |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# jointure interne pour compter les correspondances (polygones ∩ dhis2)
shp_with_dhis2 <-
shp_names |>
dplyr::inner_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# polygones sans admin DHIS2 correspondante (shapefile ▷ dhis2)
polygons_without_dhis2 <-
shp_names |>
dplyr::anti_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# admins DHIS2 sans polygone correspondant (dhis2 ▷ shapefile)
dhis2_without_polygons <-
dhis2_admins |>
dplyr::anti_join(
shp_names,
by = c("adm0", "adm1", "adm2", "adm3")
)
# comptes pour des diagnostics clairs
total_polygons <- nrow(shp_adm3)
matched_polygons <- nrow(shp_with_dhis2)
polygons_no_match <- nrow(polygons_without_dhis2)
dhis2_no_match <- nrow(dhis2_without_polygons)
# rapport
cli::cli_h2("Diagnostics de jointure admin (adm0 à adm3)")
# perspective des polygones
cli::cli_alert_success(
"Unités administratives du shapefile (uniques) : {total_polygons}"
)
cli::cli_alert_success(
"Correspondant avec DHIS2 : {matched_polygons}"
)
# perspective DHIS2
cli::cli_alert_warning(
"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}"
)
cli::cli_alert_warning(
"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}"
)
# aperçus
if (nrow(dhis2_without_polygons) > 0) {
cli::cli_h2("Admins DHIS2 sans polygone correspondant (exemple)")
dhis2_without_polygons |> head(10)
}
if (nrow(polygons_without_dhis2) > 0) {
cli::cli_h2("Polygones sans correspondance DHIS2 (exemple)")
polygons_without_dhis2 |> head(10)
}Pour adapter le code:
- Lignes 4, 10, 17, 25 et 33 : remplacez les clés de jointure (
adm0,adm1,adm2,adm3) par les noms de colonnes réellement utilisés dans votre shapefile et votre jeu de données DHIS2. - Lignes 4, 10, 17, 25 et 33 : si vous utilisez moins de niveaux administratifs (seulement
adm1etadm2), mettez à jour toutes les lignes de jointure et de comparaison pour refléter uniquement ces niveaux.
Afficher le code
join_keys = ["adm0", "adm1", "adm2", "adm3"]
# obtenir les noms administratifs distincts de DHIS2
dhis2_admins = dhis2_df[join_keys].drop_duplicates()
# supprimer la géométrie pour les jointures par noms seulement
shp_names = shp_adm3.drop(columns="geometry")[join_keys].drop_duplicates()
# jointure interne pour compter les correspondances
shp_with_dhis2 = shp_names.merge(dhis2_admins, on=join_keys, how="inner")
# polygones sans admin DHIS2 correspondante
polygons_without_dhis2 = anti_join(shp_names, dhis2_admins, on=join_keys)
# admins DHIS2 sans polygone correspondant
dhis2_without_polygons = anti_join(dhis2_admins, shp_names, on=join_keys)
# comptes pour des diagnostics clairs
total_polygons = len(shp_adm3)
matched_polygons = len(shp_with_dhis2)
polygons_no_match = len(polygons_without_dhis2)
dhis2_no_match = len(dhis2_without_polygons)
# rapport
cli_header("Diagnostics de jointure admin (adm0 à adm3)")
cli_success(f"Unités administratives du shapefile (uniques) : {total_polygons}")
cli_success(f"Correspondant avec DHIS2 : {matched_polygons}")
cli_warning(f"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}")
cli_warning(f"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}")
# aperçus
if len(dhis2_without_polygons) > 0:
cli_header("Admins DHIS2 sans polygone correspondant (exemple)")
print(dhis2_without_polygons.head(10))
if len(polygons_without_dhis2) > 0:
cli_header("Polygones sans correspondance DHIS2 (exemple)")
print(polygons_without_dhis2.head(10))Pour adapter le code:
- Ligne 1 : remplacez les clés de jointure (
adm0,adm1,adm2,adm3) par les noms de colonnes réellement utilisés dans votre shapefile et votre jeu de données DHIS2. - Si vous utilisez moins de niveaux administratifs, mettez à jour
join_keysune seule fois et réutilisez-le dans toutes les lignes de comparaison.
Sur 208 unités administratives uniques dans les données DHIS2, 135 ont été appariées avec succès, laissant 73 unités non appariées. La plupart des incohérences proviennent de petites différences d’espacement, d’orthographe ou de formatage entre les différents niveaux administratifs.
Pour obtenir une correspondance complète, les 61 unités DHIS2 restantes non appariées doivent être nettoyées et alignées sur les conventions de nommage du shapefile.
Étape 4.2 : Résoudre les incohérences avec prep_geonames()
Lorsque l’on travaille avec des données DHIS2 et des shapefiles administratifs, les noms ne s’alignent souvent pas parfaitement. De petites différences, comme les accents, les espaces supplémentaires ou les variantes orthographiques, peuvent bloquer les jointures et produire des enregistrements non appariés. La résolution de ces incohérences est nécessaire pour une agrégation et une analyse spatiale fiables.
Les incohérences de noms administratifs peuvent être résolues à l’aide de deux approches complémentaires :
Appariement approximatif (non interactif) Attribue automatiquement les correspondances les plus proches à partir de scores de similarité de chaînes. Le prétraitement supprime les parenthèses, normalise la casse et la ponctuation, et gère les accents. Voir la section Correspondance approximative des noms entre jeux de données pour plus de détails.
Harmonisation interactive (
prep_geonames()) Utilise les mêmes étapes de prétraitement, puis suggère des correspondances probables à confirmer par l’utilisateur. Les décisions sont stockées et réutilisées lors des exécutions futures.
Astuce : pour les jeux de données complexes, appliquez d’abord l’appariement approximatif afin de résoudre la plupart des incohérences, puis utilisez l’harmonisation interactive pour les cas restants.
prep_geonames() (disponible sous sntutils::prep_geonames() en R et sntutils.geo.prep_geonames() en Python) harmonise la hiérarchie administrative d’un jeu de données cible (par exemple DHIS2) avec un jeu de données de référence (par exemple un shapefile). La fonction travaille de manière hiérarchique :
- Commencer par
adm0(pays). - Passer ensuite par
adm1,adm2etadm3. - À chaque niveau, vérifier si les valeurs cibles existent dans la référence. Sinon, appliquer :
- la mise en majuscules et le retrait des espaces superflus
- la suppression des accents et de la ponctuation
- l’appariement par distance de chaînes (Levenshtein, Jaro-Winkler, etc.) pour suggérer les meilleures correspondances
Les unités non appariées sont renvoyées avec des suggestions classées par score de similarité. Le processus tient compte du contexte : les noms ne sont comparés qu’au sein de leur unité parente (par exemple, adm2 est comparé uniquement dans le même adm1). Cela réduit les faux positifs et maintient la cohérence géographique des correspondances.
Les corrections peuvent être appliquées de manière interactive ou scriptée. Pour fluidifier les flux de travail, utilisez l’argument cache_path pour enregistrer les décisions. Les corrections en cache sont automatiquement réutilisées sur les jeux de données mis à jour, ce qui garantit la cohérence entre les réexécutions et les membres de l’équipe.
La courte vidéo ci-dessous montre l’interface interactive. Les versions R et Python proposent le même flux d’harmonisation interactive :
Étape 4.3 : Vérifier la jointure finale avec le shapefile
Une fois tous les noms non appariés résolus avec prep_geonames(), les données DHIS2 sont maintenant alignées sur la structure du shapefile. Les corrections sont enregistrées dans le cache pour une réutilisation cohérente.
Vérifions maintenant que les données DHIS2 nettoyées correspondent entièrement au shapefile, en nous assurant qu’aucune unité non appariée ne subsiste.
Afficher le code
# obtenir les noms administratifs distincts de dhis2
dhis2_admins <-
dhis2_df_cleaned |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# drop geometry for name-only joins
shp_names <-
shp_adm3 |>
sf::st_drop_geometry() |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# jointure interne pour compter les correspondances (polygones ∩ dhis2)
shp_with_dhis2 <-
shp_names |>
dplyr::inner_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# polygones sans admin DHIS2 correspondante (shapefile ▷ dhis2)
polygons_without_dhis2 <-
shp_names |>
dplyr::anti_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# admins DHIS2 sans polygone correspondant (dhis2 ▷ shapefile)
dhis2_without_polygons <-
dhis2_admins |>
dplyr::anti_join(
shp_names,
by = c("adm0", "adm1", "adm2", "adm3")
)
# comptes pour des diagnostics clairs
total_polygons <- nrow(shp_adm3)
matched_polygons <- nrow(shp_with_dhis2)
polygons_no_match <- nrow(polygons_without_dhis2)
dhis2_no_match <- nrow(dhis2_without_polygons)
# rapport
cli::cli_h2("Diagnostics de jointure admin (adm0 à adm3)")
# perspective des polygones
cli::cli_alert_success(
"Unités administratives du shapefile (uniques) : {total_polygons}"
)
cli::cli_alert_success(
"Correspondant avec DHIS2 : {matched_polygons}"
)
# perspective DHIS2
cli::cli_alert_warning(
"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}"
)
cli::cli_alert_warning(
"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}"
)
# aperçus
if (nrow(dhis2_without_polygons) > 0) {
cli::cli_h2("Admins DHIS2 sans polygone correspondant (exemple)")
dhis2_without_polygons |> head(10)
}
if (nrow(polygons_without_dhis2) > 0) {
cli::cli_h2("Polygones sans correspondance DHIS2 (exemple)")
polygons_without_dhis2 |> head(10)
}Pour adapter le code:
- Lignes 4, 10, 17, 25 et 33 : remplacez les clés de jointure (
adm0,adm1,adm2,adm3) par les noms de colonnes réellement utilisés dans le shapefile et le jeu de données DHIS2. - Lignes 4, 10, 17, 25 et 33 : si vous utilisez moins de niveaux administratifs (par exemple, seulement
adm1etadm2), mettez à jour toutes les lignes de jointure et de comparaison pour refléter uniquement ces niveaux.
Afficher le code
join_keys = ["adm0", "adm1", "adm2", "adm3"]
# obtenir les noms administratifs distincts de dhis2 nettoyées
dhis2_admins = dhis2_df_cleaned[join_keys].drop_duplicates()
# supprimer la géométrie pour les jointures par noms seulement
shp_names = shp_adm3.drop(columns="geometry")[join_keys].drop_duplicates()
# jointure interne pour compter les correspondances
shp_with_dhis2 = shp_names.merge(dhis2_admins, on=join_keys, how="inner")
# polygones sans admin DHIS2 correspondante
polygons_without_dhis2 = anti_join(shp_names, dhis2_admins, on=join_keys)
# admins DHIS2 sans polygone correspondant
dhis2_without_polygons = anti_join(dhis2_admins, shp_names, on=join_keys)
total_polygons = len(shp_adm3)
matched_polygons = len(shp_with_dhis2)
polygons_no_match = len(polygons_without_dhis2)
dhis2_no_match = len(dhis2_without_polygons)
cli_header("Diagnostics de jointure admin (adm0 à adm3)")
cli_success(f"Unités administratives du shapefile (uniques) : {total_polygons}")
cli_success(f"Correspondant avec DHIS2 : {matched_polygons}")
cli_warning(f"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}")
cli_warning(f"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}")
if len(dhis2_without_polygons) > 0:
cli_header("Exemple d'unités administratives DHIS2 non appariées (10 premières)")
print(dhis2_without_polygons.head(10))
if len(polygons_without_dhis2) > 0:
cli_header("Exemple d'unités administratives du shapefile non appariées (10 premières)")
print(polygons_without_dhis2.head(10))Pour adapter le code:
- Ligne 1 : remplacez les clés de jointure (
adm0,adm1,adm2,adm3) par les noms de colonnes réellement utilisés dans le shapefile et le jeu de données DHIS2. - Si vous utilisez moins de niveaux administratifs, mettez à jour
join_keysune seule fois et réutilisez-le dans toutes les lignes de jointure et de comparaison.
Nous avons maintenant une correspondance complète des noms, et le jeu de données DHIS2 a été entièrement fusionné avec le shapefile, avec 208 unités adm3 dans les données DHIS2 appariées à leurs formes respectives. Cela garantit que toutes les analyses spatiales ultérieures seront alignées sur des limites administratives validées.
Étape 4.5 : Effectuer la jointure basée sur les noms
Maintenant que nous avons vérifié que tous les noms correspondent, effectuons la jointure réelle entre le shapefile et les données DHIS2 nettoyées :
Afficher le code
# joindre les données DHIS2 nettoyées au shapefile par noms administratifs
dhis2_df_coord_shp <- shp_adm3 |>
dplyr::left_join(
dhis2_df_cleaned,
by = c("adm0", "adm1", "adm2", "adm3")
)
# obtenir le nombre distinct de formes jointes aux données dhis2
dist_shapes <- dplyr::n_distinct(dhis2_df_coord_shp$geometry)
# vérifier le résultat
cli::cli_h2("Résultat de jointure basée sur les noms")
cli::cli_alert_success(
"{dist_shapes} polygones joints avec succès aux données DHIS2"
)Pour adapter le code:
- Ligne 4 : remplacez les clés de jointure (
adm0,adm1,adm2,adm3) par les noms de colonnes réellement utilisés dans votre shapefile et votre jeu de données.
Afficher le code
join_keys = ["adm0", "adm1", "adm2", "adm3"]
# joindre les données DHIS2 nettoyées au shapefile par noms administratifs
dhis2_df_coord_shp = shp_adm3.merge(
dhis2_df_cleaned,
on=join_keys,
how="left"
)
# obtenir le nombre distinct de formes jointes aux données dhis2
dist_shapes = geometry_key(dhis2_df_coord_shp.geometry).nunique()
# vérifier le résultat
cli_header("Résultat de jointure basée sur les noms")
cli_success(f"{dist_shapes} polygones joints avec succès aux données DHIS2")Pour adapter le code:
- Ligne 1 : remplacez les clés de jointure (
adm0,adm1,adm2,adm3) par les noms de colonnes réellement utilisés dans votre shapefile et votre jeu de données.
Étape 5 : Joindre par coordonnées (jointures basées sur les coordonnées)
Nous passons maintenant de l’appariement basé sur les noms aux jointures basées sur les coordonnées. Lorsque les noms administratifs sont manquants ou peu fiables, les coordonnées géographiques fournissent une alternative fiable (c’est-à-dire, si elles sont disponibles). Le jeu de données DHIS2 inclut la latitude et la longitude des établissements de santé, ce qui nous permet d’affecter spatialement les enregistrements aux unités administratives du shapefile. Nous calculons ensuite le centroïde des établissements dans chaque adm3 pour visualiser ultérieurement le nombre d’enfants de moins de 5 ans confirmés avec paludisme par adm3.
Étape 5.1 : Valider et examiner les coordonnées
Avant de procéder à la jointure, nous devons confirmer que les centroïdes au sein des limites adm3 sont valides, correctement formatés et se situent à l’intérieur de la zone d’étude. Les vérifications incluent :
- Vérifier que les colonnes de latitude et longitude existent et ne contiennent aucune valeur manquante ou nulle.
- Confirmer que les valeurs sont en degrés décimaux, et non en degrés-minutes-secondes ou autres formats.
- Vérifier que les longitudes se situent dans la plage correcte (par exemple, -13 à -10 pour la Sierra Leone).
- Détecter et corriger les coordonnées inversées (par exemple, lat/lon accidentellement inversées).
- Tracer pour inspecter visuellement si les points centroïdes se situent à l’intérieur de la limite du pays.
Vérifier les coordonnées manquantes
Commençons par vérifier la présence de valeurs de latitude ou longitude manquantes dans le jeu de données. Celles-ci doivent être traitées avant de poursuivre avec les jointures basées sur les coordonnées.
Pour adapter le code :
- Lignes 2 et 3 : remplacez
dhis2_dfpar le nom de votre jeu de données et mettez à jourlatetlonsi vos colonnes de coordonnées portent des noms différents.
Afficher le code
Pour adapter le code :
- Lignes 2 et 3 : remplacez
dhis2_dfpar le nom de votre jeu de données et mettez à jourlatetlonsi vos colonnes de coordonnées portent des noms différents.
Il semble qu’il n’y ait pas de coordonnées manquantes. Toutefois, si certains enregistrements manquent de coordonnées, nous pouvons les signaler pour examen et inspection manuelle.
Vérifier si les coordonnées sont en degrés décimaux
La plupart des jointures spatiales exigent des coordonnées en degrés décimaux tels que 7,7675. Mais certains enregistrements peuvent utiliser le format degrés-minutes-secondes tel que 7°46′3,93″N, ce qui causera des erreurs.
Avant de poursuivre, nous vérifions la présence de motifs DMS à l’aide d’une fonction de détection simple.
Afficher le code
# function to detect DMS format using common symbols for
# degrees, minutes, or seconds
detect_dms <- function(x) {
grepl("[°º].*['′\"″]", x)
}
# filter rows where lat or lon appears to be in DMS format
dms_detected <- dhis2_df |>
dplyr::filter(detect_dms(lat) | detect_dms(lon)) |>
dplyr::distinct(adm1, adm2, adm3, lat, lon)
# print summary
cli::cli_h2("DMS Coordinate Check")
cli::cli_alert_info(
"Number of rows with suspected DMS format: {nrow(dms_detected)}"
)
cat("\n")
dms_detectedPour adapter le code:
- Ligne 7 : remplacez
dhis2_dfpar le nom de votre jeu de données. - Lignes 8 et 9 : mettez à jour
latetlonsi vos colonnes de coordonnées portent des noms différents. - Ligne 9 : ajustez
adm1,adm2,adm3pour qu’ils correspondent à vos colonnes d’identifiant pertinentes pour le contexte.
Afficher le code
# filtrer les lignes où lat ou lon semble être au format DMS
dms_detected = (
dhis2_df.loc[
dhis2_df["lat"].apply(detect_dms_value)
| dhis2_df["lon"].apply(detect_dms_value),
["adm1", "adm2", "adm3", "lat", "lon"]
]
.drop_duplicates()
)
cli_header("Vérification des coordonnées DMS")
cli_info(f"Nombre de lignes avec format DMS suspecté : {len(dms_detected)}")
print(dms_detected)Pour adapter le code:
- Ligne 3 : remplacez
dhis2_dfpar le nom de votre jeu de données. - Lignes 4 et 5 : mettez à jour
latetlonsi vos colonnes de coordonnées portent des noms différents. - Ligne 6 : ajustez
adm1,adm2,adm3pour qu’ils correspondent à vos colonnes d’identifiant pertinentes pour le contexte.
Seulement 3 lignes ont été signalées comme utilisant le formatage DMS. Ces entrées incluent des unités de KONO, PUJEHUN et TONKOLILI, ce qui suggère que la majorité du jeu de données utilise déjà des degrés décimaux. Les enregistrements signalés seront convertis au format décimal à l’étape suivante.
Afficher le code
# check if DMS values were successfully converted
check_output <- dhis2_df |>
dplyr::filter(detect_dms(lat) | detect_dms(lon)) |>
dplyr::distinct(adm1, adm2, adm3, lat, lon) |>
dplyr::mutate(
lat_fixed = parzer::parse_lat(lat),
lon_fixed = parzer::parse_lon(lon)
)
# apply conversion to full dataset
dhis2_df_clean <- dhis2_df |>
dplyr::mutate(
lat = parzer::parse_lat(lat),
lon = parzer::parse_lon(lon)
)
# print summary of conversion
cli::cli_h2("DMS Coordinate Conversion Summary")
check_outputPour adapter le code:
- Lignes 2 et 17 : remplacez
dhis2_dfpar le nom de votre jeu de données. - Lignes 4–5 et 18–19 : mettez à jour
latetlonsi vos colonnes de coordonnées portent des noms différents.
Afficher le code
# vérifier si les valeurs DMS ont été converties avec succès
check_output = (
dhis2_df.loc[
dhis2_df["lat"].apply(detect_dms_value)
| dhis2_df["lon"].apply(detect_dms_value),
["adm1", "adm2", "adm3", "lat", "lon"]
]
.drop_duplicates()
.assign(
lat_fixed=lambda x: x["lat"].apply(parse_coord),
lon_fixed=lambda x: x["lon"].apply(parse_coord),
)
)
# appliquer la conversion au jeu de données complet
dhis2_df_clean = dhis2_df.copy()
dhis2_df_clean["lat"] = dhis2_df_clean["lat"].apply(parse_coord)
dhis2_df_clean["lon"] = dhis2_df_clean["lon"].apply(parse_coord)
cli_header("Résumé de la conversion des coordonnées DMS")
check_outputPour adapter le code:
- Lignes 2 et 17 : remplacez
dhis2_dfpar le nom de votre jeu de données. - Lignes 4 à 5 et 18 à 19 : mettez à jour
latetlonsi vos colonnes de coordonnées portent des noms différents.
Les colonnes lat_fixed et lon_fixed confirment que nos coordonnées au format DMS ont été analysées avec précision en degrés décimaux. Ces valeurs nettoyées sont maintenant stockées dans dhis2_df_clean.
Vérifier si les coordonnées sont inversées
Parfois, les valeurs de latitude et de longitude sont accidentellement échangées. Par exemple, une latitude de -12,345 et une longitude de 8,765 alors que cela devrait être inversé. Cela peut placer des points dans le mauvais hémisphère ou complètement en dehors du pays d’intérêt. Dans cette sous-section, nous effectuons une vérification de base pour signaler les coordonnées potentiellement inversées.
Lorsque des jeux de données contiennent des coordonnées géographiques, une erreur courante consiste à échanger accidentellement les valeurs de latitude et de longitude. Cela peut faire apparaître des points dans le mauvais pays ou même sur le mauvais continent.
Pour détecter les coordonnées possiblement inversées, nous utilisons une approche par boîte englobante :
- Extraire la boîte englobante géographique du shapefile, qui fournit la plage valide de latitudes et longitudes pour le pays.
- Pour chaque ligne du jeu de données :
- Vérifier si la latitude se situe dans la plage de longitude de la boîte englobante.
- Vérifier si la longitude se situe dans la plage de latitude.
- Si les deux conditions sont remplies, cela suggère que les valeurs peuvent avoir été inversées.
Cette méthode utilise des limites spatiales plutôt que des statistiques récapitulatives, ce qui la rend plus fiable dans les pays avec de grandes étendues géographiques (par exemple, Nigeria, RDC).
Afficher le code
# get bounding box from the shapefile
bbox <- sf::st_bbox(shp_adm3)
# create `possibly_flipped` column in the main dataset
dhis2_df_flagged <- dhis2_df_clean |>
dplyr::mutate(
out_of_bounds = lat < bbox[['ymin']] |
lat > bbox[['ymax']] |
lon < bbox[['xmin']] |
lon > bbox[['xmax']],
looks_flipped = lat > bbox[['xmin']] &
# lat in lon range
lat < bbox[['xmax']] &
lon > bbox[['ymin']] &
# lon in lat range
lon < bbox[['ymax']],
possibly_flipped = looks_flipped
)
# quick summary output
cli::cli_h2("Bounding Box Coordinate Check")
cli::cli_alert_info(
"Out-of-bounds rows: {sum(dhis2_df_flagged$out_of_bounds)}"
)
cli::cli_alert_info(
"Possible flipped rows: {sum(dhis2_df_flagged$possibly_flipped)}"
)
# check output
dhis2_df_flagged |>
dplyr::filter(possibly_flipped) |>
dplyr::select(adm0, adm1, adm2, adm3, lon, lat)Pour adapter le code :
- Lignes 2 et 5 : remplacez
dhis2_df_cleanpar le nom de votre jeu de données etshp_adm3par le nom de votre shapefile si différent. - Lignes 6–13 : mettez à jour
latetlonsi vos colonnes de coordonnées portent des noms différents. - Ligne 32 : ajustez
adm0,adm1,adm2,adm3pour qu’ils correspondent à vos colonnes d’identifiant pertinentes pour le contexte. - Cette méthode fonctionne bien lorsque les plages de latitude et de longitude sont clairement séparées (par exemple, lat ~8, lon ~–11 en Sierra Leone).
- ⚠️ Dans les pays comme le Nigeria, où les valeurs de lat et lon peuvent se situer dans des plages similaires (par exemple 11°N, 11°E), cette vérification peut produire des faux positifs. Utilisez-la avec prudence et examinez manuellement les lignes signalées.
Afficher le code
# get bounding box from the shapefile
xmin, ymin, xmax, ymax = shp_adm3.total_bounds
# create flags in the main dataset
dhis2_df_flagged = dhis2_df_clean.copy()
dhis2_df_flagged["out_of_bounds"] = (
(dhis2_df_flagged["lat"] < ymin)
| (dhis2_df_flagged["lat"] > ymax)
| (dhis2_df_flagged["lon"] < xmin)
| (dhis2_df_flagged["lon"] > xmax)
)
dhis2_df_flagged["possibly_flipped"] = (
(dhis2_df_flagged["lat"] > xmin)
& (dhis2_df_flagged["lat"] < xmax)
& (dhis2_df_flagged["lon"] > ymin)
& (dhis2_df_flagged["lon"] < ymax)
)
cli_header("Vérification des coordonnées de boîte englobante")
cli_info(f"Lignes hors limites : {dhis2_df_flagged['out_of_bounds'].sum()}")
cli_info(f"Lignes possiblement inversées : {dhis2_df_flagged['possibly_flipped'].sum()}")
dhis2_df_flagged.loc[
dhis2_df_flagged["possibly_flipped"],
["adm0", "adm1", "adm2", "adm3", "lon", "lat"]
]Pour adapter le code :
- Lignes 2 et 5 : remplacez
dhis2_df_cleanpar le nom de votre jeu de données etshp_adm3par le nom de votre shapefile si différent. - Lignes 6–13 : mettez à jour
latetlonsi vos colonnes de coordonnées portent des noms différents. - Ligne 25 : ajustez
adm0,adm1,adm2,adm3pour qu’ils correspondent à vos colonnes d’identifiant pertinentes pour le contexte. - Cette vérification fonctionne mieux lorsque les plages de latitude et de longitude sont clairement séparées. Examinez manuellement les lignes signalées dans les pays où les plages se chevauchent.
Ces résultats comprennent trois lignes avec des coordonnées qui tombent soit en dehors de la boîte englobante attendue, soit semblent être inversées (par exemple, des cas où la latitude est négative ou hors de la plage plausible). Étant donné que la Sierra Leone a des longitudes négatives mais pas de latitudes, ces enregistrements doivent être signalés pour examen ou corrigés manuellement sur la base des connaissances programmatiques du contexte.
Nous allons maintenant convertir ce jeu de données nettoyé en objet sf et commencer à joindre spatialement chaque ligne à son unité administrative correspondante en fonction de sa localisation.
Afficher le code
# reverse flipped coordinates
dhis2_df_corrected <- dhis2_df_flagged |>
dplyr::mutate(
lat_temp = dplyr::if_else(possibly_flipped, lon, lat),
lon_temp = dplyr::if_else(possibly_flipped, lat, lon),
lat = lat_temp,
lon = lon_temp
) |>
dplyr::select(-lat_temp, -lon_temp)
# check output
dhis2_df_corrected |>
dplyr::filter(possibly_flipped) |>
dplyr::select(adm0, adm1, adm2, adm3, lon, lat)Pour adapter le code :
- Lignes 2 et 3–7 : mettez à jour les noms de variables si votre jeu de données ou vos colonnes de coordonnées portent des noms différents.
- Ligne 14 : ajustez
adm0,adm1,adm2,adm3pour qu’ils correspondent à vos colonnes d’identifiant pertinentes pour le contexte.
Afficher le code
# reverse flipped coordinates
dhis2_df_corrected = dhis2_df_flagged.copy()
lat_temp = np.where(
dhis2_df_corrected["possibly_flipped"],
dhis2_df_corrected["lon"],
dhis2_df_corrected["lat"],
)
lon_temp = np.where(
dhis2_df_corrected["possibly_flipped"],
dhis2_df_corrected["lat"],
dhis2_df_corrected["lon"],
)
dhis2_df_corrected["lat"] = lat_temp
dhis2_df_corrected["lon"] = lon_temp
dhis2_df_corrected.loc[
dhis2_df_corrected["possibly_flipped"],
["adm0", "adm1", "adm2", "adm3", "lon", "lat"]
]Pour adapter le code :
- Lignes 2 et 3–15 : mettez à jour les noms de variables si votre jeu de données ou vos colonnes de coordonnées portent des noms différents.
- Ligne 19 : ajustez
adm0,adm1,adm2,adm3pour qu’ils correspondent à vos colonnes d’identifiant pertinentes pour le contexte.
Étape 5.2 : Vérifier et aligner le système de référence de coordonnées (CRS)
Avant d’effectuer une jointure spatiale, il est nécessaire de s’assurer que les données DHIS2 et le shapefile utilisent le même système de référence de coordonnées. La plupart des shapefiles utilisent des coordonnées géographiques en EPSG:4326, mais vos données DHIS2 peuvent ne pas être explicitement étiquetées.
Nous vérifierons le CRS des deux jeux de données et, si nécessaire, définissons ou transformons le CRS des données DHIS2 pour qu’il corresponde au shapefile.
Afficher le code
# check CRS of shapefile
shp_crs <- sf::st_crs(shp_adm3)
# convert DHIS2 data to an sf object
dhis2_df_sf <- dhis2_df_corrected |>
# only keep rows with valid coordinates
dplyr::filter(!is.na(lat), !is.na(lon)) |>
# assume input is EPSG:4326
sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
# reproject if CRS mismatch
if (sf::st_crs(dhis2_df_sf) != shp_crs) {
dhis2_df_sf <- sf::st_transform(dhis2_df_sf, shp_crs)
}
# summary
cli::cli_h2("CRS Alignment Summary")
cli::cli_alert_info("Shapefile CRS: {.strong {shp_crs$input}}")
cli::cli_alert_info(
"Intervention data CRS: {.strong {sf::st_crs(dhis2_df_sf)$input}}")
crs_summary <- data.frame(
dataset = c("Shapefile", "DHIS2 point data"),
crs = c(shp_crs$input, sf::st_crs(dhis2_df_sf)$input)
)
crs_summaryPour adapter le code :
- Ligne 2 : remplacez
shp_adm3par le nom de votre propre objet shapefile. - Lignes 5 et 6 : assurez-vous que votre jeu de données (
dhis2_df_corrected) contient des colonneslatetlonnumériques avant la conversion en objetsf. - Ligne 8 : si vos coordonnées utilisent un CRS d’entrée différent (par exemple autre que
EPSG:4326), mettez à jour l’argumentcrs = 4326en conséquence. - Remarque : dans cette démonstration, les coordonnées représentent des points centroïdes à l’intérieur des limites
adm3.
Afficher le code
# vérifier le SCR du shapefile et attribuer une valeur par défaut si manquante
if shp_adm3.crs is None:
shp_adm3 = shp_adm3.set_crs("EPSG:4326")
shp_crs = shp_adm3.crs
# convertir les données DHIS2 en GeoDataFrame
dhis2_df_sf = gpd.GeoDataFrame(
dhis2_df_corrected.dropna(subset=["lat", "lon"]),
geometry=gpd.points_from_xy(
dhis2_df_corrected.dropna(subset=["lat", "lon"])["lon"],
dhis2_df_corrected.dropna(subset=["lat", "lon"])["lat"],
),
crs="EPSG:4326",
)
# reprojeter en cas de SCR incompatible
if dhis2_df_sf.crs != shp_crs:
dhis2_df_sf = dhis2_df_sf.to_crs(shp_crs)
cli_header("Résumé de l'alignement du SCR")
cli_info(f"SCR du shapefile : {shp_crs}")
cli_info(f"SCR des données d'intervention : {dhis2_df_sf.crs}")
crs_summary = pd.DataFrame({
"dataset": ["Shapefile", "Données ponctuelles DHIS2"],
"crs": [str(shp_crs), str(dhis2_df_sf.crs)],
})
crs_summaryPour adapter le code :
- Lignes 2–4 : Remplacez
shp_adm3par le nom de votre propre objet shapefile. Le repliset_crs("EPSG:4326")garantit qu’un SCR est attribué au shapefile avant la reprojection. - Lignes 7–14 : Vérifiez que votre jeu de données (
dhis2_df_corrected) contient des colonnes numériqueslatetlonavant la conversion en GeoDataFrame. - Ligne 13 : Si vos coordonnées utilisent un SCR d’entrée différent, mettez à jour
crs="EPSG:4326"en conséquence.
Le shapefile et le jeu de données DHIS2 utilisent désormais le même SCR (EPSG:4326). Nous avons également converti avec succès les données DHIS2 avec des points centroïdes dans les limites adm3 en une couche spatiale ponctuelle.
Cela signifie que nous pouvons maintenant superposer en toute confiance les points centroïdes sur les limites administratives et procéder aux jointures spatiales.
Traçons maintenant les deux ensembles.
Afficher le code
# plot shapefile and points
ggplot2::ggplot() +
ggplot2::geom_sf(
data = shp_adm3,
fill = "white",
color = "black",
size = 0.3
) +
ggplot2::geom_sf(data = dhis2_df_sf, color = "red", size = 1.5, alpha = 0.7) +
ggplot2::labs(
title = "Points centroïdes dans les limites adm3 superposés sur la carte",
subtitle = "Tous les points sont alignés avec le SCR du shapefile (EPSG:4326)"
) +
ggplot2::theme_void()Pour adapter le code :
- Ligne 3 : remplacez
shp_adm3par votre propre shapefile si vous utilisez un niveau administratif ou une région différents. - Ligne 8 : assurez-vous que
dhis2_df_sfest votre jeu de données avec les coordonnées converties en objetsfavec le CRS correct. - Lignes 3–7 et 8 : ajustez les options
fill,coloretsizedansgeom_sf()selon les besoins pour correspondre à votre style de visualisation ou à vos besoins de présentation. - Lignes 9–11 : mettez à jour le titre et le sous-titre du graphique pour refléter votre jeu de données et votre contexte.
Afficher le code
fig, ax = plt.subplots(figsize=(8, 8))
# fixer l'aspect une seule fois sur l'axe ; passer aspect=None à
# gdf.plot() pour que geopandas ne recalcule pas un aspect à partir des
# limites de chaque couche (ce qui peut donner des valeurs non finies
# si une couche a une géométrie vide ou dégénérée et déclencher
# `aspect must be finite and positive`)
ax.set_aspect("equal")
shp_adm3.plot(
ax=ax, facecolor="white", edgecolor="black", linewidth=0.3, aspect=None
)
dhis2_df_sf.plot(
ax=ax, color="red", markersize=12, alpha=0.7, aspect=None
)
ax.set_title(
"centroid points within adm3 Boundaries Overlaid on Map\n"
"All points are aligned with shapefile CRS (EPSG:4326)"
)
ax.set_axis_off()
plt.show()Pour adapter le code :
- Ligne 2 : remplacez
shp_adm3par votre propre shapefile si vous utilisez un niveau administratif ou une région différents. - Ligne 3 : assurez-vous que
dhis2_df_sfest votre jeu de données avec les coordonnées converties en GeoDataFrame avec le CRS correct. - Lignes 2–3 : ajustez les options de remplissage, de couleur et de taille de marqueur selon les besoins.
- Lignes 4–7 : mettez à jour le titre et le sous-titre du graphique pour refléter votre jeu de données et votre contexte.
Nous avons nettoyé et aligné nos données de coordonnées avec succès. Les points centroïdes à l’intérieur des limites adm3 se superposent maintenant correctement au shapefile, et nous sommes prêts pour l’étape suivante : joindre spatialement ces points aux unités administratives.
Étape 5.3 : Agréger les coordonnées au niveau adm3
Après avoir validé et analysé les coordonnées des établissements, avant de les joindre au shapefile, l’étape suivante consiste à les agréger au niveau adm3. Il s’agit de calculer un centroïde représentatif pour chaque adm3 en faisant la moyenne de la latitude et de la longitude des établissements à l’intérieur de ses limites. Ces centroïdes fournissent un point de référence spatial pour visualiser les indicateurs agrégés.
Nous joignons ensuite ces centroïdes agrégés aux indicateurs DHIS2 de 2023, groupés et sommés au niveau adm3. Le résultat est un jeu de données où chaque unité administrative est reliée à ses indicateurs de paludisme agrégés et à la coordonnée de son centroïde correspondant, prêt pour la cartographie et les analyses ultérieures.
Afficher le code
# get the hf centroids at the adm3 level
dhis2_hf_coords <- dhis2_df_sf |>
sf::st_drop_geometry() |>
dplyr::select(adm0, adm1, adm2, adm3, lat, lon) |>
dplyr::group_by(adm0, adm1, adm2, adm3) |>
dplyr::summarise(
lon = mean(lon, na.rm = T),
lat = mean(lat, na.rm = T),
.groups = "drop"
)
# get indicators for 2023 at the adm3 level and join back
dhis2_df_sf <- dhis2_df_sf |>
sf::st_drop_geometry() |>
dplyr::select(
adm0,
adm1,
adm2,
adm3,
year,
conf_u5
) |>
dplyr::filter(year == 2023) |>
dplyr::group_by(adm0, adm1, adm2, adm3, year) |>
dplyr::summarise(
conf_u5 = sum(conf_u5, na.rm = TRUE),
.groups = "drop"
) |>
# join the coords
dplyr::left_join(
dhis2_hf_coords,
by = c("adm0", "adm1", "adm2", "adm3")
) |>
sf::st_as_sf(coords = c("lon", "lat"), crs = sf::st_crs(shp_adm3), remove = FALSE)Pour adapter le code :
- Lignes 2 et 3–4 : ajustez les colonnes sélectionnées pour correspondre à votre jeu de données (par exemple niveaux adm, noms des champs lat/lon ou variables de groupement supplémentaires).
- Lignes 14 et 17 : remplacez les variables d’indicateurs (
year,conf_u5) par les indicateurs disponibles dans votre jeu de données. - Ligne 22 : mettez à jour les clés de jointure (
adm0–adm3) si votre jeu de données utilise des niveaux administratifs différents.
Afficher le code
# get the centroid coordinates at the adm3 level
dhis2_hf_coords = (
pd.DataFrame(dhis2_df_sf.drop(columns="geometry"))
.groupby(["adm0", "adm1", "adm2", "adm3"], as_index=False)
.agg(lon=("lon", "mean"), lat=("lat", "mean"))
)
# get indicators for 2023 at adm3 level and join back coordinates
dhis2_df_sf = (
pd.DataFrame(dhis2_df_sf.drop(columns="geometry"))
[["adm0", "adm1", "adm2", "adm3", "year", "conf_u5"]]
# convertir year en chaîne pour que le filtre fonctionne que la
# colonne source soit stockée en entier ou en caractère (l'import
# rio de R conserve year en caractère)
.loc[lambda x: x["year"].astype(str) == "2023"]
.groupby(["adm0", "adm1", "adm2", "adm3", "year"], as_index=False)
.agg(conf_u5=("conf_u5", "sum"))
.merge(dhis2_hf_coords, on=["adm0", "adm1", "adm2", "adm3"], how="left")
)
dhis2_df_sf = gpd.GeoDataFrame(
dhis2_df_sf,
geometry=gpd.points_from_xy(dhis2_df_sf["lon"], dhis2_df_sf["lat"]),
crs=shp_adm3.crs,
)Pour adapter le code :
- Lignes 4 et 5 : ajustez les colonnes sélectionnées pour correspondre à votre jeu de données.
- Lignes 12 et 15 : remplacez
yearetconf_u5par les indicateurs disponibles dans votre jeu de données. - Ligne 17 : mettez à jour les clés de jointure (
adm0–adm3) si votre jeu de données utilise des niveaux administratifs différents.
Étape 5.4 : Jointure spatiale des points centroïdes au shapefile
Après avoir préparé et validé nos données de coordonnées, nous sommes prêts à joindre notre jeu de données DHIS2 avec les points centroïdes à l’intérieur des limites adm3 aux limites administratives à l’aide d’une jointure spatiale. Cette étape affecte chaque point centroïde au polygone correspondant, comme un district ou un chiefdom, ce qui nous permet de résumer et d’analyser la couverture des tests par zone géographique.
R provides several methods for determining how points relate to polygons during spatial joins::
sf::st_contains: Points contained within polygon (most common method, used in the code below)sf::st_intersects: Point touches or is within polygonsf::st_within: Point is completely within polygon (inverse of contains)sf::st_nearest_feature: Assigns to closest polygon when boundaries are ambiguous
Afficher le code
# joined to shapefile using spatial coordinates
dhis2_df_spatial_join <- sf::st_join(
dplyr::select(shp_adm3, geometry),
dhis2_df_sf,
join = sf::st_contains
) |>
dplyr::distinct(.keep_all = TRUE)
# vérifier les lignes non appariées
unmatched_points <- dhis2_df_spatial_join |> dplyr::filter(is.na(adm3))
cli::cli_h2("Validation de la jointure spatiale")
cli::cli_alert_info("Nombre de points non appariés : {nrow(unmatched_points)}")
# vérifier la sortie
head(dhis2_df_spatial_join)Pour adapter le code :
- Lignes 2 et 3 : remplacez
shp_adm3par votre shapefile de polygones etdhis2_df_sfpar vos données ponctuelles (doit être un objet sf). - Ligne 6 : ajustez le
dplyr::distinct()final pour conserver les niveaux administratifs et les variables d’intérêt pertinents (par exempleconf_u5) et supprimer les doublons éventuels. - Ligne 9 : ajustez
adm3pour qu’il corresponde à votre colonne d’identifiant pertinente pour vérifier les points non appariés. - Important : l’ordre des jeux de données dans
sf::st_join()détermine à la fois la géométrie de sortie et le prédicat spatial à utiliser :- Si nous voulons la géométrie des polygones dans les résultats → mentionner les polygones en premier :
sf::st_join(polygons, points, join = sf::st_contains). - Si nous voulons la géométrie des points dans les résultats → mentionner les points en premier :
sf::st_join(points, polygons, join = sf::st_within). - Nous souhaitons joindre notre shapefile à nos données tabulaires, donc nous mentionnons
shp_adm3en premier.
- Si nous voulons la géométrie des polygones dans les résultats → mentionner les polygones en premier :
sf::st_containsetsf::st_withinsont des relations inverses : un polygone contient un point si et seulement si le point est à l’intérieur du polygone.
Python fournit les mêmes prédicats spatiaux via geopandas.sjoin() :
predicate="contains": polygons contain points.predicate="intersects": polygons touch or contain points.predicate="within": points are within polygons, usually with points as the left dataset.sjoin_nearest(): assigns the nearest polygon when boundaries are ambiguous.
Afficher le code
# join to shapefile using spatial coordinates
dhis2_df_spatial_join = (
gpd.sjoin(
shp_adm3[["geometry"]],
dhis2_df_sf,
how="left",
predicate="contains"
)
.drop(columns="index_right", errors="ignore")
.drop_duplicates(subset=["adm0", "adm1", "adm2", "adm3", "year", "conf_u5"])
)
# vérifier les lignes non appariées
unmatched_points = dhis2_df_spatial_join.loc[dhis2_df_spatial_join["adm3"].isna()]
cli_header("Validation de la jointure spatiale")
cli_info(f"Nombre de points non appariés : {len(unmatched_points)}")
dhis2_df_spatial_join.head()Pour adapter le code :
- Lignes 4 et 5 : remplacez
shp_adm3par votre shapefile de polygones etdhis2_df_sfpar vos données ponctuelles. - Ligne 12 : ajustez le sous-ensemble de doublons final pour conserver les niveaux administratifs et les variables d’intérêt pertinents.
- Ligne 16 : ajustez
adm3pour qu’il corresponde à votre colonne d’identifiant pertinente pour vérifier les points non appariés. - Si vous voulez la géométrie des points dans la sortie, placez
dhis2_df_sfà gauche et utilisezpredicate="within".
Nous avons joint avec succès les données DHIS2 au shapefile. Tous les points centroïdes à l’intérieur des limites adm3 héritent maintenant du contexte administratif (par exemple district, chiefdom) en fonction de l’endroit où ils se situent à l’intérieur des polygones du shapefile.
Étape 5.5 : Utiliser des jointures avec tampon pour les points proches des limites (alternative à l’étape 5.4)
Parfois, les jointures basées sur les coordonnées échouent parce que les points tombent juste en dehors des limites administratives en raison d’erreurs GPS, de différences de numérisation des limites ou d’arrondissement des coordonnées. Dans ces cas, une jointure avec tampon peut aider en créant une petite zone de tolérance autour des limites.
Il est important de noter que cette étape est une alternative à l’étape 5.3, et ne doit pas être effectuée en plus de la jointure spatiale standard.
Envisagez des jointures avec tampon lorsque :
- Les points tombent juste en dehors des limites (surtout près des frontières)
- Les coordonnées GPS proviennent d’appareils mobiles (précision moindre) : tampon de 500 m à 1 km
- Données historiques (les limites ont pu changer) : tampon de 1 km à 3 km
- Établissements de santé près des frontières administratives : tampon de 500 m à 2 km
- Grappes d’enquête (déplacées pour la confidentialité, DHS par exemple) : tampon de 100 m à 500 m
Note : Il s’agit de distances de tampon suggérées ; ajustez en fonction de la qualité de vos données et du contexte spécifique.
Bien que le tampon permette de récupérer les points quasi manqués, il peut également :
- Attribuer des points à des unités administratives incorrectes dans les zones de limites denses
- Créer des chevauchements entre unités adjacentes
- Masquer de véritables problèmes de qualité des données qui devraient être traités à la source
Les décisions de tampon doivent être déterminées par la compréhension du contexte ainsi que par l’intégrité des données. Évaluez toujours si les points non appariés sont vraiment proches des limites (problèmes de précision GPS) ou éloignés (problèmes de qualité des données) avant d’appliquer des tampons.
Dans cette section, nous allons créer une version avec tampon de shp_adm3 appelée shp_adm3_buffered, puis montrer un exemple de coordonnée qui tombe en dehors de MALEMA mais devrait être attribuée à la chefferie MALEMA grâce au tampon.
Pour adapter le code:
- Ligne 5 : Ajustez
dist = 1000pour modifier la taille du tampon (en mètres). Utilisez différentes distances de tampon pour différents niveaux administratifs si nécessaire. - Ligne 6 : Remplacez
crs = 4326par la sortie desf::st_crs(shp_adm3)pour préserver la CRS d’origine.- Ligne 4 : Note : Nous transformons en
crs = 3857(Web Mercator) pour un tampon précis basé sur les mètres.
- Ligne 4 : Note : Nous transformons en
Pour adapter le code :
- Ligne 4 : ajustez
.buffer(1000)pour modifier la taille du tampon en mètres. - Ligne 5 : remplacez
epsg=4326parshp_adm3.crspour conserver le CRS d’origine. - Ligne 3 : le code transforme en EPSG:3857 pour un tampon basé sur les mètres.
Créons maintenant un exemple de coordonnée qui tombe juste en dehors du chiefdom MALEMA mais devrait logiquement lui appartenir :
Afficher le code
# select MALEMA chiefdom for our example
malema_original <- shp_adm3 |>
dplyr::filter(adm3 == "MALEMA")
malema_buffered <- shp_adm3_buffered |>
dplyr::filter(adm3 == "MALEMA")
# create example point that falls just outside MALEMA but within
# buffer zone (this simulates a GPS point with slight inaccuracy)
example_point <- data.frame(
# 10.85 W (negative for west)
lon = -10.85,
# 7.641 N (positive for north)
lat = 7.641
) |>
sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
# create visualization
ggplot2::ggplot() +
# plot the buffer first (so it appears behind)
ggplot2::geom_sf(
data = malema_buffered,
fill = "red",
alpha = 0.3,
color = "red",
size = 0.8
) +
# plot the original boundary on top
ggplot2::geom_sf(
data = malema_original,
fill = "lightblue",
color = "darkblue",
size = 1
) +
# add the example point
ggplot2::geom_sf(
data = example_point,
color = "black",
size = 3,
# filled circle
shape = 19
) +
ggplot2::labs(
title = "Exemple de limite administrative avec tampon",
subtitle = "Limite d'origine (bleu) avec zone tampon de 1 km (rouge) et point d'exemple (noir)"
) +
ggplot2::theme_void() +
ggplot2::theme(
plot.title = ggplot2::element_text(hjust = 0.5),
plot.subtitle = ggplot2::element_text(hjust = 0.5)
)Afficher le code
# select MALEMA chiefdom for the example
malema_original = shp_adm3.loc[shp_adm3["adm3"] == "MALEMA"]
malema_buffered = shp_adm3_buffered.loc[shp_adm3_buffered["adm3"] == "MALEMA"]
# create example point that falls just outside MALEMA but within the buffer zone
example_point = gpd.GeoDataFrame(
{"lon": [-10.85], "lat": [7.641]},
geometry=gpd.points_from_xy([-10.85], [7.641]),
crs="EPSG:4326"
)
fig, ax = plt.subplots(figsize=(10, 8))
# fixer l'aspect une seule fois sur l'axe et désactiver le calcul
# d'aspect de geopandas pour qu'une couche dégénérée ne déclenche pas
# `aspect must be finite and positive`
ax.set_aspect("equal")
malema_buffered.plot(
ax=ax, color="red", alpha=0.3, edgecolor="red", linewidth=0.8, aspect=None
)
malema_original.plot(
ax=ax, color="lightblue", edgecolor="darkblue", linewidth=1, aspect=None
)
example_point.plot(ax=ax, color="black", markersize=35, aspect=None)
ax.set_title(
"Exemple de limite administrative avec tampon\n"
"Limite d'origine (bleu) avec zone tampon de 1 km (rouge) et point d'exemple (noir)"
)
ax.set_axis_off()
plt.show()La limite d’origine est représentée en bleu, mais avec le tampon de 1 km (en rouge) la limite a légèrement changé. Nous pouvons maintenant voir que la coordonnée juste en dehors de la limite de MALEMA sera incluse dans le chiefdom MALEMA si shp_adm3_buffered est utilisé. Cela s’appliquera à tous les autres points de la carte qui tombent dans les zones tampons de leurs unités administratives les plus proches.
Si l’on souhaitait utiliser l’approche avec tampon pour joindre les données, il faudrait utiliser shp_adm3_buffered pour la jointure spatiale au lieu de shp_adm3.
Pour adapter le code :
- Lignes 2 et 3 : remplacez
shp_adm3_bufferedpar votre shapefile de polygones etdhis2_df_sfpar vos données ponctuelles (doit être un objet sf). - Ligne 6 : ajustez le
dplyr::distinct()final pour conserver les niveaux administratifs et les variables d’intérêt pertinents (par exempleconf_u5) et supprimer les doublons éventuels. - Important : l’ordre des jeux de données dans
sf::st_join()détermine à la fois la géométrie de sortie et le prédicat spatial à utiliser :- Si nous voulons la géométrie des polygones dans les résultats → mentionner les polygones en premier :
sf::st_join(polygons, points, join = sf::st_contains). - Si nous voulons la géométrie des points dans les résultats → mentionner les points en premier :
sf::st_join(points, polygons, join = sf::st_within). - Nous souhaitons joindre notre shapefile à nos données tabulaires, donc nous mentionnons
shp_adm3_buffereden premier.
- Si nous voulons la géométrie des polygones dans les résultats → mentionner les polygones en premier :
sf::st_containsetsf::st_withinsont des relations inverses : un polygone contient un point si et seulement si le point est à l’intérieur du polygone.
Pour adapter le code :
- Lignes 4 et 5 : remplacez
shp_adm3_bufferedpar votre shapefile de polygones avec tampon etdhis2_df_sfpar vos données ponctuelles. - Ligne 11 : ajustez le
.drop_duplicates()final pour conserver les niveaux administratifs et les variables d’intérêt pertinents. - Si vous voulez la géométrie des points dans la sortie, placez
dhis2_df_sfà gauche et utilisezpredicate="within".
Étape 6 : Valider les deux méthodes de jointure
Après avoir démontré avec succès les méthodes de jointure attributaire (par noms) et basée sur les coordonnées, nous devons valider que les deux approches produisent des résultats cohérents et complets. Cette étape de validation garantit que notre liaison de données spatiales est fiable et que nous pouvons utiliser l’une ou l’autre méthode en toute confiance selon la disponibilité et la qualité des données.
Nous comparerons les résultats des deux méthodes de jointure pour confirmer qu’elles produisent la même couverture spatiale et identifier les écarts éventuels qui nécessitent une investigation.
Étape 6.1 : Préparer les deux résultats de jointure pour comparaison
Commençons par créer des versions propres des deux résultats de jointure avec des noms de colonnes cohérents pour une comparaison facile.
Afficher le code
# name-based join result (from Step 4.5)
name_join_result <- dhis2_df_coord_shp |>
dplyr::mutate(join_method = "Attribute-based Join") |>
as.data.frame() |>
dplyr::select(
adm0,
adm1,
adm2,
adm3,
year,
conf_u5,
join_method
) |>
dplyr::filter(year == 2023) |>
dplyr::group_by(adm0, adm1, adm2, adm3, year, join_method) |>
dplyr::summarise(
conf_u5 = sum(conf_u5, na.rm = TRUE),
.groups = "drop"
) |>
# join back shapefile
dplyr::left_join(
shp_adm3,
by = c("adm0", "adm1", "adm2", "adm3")
)
# coordinate-based join result (from Step 5.3)
coord_join_result <- dhis2_df_spatial_join |>
dplyr::mutate(join_method = "Coordinate-based Join") |>
dplyr::select(
adm0,
adm1,
adm2,
adm3,
conf_u5,
join_method
) |>
# remove unmatched points
dplyr::filter(!is.na(adm3))
# bind both together
joined_plot_data <- dplyr::bind_rows(
name_join_result,
coord_join_result
)
# check summary statistics for conf_u5
joined_plot_data |>
as.data.frame() |>
dplyr::group_by(join_method) |>
dplyr::summarise(
min_conf_u5 = min(conf_u5, na.rm = TRUE),
mean_conf_u5 = mean(conf_u5, na.rm = TRUE),
max_conf_u5 = max(conf_u5, na.rm = TRUE),
n_missing = sum(is.na(conf_u5))
)Pour adapter le code :
- Lignes 10, 16 et 28 : remplacez
conf_u5par votre propre indicateur de paludisme. - Lignes 5–10 et 23–28 : ajustez les colonnes sélectionnées pour correspondre à la structure de votre jeu de données et aux variables d’intérêt.
Afficher le code
# name-based join result from Step 4.5
name_join_result = (
pd.DataFrame(dhis2_df_coord_shp.drop(columns="geometry"))
.assign(join_method="Attribute-based Join")
[["adm0", "adm1", "adm2", "adm3", "year", "conf_u5", "join_method"]]
# convertir year en chaîne pour que le filtre fonctionne que la
# colonne source soit stockée en entier ou en caractère (l'import
# rio de R conserve year en caractère)
.loc[lambda x: x["year"].astype(str) == "2023"]
.groupby(["adm0", "adm1", "adm2", "adm3", "year", "join_method"], as_index=False)
.agg(conf_u5=("conf_u5", "sum"))
.merge(shp_adm3, on=["adm0", "adm1", "adm2", "adm3"], how="left")
)
name_join_result = gpd.GeoDataFrame(name_join_result, geometry="geometry", crs=shp_adm3.crs)
# coordinate-based join result from Step 5.3
coord_join_result = (
dhis2_df_spatial_join
.assign(join_method="Coordinate-based Join")
[["adm0", "adm1", "adm2", "adm3", "conf_u5", "join_method", "geometry"]]
.loc[lambda x: x["adm3"].notna()]
)
# bind both together
joined_plot_data = gpd.GeoDataFrame(
pd.concat([name_join_result, coord_join_result], ignore_index=True, sort=False),
geometry="geometry",
crs=shp_adm3.crs
)
# check summary statistics for conf_u5
joined_plot_data.groupby("join_method").agg(
min_conf_u5=("conf_u5", "min"),
mean_conf_u5=("conf_u5", "mean"),
max_conf_u5=("conf_u5", "max"),
n_missing=("conf_u5", lambda x: x.isna().sum()),
).reset_index()Pour adapter le code :
- Lignes 6, 9 et 18 : remplacez
conf_u5par votre propre indicateur de paludisme. - Lignes 5–6 et 17–18 : ajustez les colonnes sélectionnées pour correspondre à la structure de votre jeu de données et aux variables d’intérêt.
Nous pouvons constater que la jointure a fonctionné car les statistiques récapitulatives de conf_u5 pour les deux jeux de données sont exactement identiques.
Étape 6.2 : Validation visuelle - comparaison côte à côte
Créons maintenant des cartes côte à côte pour confirmer visuellement que les deux méthodes de jointure produisent des patterns spatiaux identiques. Chaque polygone du shapefile doit avoir le même remplissage de couleur dans les deux cartes.
Afficher le code
adm3_validation_map <- joined_plot_data |>
sf::st_as_sf() |>
ggplot2::ggplot() +
ggplot2::geom_sf(
aes(fill = conf_u5 / 1000),
color = "grey20",
size = 0.15
) +
ggplot2::scale_fill_viridis_c(
option = "A",
direction = -1,
labels = scales::label_number(
accuracy = 1,
# formats as 5k, 10k, 20k
suffix = "k"
),
na.value = "lightgrey",
guide = ggplot2::guide_colorbar(
barwidth = grid::unit(5, "cm"),
barheight = grid::unit(0.4, "cm"),
title.position = "top"
)
) +
ggplot2::facet_wrap(~join_method, nrow = 1) +
ggplot2::labs(
title = "Cas confirmés de moins de 5 ans par Admin-3 en 2023\n",
fill = "Cas confirmés (moins de 5 ans)"
) +
ggplot2::coord_sf(datum = NA) +
ggplot2::theme_void() +
ggplot2::theme(
strip.text = ggplot2::element_text(size = 10, face = "bold"),
legend.position = "bottom",
legend.title = ggplot2::element_text(hjust = 0.5),
plot.title = ggplot2::element_text(hjust = 0.5)
)
# afficher le graphique
adm3_validation_map
# enregistrer le graphique
ggplot2::ggsave(
plot = adm3_validation_map,
here::here("03_output/3a_figures/shapefile_check_conf_adm3.png"),
width = 12,
height = 5,
dpi = 300
)Pour adapter le code :
- Ligne 5 : remplacez
conf_u5par votre variable d’indicateur de paludisme continue. - Lignes 25–27 : mettez à jour les titres et sous-titres du graphique pour refléter votre contexte spécifique.
Afficher le code
validation_plot = plot_validation_maps(
joined_plot_data,
value_col="conf_u5",
method_col="join_method",
title="Cas confirmés de moins de 5 ans par Admin-3 en 2023",
)
plt.show()
# enregistrer le graphique
validation_plot.savefig(
here("03_output/3a_figures/shapefile_check_conf_adm3.png"),
dpi=300,
bbox_inches="tight",
)Pour adapter le code:
- Ligne 4 : Remplacez
conf_u5par votre variable d’indicateur du paludisme continue. - Ligne 6 : Mettez à jour le titre du graphique pour refléter votre contexte spécifique.
Les 208 zones ont été jointes avec succès avec des taux de dépistage identiques. Les cartes côte à côte confirment que tout s’est aligné correctement. Utilisez des inspections visuelles comme celle-ci pour voir si votre fusion a fonctionné correctement et détecter d’éventuels problèmes de données dès le début.
Étape 7 : Enregistrer les données
Dans cette étape, nous enregistrons les données DHIS2 fusionnées finales avec notre shapefile.
Il existe plusieurs formats pour enregistrer vos données, tous démontrés dans l’étape 7 de la page Gestion et personnalisation des shapefiles. Dans cet exemple, nous utiliserons le simple format RDS, en veillant à ce que lors d’une utilisation future, lorsque nous le chargeons, nous le convertissions en objet sf :
Afficher le code
Pour adapter le code :
- Lignes 2–4 et 7–9 : veillez à adapter les chemins de fichiers et les objets pour les fichiers à enregistrer de manière appropriée avant l’enregistrement.
Afficher le code
save_path = Path(surv_path) / "processed"
save_path.mkdir(parents=True, exist_ok=True)
# save the name-based joined data
dhis2_df_coord_shp.to_file(
save_path / "sle_dhis2_df_name_joined_adm3.gpkg",
layer="name_joined_adm3",
driver="GPKG"
)
# save the coordinate-based joined data
dhis2_df_spatial_join.to_file(
save_path / "sle_dhis2_df_coord_joined_adm3.gpkg",
layer="coord_joined_adm3",
driver="GPKG"
)Pour adapter le code:
- Lignes 2–4 et 10–12 : Adaptez les chemins de fichiers et les objets avant d’enregistrer.
- Utilisez GeoPackage pour les sorties spatiales et CSV ou Parquet pour les tables non spatiales.
Résumé
Dans cette section, nous avons parcouru la préparation complète des données de coordonnées pour l’analyse spatiale. Nous avons identifié et converti les valeurs au format DMS, signalé et corrigé les coordonnées potentiellement inversées à l’aide de la logique de boîte englobante et de vérifications visuelles, et nous nous sommes assurés que les deux jeux de données partageaient une CRS cohérente. Après avoir converti les données DHIS2 en objet sf, nous avons effectué avec succès une jointure spatiale avec le shapefile et visualisé les résultats à l’aide de cartes propres et catégorisées. Cette section constitue une référence clé pour toute personne préparant des jeux de données basés sur des points pour l’intégration avec des limites administratives ; consultez-la chaque fois que vous devez aligner et valider des données géospatiales.
Code complet
Le script complet pour fusionner des shapefiles avec des données tabulaires est disponible ci-dessous.
Show full code
################################################################################
####### ~ Fusion des shapefiles avec des données tabulaires full code ~ ########
################################################################################
### Step -----------------------------------------------------------------------
# Vérifier si 'pacman' est installé; l'installer s'il manque
if (!requireNamespace("pacman", quietly = TRUE)) {
install.packages("pacman")
}
# Charger tous les packages requis avec pacman
pacman::p_load(
sf, # pour lire et manipuler les shapefiles
rio, # pour importer des fichiers Excel (remplace readxl)
dplyr, # pour manipuler les données
stringr, # pour nettoyer et formater les chaînes
ggplot2, # pour créer des graphiques
cli, # pour les sorties console stylisées
here # pour les chemins de fichiers multiplateformes
)
# sntutils contient plusieurs fonctions d'aide utiles pour la gestion
# et le nettoyage des données
if (!requireNamespace("sntutils", quietly = TRUE)) {
devtools::install_github("ahadi-analytics/sntutils")
}
# pour analyser les coordonnées
if (!requireNamespace("parzer", quietly = TRUE)) {
remotes::install_github("ropensci/parzer")
}
# importer le shapefile
shp_adm3 <- readRDS(
here::here(
"1.1_foundational",
"1.1a_administrative_boundaries",
"processed",
"sle_spatial_adm3_2021.rds"
)
) |>
# garantir que la sortie reste un objet sf valide pour les jointures suivantes
sf::st_as_sf()
# chemin des données de surveillance
surv_path <- here::here("1.2_epidemiology", "1.2a_routine_surveillance")
# obtenir les données sur le paludisme
dhis2_df <- rio::import(
here::here(
surv_path,
"raw",
"clean_malaria_routine_data_final.rds"
)
)
# vérifier les données DHIS2 et le shapefile
cli::cli_h2("Données DHIS2")
dhis2_df |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)
cli::cli_h2("Shapefile Adm3")
shp_adm3 |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)
# nettoyage initial
dhis2_df <- dhis2_df |>
dplyr::mutate(
adm0 = toupper(adm0),
adm2 = toupper(adm1),
adm3 = toupper(adm3),
adm2 = stringr::str_replace(
adm2,
stringr::fixed(" DISTRICT"),
""
),
adm3 = stringr::str_replace(
adm3,
stringr::fixed(" CHIEFDOM"),
""
)
) |>
dplyr::mutate(
adm1 = dplyr::case_when(
adm2 %in% c("KAILAHUN", "KENEMA", "KONO") ~
"EASTERN",
adm2 %in% c("BOMBALI", "FALABA", "KOINADUGU", "TONKOLILI") ~
"NORTH EAST",
adm2 %in% c("KAMBIA", "KARENE", "PORT LOKO") ~
"NORTH WEST",
adm2 %in% c("BO", "BONTHE", "MOYAMBA", "PUJEHUN") ~
"SOUTHERN",
adm2 %in% c("WESTERN AREA RURAL", "WESTERN AREA URBAN") ~
"WESTERN"
)
)
# vérifier les données DHIS2 et le shapefile
cli::cli_h2("Données DHIS2 (nettoyage initial) ")
dhis2_df |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)
cli::cli_h2("Shapefile Adm3")
shp_adm3 |>
dplyr::distinct(adm0, adm1, adm2, adm3) |>
dplyr::arrange(adm0, adm1, adm2, adm3) |>
head(n = 10)
# obtenir les noms administratifs distincts de DHIS2
dhis2_admins <-
dhis2_df |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# supprimer la géométrie pour les jointures par noms seulement
shp_names <-
shp_adm3 |>
sf::st_drop_geometry() |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# jointure interne pour compter les correspondances (polygones ∩ dhis2)
shp_with_dhis2 <-
shp_names |>
dplyr::inner_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# polygones sans admin DHIS2 correspondante (shapefile ▷ dhis2)
polygons_without_dhis2 <-
shp_names |>
dplyr::anti_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# admins DHIS2 sans polygone correspondant (dhis2 ▷ shapefile)
dhis2_without_polygons <-
dhis2_admins |>
dplyr::anti_join(
shp_names,
by = c("adm0", "adm1", "adm2", "adm3")
)
# comptes pour des diagnostics clairs
total_polygons <- nrow(shp_adm3)
matched_polygons <- nrow(shp_with_dhis2)
polygons_no_match <- nrow(polygons_without_dhis2)
dhis2_no_match <- nrow(dhis2_without_polygons)
# rapport
cli::cli_h2("Diagnostics de jointure admin (adm0 à adm3)")
# perspective des polygones
cli::cli_alert_success(
"Unités administratives du shapefile (uniques) : {total_polygons}"
)
cli::cli_alert_success(
"Correspondant avec DHIS2 : {matched_polygons}"
)
# perspective DHIS2
cli::cli_alert_warning(
"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}"
)
cli::cli_alert_warning(
"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}"
)
# aperçus
if (nrow(dhis2_without_polygons) > 0) {
cli::cli_h2("Admins DHIS2 sans polygone correspondant (exemple)")
dhis2_without_polygons |> head(10)
}
if (nrow(polygons_without_dhis2) > 0) {
cli::cli_h2("Polygones sans correspondance DHIS2 (exemple)")
polygons_without_dhis2 |> head(10)
}
# jointure interne (conserver uniquement les polygones appariés)
# définir l'emplacement du cache
cache_loc <- "1.1_foundational/1d_cache_files"
# harmoniser les noms administratifs dans dhis2_df
# à l'aide de shp_adm3
dhis2_df_cleaned <-
sntutils::prep_geonames(
target_df = dhis2_df, # jeu de données à nettoyer
lookup_df = shp_adm3, # jeu de référence avec les bons noms admin
level0 = "adm0",
level1 = "adm1",
level2 = "adm2",
level3 = "adm3",
method = "lv", # distance de Levenshtein pour l'appariement
cache_path = here::here(cache_loc, "geoname_cache.rds")
)
# obtenir les noms administratifs distincts de dhis2
dhis2_admins <-
dhis2_df_cleaned |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# drop geometry for name-only joins
shp_names <-
shp_adm3 |>
sf::st_drop_geometry() |>
dplyr::distinct(adm0, adm1, adm2, adm3)
# jointure interne pour compter les correspondances (polygones ∩ dhis2)
shp_with_dhis2 <-
shp_names |>
dplyr::inner_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# polygones sans admin DHIS2 correspondante (shapefile ▷ dhis2)
polygons_without_dhis2 <-
shp_names |>
dplyr::anti_join(
dhis2_admins,
by = c("adm0", "adm1", "adm2", "adm3")
)
# admins DHIS2 sans polygone correspondant (dhis2 ▷ shapefile)
dhis2_without_polygons <-
dhis2_admins |>
dplyr::anti_join(
shp_names,
by = c("adm0", "adm1", "adm2", "adm3")
)
# comptes pour des diagnostics clairs
total_polygons <- nrow(shp_adm3)
matched_polygons <- nrow(shp_with_dhis2)
polygons_no_match <- nrow(polygons_without_dhis2)
dhis2_no_match <- nrow(dhis2_without_polygons)
# rapport
cli::cli_h2("Diagnostics de jointure admin (adm0 à adm3)")
# perspective des polygones
cli::cli_alert_success(
"Unités administratives du shapefile (uniques) : {total_polygons}"
)
cli::cli_alert_success(
"Correspondant avec DHIS2 : {matched_polygons}"
)
# perspective DHIS2
cli::cli_alert_warning(
"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}"
)
cli::cli_alert_warning(
"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}"
)
# aperçus
if (nrow(dhis2_without_polygons) > 0) {
cli::cli_h2("Admins DHIS2 sans polygone correspondant (exemple)")
dhis2_without_polygons |> head(10)
}
if (nrow(polygons_without_dhis2) > 0) {
cli::cli_h2("Polygones sans correspondance DHIS2 (exemple)")
polygons_without_dhis2 |> head(10)
}
# joindre les données DHIS2 nettoyées au shapefile par noms administratifs
dhis2_df_coord_shp <- shp_adm3 |>
dplyr::left_join(
dhis2_df_cleaned,
by = c("adm0", "adm1", "adm2", "adm3")
)
# obtenir le nombre distinct de formes jointes aux données dhis2
dist_shapes <- dplyr::n_distinct(dhis2_df_coord_shp$geometry)
# vérifier le résultat
cli::cli_h2("Résultat de jointure basée sur les noms")
cli::cli_alert_success(
"{dist_shapes} polygones joints avec succès aux données DHIS2"
)
# Count missing coordinates
missing_lat <- sum(is.na(dhis2_df$lat))
missing_lon <- sum(is.na(dhis2_df$lon))
cli::cli_h2("Missing Coordinate Check")
cli::cli_alert_info("Missing latitude values: {missing_lat}")
cli::cli_alert_info("Missing longitude values: {missing_lon}")
# function to detect DMS format using common symbols for
# degrees, minutes, or seconds
detect_dms <- function(x) {
grepl("[°º].*['′\"″]", x)
}
# filter rows where lat or lon appears to be in DMS format
dms_detected <- dhis2_df |>
dplyr::filter(detect_dms(lat) | detect_dms(lon)) |>
dplyr::distinct(adm1, adm2, adm3, lat, lon)
# print summary
cli::cli_h2("DMS Coordinate Check")
cli::cli_alert_info(
"Number of rows with suspected DMS format: {nrow(dms_detected)}"
)
cat("\n")
dms_detected
# check if DMS values were successfully converted
check_output <- dhis2_df |>
dplyr::filter(detect_dms(lat) | detect_dms(lon)) |>
dplyr::distinct(adm1, adm2, adm3, lat, lon) |>
dplyr::mutate(
lat_fixed = parzer::parse_lat(lat),
lon_fixed = parzer::parse_lon(lon)
)
# apply conversion to full dataset
dhis2_df_clean <- dhis2_df |>
dplyr::mutate(
lat = parzer::parse_lat(lat),
lon = parzer::parse_lon(lon)
)
# print summary of conversion
cli::cli_h2("DMS Coordinate Conversion Summary")
check_output
# get bounding box from the shapefile
bbox <- sf::st_bbox(shp_adm3)
# create `possibly_flipped` column in the main dataset
dhis2_df_flagged <- dhis2_df_clean |>
dplyr::mutate(
out_of_bounds = lat < bbox[['ymin']] |
lat > bbox[['ymax']] |
lon < bbox[['xmin']] |
lon > bbox[['xmax']],
looks_flipped = lat > bbox[['xmin']] &
# lat in lon range
lat < bbox[['xmax']] &
lon > bbox[['ymin']] &
# lon in lat range
lon < bbox[['ymax']],
possibly_flipped = looks_flipped
)
# quick summary output
cli::cli_h2("Bounding Box Coordinate Check")
cli::cli_alert_info(
"Out-of-bounds rows: {sum(dhis2_df_flagged$out_of_bounds)}"
)
cli::cli_alert_info(
"Possible flipped rows: {sum(dhis2_df_flagged$possibly_flipped)}"
)
# check output
dhis2_df_flagged |>
dplyr::filter(possibly_flipped) |>
dplyr::select(adm0, adm1, adm2, adm3, lon, lat)
# reverse flipped coordinates
dhis2_df_corrected <- dhis2_df_flagged |>
dplyr::mutate(
lat_temp = dplyr::if_else(possibly_flipped, lon, lat),
lon_temp = dplyr::if_else(possibly_flipped, lat, lon),
lat = lat_temp,
lon = lon_temp
) |>
dplyr::select(-lat_temp, -lon_temp)
# check output
dhis2_df_corrected |>
dplyr::filter(possibly_flipped) |>
dplyr::select(adm0, adm1, adm2, adm3, lon, lat)
# check CRS of shapefile
shp_crs <- sf::st_crs(shp_adm3)
# convert DHIS2 data to an sf object
dhis2_df_sf <- dhis2_df_corrected |>
# only keep rows with valid coordinates
dplyr::filter(!is.na(lat), !is.na(lon)) |>
# assume input is EPSG:4326
sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
# reproject if CRS mismatch
if (sf::st_crs(dhis2_df_sf) != shp_crs) {
dhis2_df_sf <- sf::st_transform(dhis2_df_sf, shp_crs)
}
# summary
cli::cli_h2("CRS Alignment Summary")
cli::cli_alert_info("Shapefile CRS: {.strong {shp_crs$input}}")
cli::cli_alert_info(
"Intervention data CRS: {.strong {sf::st_crs(dhis2_df_sf)$input}}")
crs_summary <- data.frame(
dataset = c("Shapefile", "DHIS2 point data"),
crs = c(shp_crs$input, sf::st_crs(dhis2_df_sf)$input)
)
crs_summary
# plot shapefile and points
ggplot2::ggplot() +
ggplot2::geom_sf(
data = shp_adm3,
fill = "white",
color = "black",
size = 0.3
) +
ggplot2::geom_sf(data = dhis2_df_sf, color = "red", size = 1.5, alpha = 0.7) +
ggplot2::labs(
title = "Points centroïdes dans les limites adm3 superposés sur la carte",
subtitle = "Tous les points sont alignés avec le SCR du shapefile (EPSG:4326)"
) +
ggplot2::theme_void()
# get the hf centroids at the adm3 level
dhis2_hf_coords <- dhis2_df_sf |>
sf::st_drop_geometry() |>
dplyr::select(adm0, adm1, adm2, adm3, lat, lon) |>
dplyr::group_by(adm0, adm1, adm2, adm3) |>
dplyr::summarise(
lon = mean(lon, na.rm = T),
lat = mean(lat, na.rm = T),
.groups = "drop"
)
# get indicators for 2023 at the adm3 level and join back
dhis2_df_sf <- dhis2_df_sf |>
sf::st_drop_geometry() |>
dplyr::select(
adm0,
adm1,
adm2,
adm3,
year,
conf_u5
) |>
dplyr::filter(year == 2023) |>
dplyr::group_by(adm0, adm1, adm2, adm3, year) |>
dplyr::summarise(
conf_u5 = sum(conf_u5, na.rm = TRUE),
.groups = "drop"
) |>
# join the coords
dplyr::left_join(
dhis2_hf_coords,
by = c("adm0", "adm1", "adm2", "adm3")
) |>
sf::st_as_sf(coords = c("lon", "lat"), crs = sf::st_crs(shp_adm3), remove = FALSE)
# joined to shapefile using spatial coordinates
dhis2_df_spatial_join <- sf::st_join(
dplyr::select(shp_adm3, geometry),
dhis2_df_sf,
join = sf::st_contains
) |>
dplyr::distinct(.keep_all = TRUE)
# vérifier les lignes non appariées
unmatched_points <- dhis2_df_spatial_join |> dplyr::filter(is.na(adm3))
cli::cli_h2("Validation de la jointure spatiale")
cli::cli_alert_info("Nombre de points non appariés : {nrow(unmatched_points)}")
# vérifier la sortie
head(dhis2_df_spatial_join)
# create 1km buffer around all administrative boundaries
# transform to meters
shp_adm3_buffered <- shp_adm3 |>
sf::st_transform(crs = 3857) |>
# buffer 1000 meters
sf::st_buffer(dist = 1000) |>
sf::st_transform(crs = 4326)
# select MALEMA chiefdom for our example
malema_original <- shp_adm3 |>
dplyr::filter(adm3 == "MALEMA")
malema_buffered <- shp_adm3_buffered |>
dplyr::filter(adm3 == "MALEMA")
# create example point that falls just outside MALEMA but within
# buffer zone (this simulates a GPS point with slight inaccuracy)
example_point <- data.frame(
# 10.85 W (negative for west)
lon = -10.85,
# 7.641 N (positive for north)
lat = 7.641
) |>
sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)
# create visualization
ggplot2::ggplot() +
# plot the buffer first (so it appears behind)
ggplot2::geom_sf(
data = malema_buffered,
fill = "red",
alpha = 0.3,
color = "red",
size = 0.8
) +
# plot the original boundary on top
ggplot2::geom_sf(
data = malema_original,
fill = "lightblue",
color = "darkblue",
size = 1
) +
# add the example point
ggplot2::geom_sf(
data = example_point,
color = "black",
size = 3,
# filled circle
shape = 19
) +
ggplot2::labs(
title = "Exemple de limite administrative avec tampon",
subtitle = "Limite d'origine (bleu) avec zone tampon de 1 km (rouge) et point d'exemple (noir)"
) +
ggplot2::theme_void() +
ggplot2::theme(
plot.title = ggplot2::element_text(hjust = 0.5),
plot.subtitle = ggplot2::element_text(hjust = 0.5)
)
# join using buffered shp
dhis2_df_coord_shp_buff <- sf::st_join(
dplyr::select(shp_adm3_buffered, geometry),
dhis2_df_sf,
join = sf::st_contains
) |>
dplyr::distinct()
# name-based join result (from Step 4.5)
name_join_result <- dhis2_df_coord_shp |>
dplyr::mutate(join_method = "Attribute-based Join") |>
as.data.frame() |>
dplyr::select(
adm0,
adm1,
adm2,
adm3,
year,
conf_u5,
join_method
) |>
dplyr::filter(year == 2023) |>
dplyr::group_by(adm0, adm1, adm2, adm3, year, join_method) |>
dplyr::summarise(
conf_u5 = sum(conf_u5, na.rm = TRUE),
.groups = "drop"
) |>
# join back shapefile
dplyr::left_join(
shp_adm3,
by = c("adm0", "adm1", "adm2", "adm3")
)
# coordinate-based join result (from Step 5.3)
coord_join_result <- dhis2_df_spatial_join |>
dplyr::mutate(join_method = "Coordinate-based Join") |>
dplyr::select(
adm0,
adm1,
adm2,
adm3,
conf_u5,
join_method
) |>
# remove unmatched points
dplyr::filter(!is.na(adm3))
# bind both together
joined_plot_data <- dplyr::bind_rows(
name_join_result,
coord_join_result
)
# check summary statistics for conf_u5
joined_plot_data |>
as.data.frame() |>
dplyr::group_by(join_method) |>
dplyr::summarise(
min_conf_u5 = min(conf_u5, na.rm = TRUE),
mean_conf_u5 = mean(conf_u5, na.rm = TRUE),
max_conf_u5 = max(conf_u5, na.rm = TRUE),
n_missing = sum(is.na(conf_u5))
)
adm3_validation_map <- joined_plot_data |>
sf::st_as_sf() |>
ggplot2::ggplot() +
ggplot2::geom_sf(
aes(fill = conf_u5 / 1000),
color = "grey20",
size = 0.15
) +
ggplot2::scale_fill_viridis_c(
option = "A",
direction = -1,
labels = scales::label_number(
accuracy = 1,
# formats as 5k, 10k, 20k
suffix = "k"
),
na.value = "lightgrey",
guide = ggplot2::guide_colorbar(
barwidth = grid::unit(5, "cm"),
barheight = grid::unit(0.4, "cm"),
title.position = "top"
)
) +
ggplot2::facet_wrap(~join_method, nrow = 1) +
ggplot2::labs(
title = "Cas confirmés de moins de 5 ans par Admin-3 en 2023\n",
fill = "Cas confirmés (moins de 5 ans)"
) +
ggplot2::coord_sf(datum = NA) +
ggplot2::theme_void() +
ggplot2::theme(
strip.text = ggplot2::element_text(size = 10, face = "bold"),
legend.position = "bottom",
legend.title = ggplot2::element_text(hjust = 0.5),
plot.title = ggplot2::element_text(hjust = 0.5)
)
# afficher le graphique
adm3_validation_map
# enregistrer le graphique
ggplot2::ggsave(
plot = adm3_validation_map,
here::here("03_output/3a_figures/shapefile_check_conf_adm3.png"),
width = 12,
height = 5,
dpi = 300
)
# save the name-based joined data
saveRDS(
dhis2_df_coord_shp,
here::here(
surv_path, "processed", "sle_dhis2_df_name_joined_adm3.rds"
)
)
# save the coordinate-based joined data
saveRDS(
dhis2_df_spatial_join,
here::here(
surv_path, "processed", "sle_dhis2_df_coord_joined_adm3.rds"
)
)Show full code
################################################################################
####### ~ Fusion des shapefiles avec des données tabulaires full code ~ ########
################################################################################
### Step -----------------------------------------------------------------------
from pathlib import Path
import re
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pyreadr
from matplotlib import colors
from mpl_toolkits.axes_grid1 import make_axes_locatable
from pyprojroot import here
from sntutils.geo import prep_geonames
def read_rds(path):
"""Lire un fichier RDS à objet unique comme objet pandas."""
result = pyreadr.read_r(str(path))
return next(iter(result.values()))
def cli_header(message):
print(f"\n{message}")
def cli_info(message):
print(f"INFO: {message}")
def cli_success(message):
print(f"SUCCESS: {message}")
def cli_warning(message):
print(f"WARNING: {message}")
def anti_join(left, right, on):
"""Renvoyer les lignes de gauche sans clé correspondante à droite."""
right_keys = right[on].drop_duplicates()
return (
left.merge(right_keys, on=on, how="left", indicator=True)
.loc[lambda x: x["_merge"] == "left_only"]
.drop(columns="_merge")
)
def geometry_key(series):
"""Créer des clés WKB stables pour compter les géométries distinctes."""
return series.apply(lambda geom: geom.wkb if geom is not None else None)
def detect_dms_value(value):
"""Détecter les chaînes de coordonnées en degrés-minutes-secondes."""
return bool(re.search(r"[°º].*['′\"″]", str(value)))
def parse_coord(value):
"""Analyser des valeurs latitude/longitude décimales ou DMS en degrés décimaux."""
if pd.isna(value):
return np.nan
text = str(value).strip()
try:
return float(text)
except ValueError:
pass
# extraire d'abord l'indicateur d'hémisphère pour que le `\D*` gourmand
# du regex DMS principal ci-dessous ne l'absorbe pas. sans cela, des
# chaînes comme "11°8'37.08\"W" seraient analysées en +11,14 au lieu de
# -11,14, plaçant le point hors des limites de longitude du pays.
hemi_match = re.search(r"[NSEW]", text, flags=re.IGNORECASE)
hemi = hemi_match.group(0).upper() if hemi_match else ""
match = re.search(
r"(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)?\D*(\d+(?:\.\d+)?)?",
text,
flags=re.IGNORECASE
)
if not match:
return np.nan
deg = float(match.group(1))
minute = float(match.group(2) or 0)
second = float(match.group(3) or 0)
decimal = deg + minute / 60 + second / 3600
return -decimal if hemi in {"S", "W"} else decimal
def plot_validation_maps(data, value_col, method_col, title):
"""Créer des cartes de validation côte à côte avec une échelle de couleurs commune."""
methods = list(data[method_col].dropna().unique())
# protection contre une liste de méthodes vide pour éviter que
# matplotlib ne lève `Number of columns must be a positive integer,
# not 0`. Cela se produit lorsqu'un filtre en amont (par exemple un
# filtre d'année) supprime toutes les lignes.
if len(methods) == 0:
raise ValueError(
f"Aucune valeur trouvée dans `{method_col}` après dropna(); "
"vérifier qu'au moins une ligne survit aux filtres en amont."
)
fig, axes = plt.subplots(1, len(methods), figsize=(10, 6), constrained_layout=True)
if len(methods) == 1:
axes = [axes]
vmin = data[value_col].min(skipna=True) / 1000
vmax = data[value_col].max(skipna=True) / 1000
norm = colors.Normalize(vmin=vmin, vmax=vmax)
cmap = plt.get_cmap("magma_r")
for ax, method in zip(axes, methods):
# fixer l'aspect sur l'axe et désactiver le calcul d'aspect de
# geopandas pour qu'un sous-ensemble dégénéré ne déclenche pas
# `aspect must be finite and positive`
ax.set_aspect("equal")
subset = data.loc[data[method_col] == method].copy()
subset["plot_value"] = subset[value_col] / 1000
subset.plot(
ax=ax,
column="plot_value",
cmap=cmap,
norm=norm,
# matplotlib ne reconnaît pas le « grey20 » de R ; utiliser
# l'équivalent hexadécimal valide pour les deux moteurs
edgecolor="#333333",
linewidth=0.15,
missing_kwds={"color": "lightgrey"},
aspect=None,
)
ax.set_title(method, fontsize=10, fontweight="bold")
ax.set_axis_off()
fig.suptitle(title, fontsize=14)
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
fig.colorbar(
sm,
ax=axes,
orientation="horizontal",
fraction=0.04,
pad=0.05,
label="Cas confirmés (moins de 5 ans, milliers)",
)
return fig
# importer le shapefile
spat_path = here("1.1_foundational/1.1a_administrative_boundaries")
shp_adm3 = (
gpd.read_file(Path(spat_path) / "raw" / "Chiefdom2021.shp")
.assign(adm0="SIERRA LEONE")
.rename(columns={
"FIRST_REGI": "adm1",
"FIRST_DNAM": "adm2",
"FIRST_CHIE": "adm3",
})
[["adm0", "adm1", "adm2", "adm3", "geometry"]]
)
# chemin des données de surveillance
surv_path = here("1.2_epidemiology/1.2a_routine_surveillance")
# obtenir les données sur le paludisme
dhis2_df = read_rds(Path(surv_path) / "raw" / "clean_malaria_routine_data_final.rds")
# vérifier les données DHIS2 et le shapefile
cli_header("Données DHIS2")
dhis2_df[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)
cli_header("Shapefile Adm3")
shp_adm3[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)
region_lookup = {
"KAILAHUN": "EASTERN",
"KENEMA": "EASTERN",
"KONO": "EASTERN",
"BOMBALI": "NORTH EAST",
"FALABA": "NORTH EAST",
"KOINADUGU": "NORTH EAST",
"TONKOLILI": "NORTH EAST",
"KAMBIA": "NORTH WEST",
"KARENE": "NORTH WEST",
"PORT LOKO": "NORTH WEST",
"BO": "SOUTHERN",
"BONTHE": "SOUTHERN",
"MOYAMBA": "SOUTHERN",
"PUJEHUN": "SOUTHERN",
"WESTERN AREA RURAL": "WESTERN",
"WESTERN AREA URBAN": "WESTERN",
}
# nettoyage initial
dhis2_df = dhis2_df.copy()
dhis2_df["adm0"] = dhis2_df["adm0"].str.upper()
dhis2_df["adm2"] = dhis2_df["adm1"].str.upper().str.replace(" DISTRICT", "", regex=False)
dhis2_df["adm3"] = dhis2_df["adm3"].str.upper().str.replace(" CHIEFDOM", "", regex=False)
dhis2_df["adm1"] = dhis2_df["adm2"].map(region_lookup)
# vérifier les données DHIS2 et le shapefile
cli_header("Données DHIS2 (nettoyage initial)")
dhis2_df[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)
cli_header("Shapefile Adm3")
shp_adm3[["adm0", "adm1", "adm2", "adm3"]].drop_duplicates().sort_values(
["adm0", "adm1", "adm2", "adm3"]
).head(10)
join_keys = ["adm0", "adm1", "adm2", "adm3"]
# obtenir les noms administratifs distincts de DHIS2
dhis2_admins = dhis2_df[join_keys].drop_duplicates()
# supprimer la géométrie pour les jointures par noms seulement
shp_names = shp_adm3.drop(columns="geometry")[join_keys].drop_duplicates()
# jointure interne pour compter les correspondances
shp_with_dhis2 = shp_names.merge(dhis2_admins, on=join_keys, how="inner")
# polygones sans admin DHIS2 correspondante
polygons_without_dhis2 = anti_join(shp_names, dhis2_admins, on=join_keys)
# admins DHIS2 sans polygone correspondant
dhis2_without_polygons = anti_join(dhis2_admins, shp_names, on=join_keys)
# comptes pour des diagnostics clairs
total_polygons = len(shp_adm3)
matched_polygons = len(shp_with_dhis2)
polygons_no_match = len(polygons_without_dhis2)
dhis2_no_match = len(dhis2_without_polygons)
# rapport
cli_header("Diagnostics de jointure admin (adm0 à adm3)")
cli_success(f"Unités administratives du shapefile (uniques) : {total_polygons}")
cli_success(f"Correspondant avec DHIS2 : {matched_polygons}")
cli_warning(f"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}")
cli_warning(f"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}")
# aperçus
if len(dhis2_without_polygons) > 0:
cli_header("Admins DHIS2 sans polygone correspondant (exemple)")
print(dhis2_without_polygons.head(10))
if len(polygons_without_dhis2) > 0:
cli_header("Polygones sans correspondance DHIS2 (exemple)")
print(polygons_without_dhis2.head(10))
# définir l'emplacement du cache
cache_loc = "1.1_foundational/1d_cache_files"
# harmoniser les noms administratifs de dhis2_df en utilisant shp_adm3 comme référence
dhis2_df_cleaned = prep_geonames(
target_df=dhis2_df,
lookup_df=shp_adm3.drop(columns="geometry"),
level0="adm0",
level1="adm1",
level2="adm2",
level3="adm3",
method="lv",
cache_path=here(cache_loc, "geoname_cache.csv"),
preserve_case=True,
)
join_keys = ["adm0", "adm1", "adm2", "adm3"]
# obtenir les noms administratifs distincts de dhis2 nettoyées
dhis2_admins = dhis2_df_cleaned[join_keys].drop_duplicates()
# supprimer la géométrie pour les jointures par noms seulement
shp_names = shp_adm3.drop(columns="geometry")[join_keys].drop_duplicates()
# jointure interne pour compter les correspondances
shp_with_dhis2 = shp_names.merge(dhis2_admins, on=join_keys, how="inner")
# polygones sans admin DHIS2 correspondante
polygons_without_dhis2 = anti_join(shp_names, dhis2_admins, on=join_keys)
# admins DHIS2 sans polygone correspondant
dhis2_without_polygons = anti_join(dhis2_admins, shp_names, on=join_keys)
total_polygons = len(shp_adm3)
matched_polygons = len(shp_with_dhis2)
polygons_no_match = len(polygons_without_dhis2)
dhis2_no_match = len(dhis2_without_polygons)
cli_header("Diagnostics de jointure admin (adm0 à adm3)")
cli_success(f"Unités administratives du shapefile (uniques) : {total_polygons}")
cli_success(f"Correspondant avec DHIS2 : {matched_polygons}")
cli_warning(f"Unités administratives DHIS2 non appariées (aucun polygone) : {dhis2_no_match}")
cli_warning(f"Unités administratives du shapefile non appariées (aucune entrée DHIS2) : {polygons_no_match}")
if len(dhis2_without_polygons) > 0:
cli_header("Exemple d'unités administratives DHIS2 non appariées (10 premières)")
print(dhis2_without_polygons.head(10))
if len(polygons_without_dhis2) > 0:
cli_header("Exemple d'unités administratives du shapefile non appariées (10 premières)")
print(polygons_without_dhis2.head(10))
join_keys = ["adm0", "adm1", "adm2", "adm3"]
# joindre les données DHIS2 nettoyées au shapefile par noms administratifs
dhis2_df_coord_shp = shp_adm3.merge(
dhis2_df_cleaned,
on=join_keys,
how="left"
)
# obtenir le nombre distinct de formes jointes aux données dhis2
dist_shapes = geometry_key(dhis2_df_coord_shp.geometry).nunique()
# vérifier le résultat
cli_header("Résultat de jointure basée sur les noms")
cli_success(f"{dist_shapes} polygones joints avec succès aux données DHIS2")
# compter les coordonnées manquantes
missing_lat = dhis2_df["lat"].isna().sum()
missing_lon = dhis2_df["lon"].isna().sum()
cli_header("Vérification des coordonnées manquantes")
cli_info(f"Valeurs de latitude manquantes : {missing_lat}")
cli_info(f"Valeurs de longitude manquantes : {missing_lon}")
# filtrer les lignes où lat ou lon semble être au format DMS
dms_detected = (
dhis2_df.loc[
dhis2_df["lat"].apply(detect_dms_value)
| dhis2_df["lon"].apply(detect_dms_value),
["adm1", "adm2", "adm3", "lat", "lon"]
]
.drop_duplicates()
)
cli_header("Vérification des coordonnées DMS")
cli_info(f"Nombre de lignes avec format DMS suspecté : {len(dms_detected)}")
print(dms_detected)
# vérifier si les valeurs DMS ont été converties avec succès
check_output = (
dhis2_df.loc[
dhis2_df["lat"].apply(detect_dms_value)
| dhis2_df["lon"].apply(detect_dms_value),
["adm1", "adm2", "adm3", "lat", "lon"]
]
.drop_duplicates()
.assign(
lat_fixed=lambda x: x["lat"].apply(parse_coord),
lon_fixed=lambda x: x["lon"].apply(parse_coord),
)
)
# appliquer la conversion au jeu de données complet
dhis2_df_clean = dhis2_df.copy()
dhis2_df_clean["lat"] = dhis2_df_clean["lat"].apply(parse_coord)
dhis2_df_clean["lon"] = dhis2_df_clean["lon"].apply(parse_coord)
cli_header("Résumé de la conversion des coordonnées DMS")
check_output
# get bounding box from the shapefile
xmin, ymin, xmax, ymax = shp_adm3.total_bounds
# create flags in the main dataset
dhis2_df_flagged = dhis2_df_clean.copy()
dhis2_df_flagged["out_of_bounds"] = (
(dhis2_df_flagged["lat"] < ymin)
| (dhis2_df_flagged["lat"] > ymax)
| (dhis2_df_flagged["lon"] < xmin)
| (dhis2_df_flagged["lon"] > xmax)
)
dhis2_df_flagged["possibly_flipped"] = (
(dhis2_df_flagged["lat"] > xmin)
& (dhis2_df_flagged["lat"] < xmax)
& (dhis2_df_flagged["lon"] > ymin)
& (dhis2_df_flagged["lon"] < ymax)
)
cli_header("Vérification des coordonnées de boîte englobante")
cli_info(f"Lignes hors limites : {dhis2_df_flagged['out_of_bounds'].sum()}")
cli_info(f"Lignes possiblement inversées : {dhis2_df_flagged['possibly_flipped'].sum()}")
dhis2_df_flagged.loc[
dhis2_df_flagged["possibly_flipped"],
["adm0", "adm1", "adm2", "adm3", "lon", "lat"]
]
# reverse flipped coordinates
dhis2_df_corrected = dhis2_df_flagged.copy()
lat_temp = np.where(
dhis2_df_corrected["possibly_flipped"],
dhis2_df_corrected["lon"],
dhis2_df_corrected["lat"],
)
lon_temp = np.where(
dhis2_df_corrected["possibly_flipped"],
dhis2_df_corrected["lat"],
dhis2_df_corrected["lon"],
)
dhis2_df_corrected["lat"] = lat_temp
dhis2_df_corrected["lon"] = lon_temp
dhis2_df_corrected.loc[
dhis2_df_corrected["possibly_flipped"],
["adm0", "adm1", "adm2", "adm3", "lon", "lat"]
]
# vérifier le SCR du shapefile et attribuer une valeur par défaut si manquante
if shp_adm3.crs is None:
shp_adm3 = shp_adm3.set_crs("EPSG:4326")
shp_crs = shp_adm3.crs
# convertir les données DHIS2 en GeoDataFrame
dhis2_df_sf = gpd.GeoDataFrame(
dhis2_df_corrected.dropna(subset=["lat", "lon"]),
geometry=gpd.points_from_xy(
dhis2_df_corrected.dropna(subset=["lat", "lon"])["lon"],
dhis2_df_corrected.dropna(subset=["lat", "lon"])["lat"],
),
crs="EPSG:4326",
)
# reprojeter en cas de SCR incompatible
if dhis2_df_sf.crs != shp_crs:
dhis2_df_sf = dhis2_df_sf.to_crs(shp_crs)
cli_header("Résumé de l'alignement du SCR")
cli_info(f"SCR du shapefile : {shp_crs}")
cli_info(f"SCR des données d'intervention : {dhis2_df_sf.crs}")
crs_summary = pd.DataFrame({
"dataset": ["Shapefile", "Données ponctuelles DHIS2"],
"crs": [str(shp_crs), str(dhis2_df_sf.crs)],
})
crs_summary
fig, ax = plt.subplots(figsize=(8, 8))
# fixer l'aspect une seule fois sur l'axe ; passer aspect=None à
# gdf.plot() pour que geopandas ne recalcule pas un aspect à partir des
# limites de chaque couche (ce qui peut donner des valeurs non finies
# si une couche a une géométrie vide ou dégénérée et déclencher
# `aspect must be finite and positive`)
ax.set_aspect("equal")
shp_adm3.plot(
ax=ax, facecolor="white", edgecolor="black", linewidth=0.3, aspect=None
)
dhis2_df_sf.plot(
ax=ax, color="red", markersize=12, alpha=0.7, aspect=None
)
ax.set_title(
"centroid points within adm3 Boundaries Overlaid on Map\n"
"All points are aligned with shapefile CRS (EPSG:4326)"
)
ax.set_axis_off()
plt.show()
# get the centroid coordinates at the adm3 level
dhis2_hf_coords = (
pd.DataFrame(dhis2_df_sf.drop(columns="geometry"))
.groupby(["adm0", "adm1", "adm2", "adm3"], as_index=False)
.agg(lon=("lon", "mean"), lat=("lat", "mean"))
)
# get indicators for 2023 at adm3 level and join back coordinates
dhis2_df_sf = (
pd.DataFrame(dhis2_df_sf.drop(columns="geometry"))
[["adm0", "adm1", "adm2", "adm3", "year", "conf_u5"]]
# convertir year en chaîne pour que le filtre fonctionne que la
# colonne source soit stockée en entier ou en caractère (l'import
# rio de R conserve year en caractère)
.loc[lambda x: x["year"].astype(str) == "2023"]
.groupby(["adm0", "adm1", "adm2", "adm3", "year"], as_index=False)
.agg(conf_u5=("conf_u5", "sum"))
.merge(dhis2_hf_coords, on=["adm0", "adm1", "adm2", "adm3"], how="left")
)
dhis2_df_sf = gpd.GeoDataFrame(
dhis2_df_sf,
geometry=gpd.points_from_xy(dhis2_df_sf["lon"], dhis2_df_sf["lat"]),
crs=shp_adm3.crs,
)
# join to shapefile using spatial coordinates
dhis2_df_spatial_join = (
gpd.sjoin(
shp_adm3[["geometry"]],
dhis2_df_sf,
how="left",
predicate="contains"
)
.drop(columns="index_right", errors="ignore")
.drop_duplicates(subset=["adm0", "adm1", "adm2", "adm3", "year", "conf_u5"])
)
# vérifier les lignes non appariées
unmatched_points = dhis2_df_spatial_join.loc[dhis2_df_spatial_join["adm3"].isna()]
cli_header("Validation de la jointure spatiale")
cli_info(f"Nombre de points non appariés : {len(unmatched_points)}")
dhis2_df_spatial_join.head()
# create 1km buffer around all administrative boundaries
shp_adm3_buffered = shp_adm3.to_crs(epsg=3857).copy()
shp_adm3_buffered["geometry"] = shp_adm3_buffered.geometry.buffer(1000)
shp_adm3_buffered = shp_adm3_buffered.to_crs(epsg=4326)
# select MALEMA chiefdom for the example
malema_original = shp_adm3.loc[shp_adm3["adm3"] == "MALEMA"]
malema_buffered = shp_adm3_buffered.loc[shp_adm3_buffered["adm3"] == "MALEMA"]
# create example point that falls just outside MALEMA but within the buffer zone
example_point = gpd.GeoDataFrame(
{"lon": [-10.85], "lat": [7.641]},
geometry=gpd.points_from_xy([-10.85], [7.641]),
crs="EPSG:4326"
)
fig, ax = plt.subplots(figsize=(10, 8))
# fixer l'aspect une seule fois sur l'axe et désactiver le calcul
# d'aspect de geopandas pour qu'une couche dégénérée ne déclenche pas
# `aspect must be finite and positive`
ax.set_aspect("equal")
malema_buffered.plot(
ax=ax, color="red", alpha=0.3, edgecolor="red", linewidth=0.8, aspect=None
)
malema_original.plot(
ax=ax, color="lightblue", edgecolor="darkblue", linewidth=1, aspect=None
)
example_point.plot(ax=ax, color="black", markersize=35, aspect=None)
ax.set_title(
"Exemple de limite administrative avec tampon\n"
"Limite d'origine (bleu) avec zone tampon de 1 km (rouge) et point d'exemple (noir)"
)
ax.set_axis_off()
plt.show()
# join using buffered shapefile
dhis2_df_coord_shp_buff = (
gpd.sjoin(
shp_adm3_buffered[["geometry"]],
dhis2_df_sf,
how="left",
predicate="contains"
)
.drop(columns="index_right", errors="ignore")
.drop_duplicates()
)
# name-based join result from Step 4.5
name_join_result = (
pd.DataFrame(dhis2_df_coord_shp.drop(columns="geometry"))
.assign(join_method="Attribute-based Join")
[["adm0", "adm1", "adm2", "adm3", "year", "conf_u5", "join_method"]]
# convertir year en chaîne pour que le filtre fonctionne que la
# colonne source soit stockée en entier ou en caractère (l'import
# rio de R conserve year en caractère)
.loc[lambda x: x["year"].astype(str) == "2023"]
.groupby(["adm0", "adm1", "adm2", "adm3", "year", "join_method"], as_index=False)
.agg(conf_u5=("conf_u5", "sum"))
.merge(shp_adm3, on=["adm0", "adm1", "adm2", "adm3"], how="left")
)
name_join_result = gpd.GeoDataFrame(name_join_result, geometry="geometry", crs=shp_adm3.crs)
# coordinate-based join result from Step 5.3
coord_join_result = (
dhis2_df_spatial_join
.assign(join_method="Coordinate-based Join")
[["adm0", "adm1", "adm2", "adm3", "conf_u5", "join_method", "geometry"]]
.loc[lambda x: x["adm3"].notna()]
)
# bind both together
joined_plot_data = gpd.GeoDataFrame(
pd.concat([name_join_result, coord_join_result], ignore_index=True, sort=False),
geometry="geometry",
crs=shp_adm3.crs
)
# check summary statistics for conf_u5
joined_plot_data.groupby("join_method").agg(
min_conf_u5=("conf_u5", "min"),
mean_conf_u5=("conf_u5", "mean"),
max_conf_u5=("conf_u5", "max"),
n_missing=("conf_u5", lambda x: x.isna().sum()),
).reset_index()
validation_plot = plot_validation_maps(
joined_plot_data,
value_col="conf_u5",
method_col="join_method",
title="Cas confirmés de moins de 5 ans par Admin-3 en 2023",
)
plt.show()
# enregistrer le graphique
validation_plot.savefig(
here("03_output/3a_figures/shapefile_check_conf_adm3.png"),
dpi=300,
bbox_inches="tight",
)
save_path = Path(surv_path) / "processed"
save_path.mkdir(parents=True, exist_ok=True)
# save the name-based joined data
dhis2_df_coord_shp.to_file(
save_path / "sle_dhis2_df_name_joined_adm3.gpkg",
layer="name_joined_adm3",
driver="GPKG"
)
# save the coordinate-based joined data
dhis2_df_spatial_join.to_file(
save_path / "sle_dhis2_df_coord_joined_adm3.gpkg",
layer="coord_joined_adm3",
driver="GPKG"
)




