Dev Site — You are viewing the development build. Go to Main Site

  • English
  • Français
  1. 2. Assemblage et gestion des données
  2. 2.2 Formations sanitaires
  3. Coordonnées des établissements de santé et données ponctuelles
  • Bibliothèque de code pour l'adaptation infranationale
    Version française
  • 1. Pour commencer
    • 1.1 À propos et comment nous contacter
    • 1.2 Pour tous les utilisateurs
    • 1.3 Pour l’équipe SNT
    • 1.4 Pour les analystes
    • 1.5 Acronymes et bibliothèque de ressources
    • 1.6 Produire des résultats de haute qualité
  • 2. Assemblage et gestion des données
    • 2.1 Utilisation des shapefiles
      • Aperçu des données spatiales
      • Utilisation et visualisation de base des shapefiles
      • Gestion et personnalisation des shapefiles
      • Fusion des shapefiles avec des données tabulaires
    • 2.2 Formations sanitaires
      • Correspondance approximative des noms entre jeux de données
      • Coordonnées des établissements de santé et données ponctuelles
    • 2.3 Données de cas de routine (DHIS2)
      • Détermination du statut actif et inactif
      • Data extraction from DHIS2
      • Prétraitement des données DHIS2
      • Méthodes de détection des données manquantes
      • Outlier correction
      • Considérations contextuelles
      • Taux de notification des établissements de santé
      • Quality control/checks
      • Outlier detection methods
      • Imputation of missing data
      • Final database
    • 2.4 Données du stock
      • lmis
    • 2.5 Données démographiques
      • Données démographiques nationales
      • Raster de population WorldPop
    • 2.6 Enquêtes nationales auprès des ménages
      • DHS Data Overview and Preparation
      • All-Cause Child Mortality
      • Wealth quintiles analysis
      • Extraction of ITN ownership, access, and usage
      • Extracion of prevalence data
      • Calculation of treatment-seeking data
    • 2.7 Données entomologiques
      • Données entomologiques
    • 2.8 Données climatiques et environnementales
      • Extraction de données climatiques et environnementales à partir de données raster
    • 2.9 Données modélisées
      • Generating spatial modeled estimates
      • Travailler avec les estimations modélisées géospatiales
      • Modeled Estimates of Entomological Indicators
      • Mortality estimates from IHME
    • 2.10 Données financières
  • 3. Analyse de la situation
    • 3.1 Revue des interventions historiques
      • Prise en charge des cas
      • Interventions de routine
      • Les campagnes de masse de moustiquaires
      • Les campagnes de chimioprévention
      • Autres interventions lutte antivectorielle
    • 3.2 Analyse des tendances
    • 3.3 Analyse des facteurs de risque
    • 3.4 Évaluation de l’impact des interventions
    • 3.5 Analyse des coûts
  • 4. Stratification
    • 4.1 Stratification épidémiologique
      • Aperçu de l’incidence et incidence brute
      • Ajustement de l’incidence 1 : noncomplétude du dépistage
      • Ajustement de l’incidence 2 : noncomplétude du rapportage
      • Ajustement de l’incidence 2 : recherche des soins
      • Incidence stratification
      • Stratification par prévalence et mortalité
      • Risk categorization
      • Combined risk categorization
      • Risk categorization REMOVE?
    • 4.2 Accès aux soins
    • 4.3 Saisonnalité
      • Définir les zones saisonnières
      • Durées de saisonnalité
    • 4.4 Microstratification urbaine
  • 5. Ciblage et priorisation des interventions
    • 5.1 Ciblage des interventions
    • 5.2 Priorisation
    • 5.3 Optimisation dans la limite des ressources

On this page

  • Aperçu
  • Concepts clés
    • Qu’est-ce que la donnée de coordonnées ponctuelles ?
    • Types de données ponctuelles dans le SNT
    • D’où viennent les coordonnées ?
    • Systèmes de référence de coordonnées (CRS)
    • Formats de coordonnées
    • Précision des coordonnées
    • Problèmes courants avec les données de coordonnées ponctuelles
      • Coordonnées manquantes ou vides
      • Texte de coordonnées mal formé
      • Format degrés-minutes-secondes (DMS)
      • Coordonnées hors plage
      • Latitude et longitude inversées
      • Coordonnées de l’île zéro (0, 0)
      • Coordonnées en double
      • Emplacements partagés et centroïdes de substitution
      • Points hors de la limite du pays
      • Faible précision
      • Incompatibilité de CRS ou de projection
      • Colonne de coordonnées combinées
  • Étape par étape
    • Étape 1 : Installer et charger les paquets
    • Étape 2 : Charger et préparer les données d’entrée
    • Étape 3 : Définir, valider et nettoyer les coordonnées
      • Étape 3.1 : Séparer la colonne de coordonnées (si nécessaire)
      • Étape 3.2 : Détecter les problèmes de qualité des coordonnées
      • Étape 3.3 : Nettoyer les coordonnées
    • Étape 4 : Convertir en objet spatial
    • Étape 5 : Assigner les établissements aux unités administratives
    • Étape 6 : Sauvegarder les données nettoyées et spatiales
    • Étape 7 : Créer des cartes des établissements de santé
      • Étape 7.1 : Carte de base des établissements de santé
      • Étape 7.2 : Établissements de santé par type
      • Étape 7.3 : Types spécifiques d’établissements de santé
    • Étape 8 : Visualisations avancées de coordonnées ponctuelles
      • Étape 8.1 : Résumé des données de coordonnées ponctuelles
      • Étape 8.2 : Visualisation de coordonnées ponctuelles à indicateur unique
      • Étape 8.3 : Visualisation de coordonnées ponctuelles à indicateurs multiples
  • Résumé
  • Code complet
  1. 2. Assemblage et gestion des données
  2. 2.2 Formations sanitaires
  3. Coordonnées des établissements de santé et données ponctuelles

Coordonnées des établissements de santé et données ponctuelles

Débutant

Aperçu

Parmi les éléments les plus importants du processus SNT, beaucoup se représentent mieux sous forme de points que de surfaces. Un point est un emplacement unique à la surface de la Terre, décrit par une paire de coordonnées (longitude et latitude). Les établissements de santé, les agents de santé communautaires, les villages et agglomérations, les grappes d’enquête, ainsi que les localisations de cas ou d’événements individuels peuvent tous être stockés, cartographiés et analysés en tant que données ponctuelles.

Cette page utilise la cartographie des coordonnées des établissements de santé comme exemple concret de travail avec des données de coordonnées ponctuelles. Le même code peut être adapté à tout autre type de données ponctuelles, car les étapes sous-jacentes (nettoyage des coordonnées, conversion en objet spatial et tracé sur les limites administratives) sont identiques quelle que soit la nature des points. Cartographier des indicateurs à leurs coordonnées ponctuelles, plutôt que de les agréger au niveau du district, révèle également des tendances spatiales à une échelle beaucoup plus fine.

TipEn savoir plus sur les données spatiales

Pour des informations générales sur les shapefiles et des liens vers tout le contenu sur les données spatiales dans la bibliothèque de code SNT, y compris les rasters et les données ponctuelles, veuillez consulter Aperçu des données spatiales.

La cartographie des coordonnées des établissements de santé est le processus de représentation des localisations des établissements (longitude et latitude) sur une carte, en superposition avec les limites administratives. Placer les établissements dans leur contexte géographique montre la distribution des infrastructures sanitaires à travers un pays et aide à identifier les zones bien desservies et celles qui présentent des lacunes de service.

NoteObjectifs
  • Comprendre ce que sont les données de coordonnées ponctuelles et en quoi elles diffèrent des données polygonales (shapefile)
  • Reconnaître les problèmes de qualité courants dans les données de coordonnées et savoir les résoudre
  • Visualiser la distribution géographique des établissements de santé au travers des limites administratives
  • Cartographier les indicateurs au niveau des établissements, tels que le taux de positivité des tests, à leurs coordonnées ponctuelles

La cartographie des établissements de santé contribue à plusieurs tâches SNT. Elle révèle les lacunes de couverture géographique, soutient l’analyse des zones de desserte (estimation de la population desservie par chaque établissement) et permet le ciblage spatial des interventions. Cette page s’appuie sur les données d’établissements préparées dans le guide Correspondance floue de noms entre jeux de données pour créer ces visualisations.

Concepts clés

Qu’est-ce que la donnée de coordonnées ponctuelles ?

Les données de coordonnées ponctuelles enregistrent la position d’entités individuelles sous forme d’une paire de nombres, généralement la longitude (x) et la latitude (y). Chaque ligne du tableau correspond à un emplacement, accompagné d’attributs qui le décrivent, tels qu’un nom d’établissement, un identifiant unique, le type d’établissement ou une valeur d’indicateur.

Cela diffère des données polygonales stockées dans les shapefiles, qui décrivent des surfaces (le contour d’un district ou d’une chefferie) plutôt que des emplacements individuels. Les deux sont complémentaires : dans ce flux de travail, nous traçons les points des établissements sur les polygones administratifs afin que chaque établissement puisse être lu dans son contexte géographique et administratif. Les données ponctuelles arrivent généralement sous forme de tableau (un fichier Excel ou CSV avec des colonnes de coordonnées), tandis que les limites arrivent sous forme de shapefiles.

Types de données ponctuelles dans le SNT

Le même flux de travail s’applique à toute entité pouvant être localisée par une seule paire de coordonnées. Les exemples courants dans le SNT comprennent :

  • Les établissements de santé, issus de la liste principale des établissements (MFL) ou de DHIS2
  • Les agents de santé communautaires, cartographiés selon leur zone d’affectation
  • Les villages et agglomérations, souvent utilisés pour l’analyse de la population ou de l’accessibilité
  • Les grappes d’enquête des enquêtes DHS ou MIS (noter que les coordonnées des grappes sont délibérément déplacées pour des raisons de confidentialité et ne doivent donc pas être traitées comme exactes)
  • Les localisations de cas ou d’événements issues de la surveillance, où chaque enregistrement est une occurrence unique

D’où viennent les coordonnées ?

Les coordonnées parviennent à l’équipe SNT depuis plusieurs sources, et connaître la source aide à anticiper les problèmes de qualité :

  • La collecte GPS sur le terrain, par exemple lors d’un recensement des établissements, généralement la plus précise
  • Les listes principales d’établissements et les registres des unités organisationnelles DHIS2, où les coordonnées peuvent varier en âge et en qualité
  • Les répertoires géographiques et le géocodage, où un nom de lieu est associé à une coordonnée, qui peut être approximative
ImportantConsultez l’équipe SNT

Les données de coordonnées doivent être examinées et confirmées avec l’équipe SNT avant d’être utilisées pour l’analyse. Confirmez la source des coordonnées, la colonne qui identifie chaque établissement de manière unique, et si les classifications du type d’établissement sont standardisées.

Systèmes de référence de coordonnées (CRS)

Le système de référence de coordonnées (CRS) définit comment les localisations géographiques sont représentées numériquement. Pour un contexte complet sur les systèmes de référence de coordonnées, les projections et les principes de gestion des données spatiales, voir l’Aperçu des données spatiales.

Deux systèmes de référence de coordonnées sont les plus importants pour les données ponctuelles :

  • WGS84 (EPSG:4326) utilise la longitude et la latitude en degrés décimaux. Il s’agit du standard pour la cartographie et les données GPS, et c’est ainsi que les coordonnées des établissements de santé sont presque toujours partagées dans le processus SNT.
  • UTM (EPSG:326XX) utilise des mètres au sein d’une zone unique. Il est plus approprié lorsque des distances ou des surfaces doivent être mesurées avec précision, par exemple lors de l’estimation des zones de desserte ou de la distance à l’établissement le plus proche.

Contrairement au travail avec les shapefiles, où WGS84 est généralement suffisant tout au long du processus, l’analyse des points nécessite parfois UTM. Un schéma courant consiste à conserver les données en WGS84 pour la cartographie et à reprojeter uniquement vers la zone UTM locale pour les calculs de distances ou de surfaces. Quel que soit le CRS retenu, chaque couche d’une carte doit partager le même CRS, sans quoi les couches ne s’aligneront pas.

Dans le processus SNT, les données des établissements de santé sont généralement partagées par l’équipe SNT en format Excel ou CSV, avec les coordonnées fournies en longitude et latitude. Le système de coordonnées géographiques (WGS84 / EPSG:4326) est donc utilisé pour la cartographie, sauf indication contraire de l’équipe SNT.

Formats de coordonnées

Exemples de coordonnées en WGS84 :

  • Latitude : 8,4820° (au nord de l’équateur)
  • Longitude : -13,2334° (à l’ouest du méridien de Greenwich)
  • Emplacement : Ce point représente Freetown, Sierra Leone
  • Format : (lat, lon) = (8,48206, -13,23345)
Note

Remarque : Il existe un autre format de coordonnées appelé DMS (degrés-minutes-secondes), moins souvent utilisé dans les données sanitaires. Le format DMS ressemble à 8°28’55”N, 13°14’00”W et se trouve principalement dans la navigation maritime traditionnelle, l’aviation et les applications d’arpentage. Pour plus de détails, consulter la documentation sur : Fusion de vecteurs spatiaux

Précision des coordonnées

Pour les coordonnées en format décimal, le nombre de décimales indique la précision avec laquelle un établissement est localisé. Un plus grand nombre de décimales signifie une position plus précise.

  • 5 décimales ou plus (~1 mètre de précision) : idéal pour la cartographie au niveau des établissements
  • 4 décimales (~11 mètres de précision) : acceptable pour la plupart des applications SNT
  • 3 décimales (~111 mètres de précision) : peut entraîner un mauvais placement dans les zones urbaines denses
  • 2 décimales ou moins : consulter l’équipe SNT pour obtenir des coordonnées plus précises

Problèmes courants avec les données de coordonnées ponctuelles

Les données de coordonnées arrivent souvent avec des problèmes de qualité qui doivent être résolus avant la cartographie. Chaque problème ci-dessous est décrit de la même façon : à quoi il ressemble, pourquoi il est important, comment le détecter et comment le résoudre. L’Étape 3 montre le code de détection pour ces vérifications.

Coordonnées manquantes ou vides

  • À quoi cela ressemble : une ligne d’établissement a une longitude ou une latitude vide ou NA.
  • Pourquoi c’est important : l’établissement ne peut pas être placé sur une carte et est silencieusement supprimé par la plupart des fonctions de tracé.
  • Comment le détecter : compter les lignes où la longitude ou la latitude est NA ou vide.
  • Comment le résoudre : faire un suivi auprès de l’équipe SNT pour récupérer les coordonnées. Si elles ne peuvent pas être trouvées, filtrer les lignes avant la cartographie et conserver un enregistrement de celles qui ont été retirées.

Texte de coordonnées mal formé

  • À quoi cela ressemble : des valeurs qui ne sont pas des décimales propres, telles que 8,4820 (décimale avec virgule), -13.2334°, 13..26, un ? parasite à la place du point décimal, ou des espaces réservés comme "NA", "-" ou "none".
  • Pourquoi c’est important : ces chaînes de caractères ne peuvent pas être converties en nombres, l’établissement est alors perdu ou mal localisé.
  • Comment le détecter : tenter une conversion numérique et inspecter les valeurs qui deviennent NA ; rechercher les caractères non numériques.
  • Comment le résoudre : normaliser d’abord le texte (supprimer les espaces, convertir les décimales avec virgule en points, supprimer les symboles parasites et les signes moins Unicode), puis convertir en numérique.

Format degrés-minutes-secondes (DMS)

  • À quoi cela ressemble : 8°28'55"N, 13°14'00"W au lieu de degrés décimaux.
  • Pourquoi c’est important : les chaînes DMS ne sont pas numériques et ne peuvent pas être cartographiées sans conversion.
  • Comment le détecter : rechercher les symboles de degré, minute et seconde ou les indicateurs d’hémisphère N/S/E/W.
  • Comment le résoudre : convertir en degrés décimaux, par exemple avec le paquet parzer (parzer::parse_lon() / parzer::parse_lat()) ou le paquet measurements.

Coordonnées hors plage

  • À quoi cela ressemble : une longitude hors de -180 à 180 ou une latitude hors de -90 à 90 (par exemple 181 ou -200).
  • Pourquoi c’est important : le point ne peut pas exister sur Terre et signale une erreur de saisie de données ou d’unité.
  • Comment le détecter : vérifier chaque valeur par rapport aux limites WGS84 valides.
  • Comment le résoudre : mettre les valeurs hors plage à NA et les signaler à l’équipe SNT.

Latitude et longitude inversées

  • À quoi cela ressemble : la valeur de longitude se trouve dans la colonne latitude, ou vice versa, plaçant l’établissement au mauvais endroit et parfois dans le mauvais hémisphère.
  • Pourquoi c’est important : l’établissement est cartographié au mauvais endroit tout en paraissant avoir des coordonnées valides.
  • Comment le détecter : tester si le point se trouve à l’intérieur de la limite du pays ; si ce n’est pas le cas, mais que le point inversé l’est, les colonnes sont probablement inversées.
  • Comment le résoudre : confirmer l’ordre des colonnes avec la source de données, puis inverser les valeurs là où l’inversion place le point à l’intérieur du pays.

Coordonnées de l’île zéro (0, 0)

  • À quoi cela ressemble : des coordonnées exactement égales à (0, 0), un point dans le golfe de Guinée au large de l’Afrique de l’Ouest.
  • Pourquoi c’est important : (0, 0) est une valeur par défaut commune saisie lorsqu’aucune coordonnée réelle n’est disponible, il ne s’agit donc presque jamais d’un emplacement réel d’établissement.
  • Comment le détecter : signaler les lignes où la longitude et la latitude sont toutes deux égales à zéro.
  • Comment le résoudre : traiter comme manquant et re-sourcer auprès de l’équipe SNT.

Coordonnées en double

  • À quoi cela ressemble : le même identifiant d’établissement apparaît plus d’une fois avec les mêmes coordonnées.
  • Pourquoi c’est important : les enregistrements en double gonflent les comptages et biaisent tout résumé par établissement.
  • Comment le détecter : rechercher les lignes qui se répètent sur l’identifiant de l’établissement et la paire de coordonnées.
  • Comment le résoudre : conserver un enregistrement par établissement, après avoir confirmé que les doublons sont de véritables répétitions et non des établissements distincts qui partagent par hasard un nom.

Emplacements partagés et centroïdes de substitution

  • À quoi cela ressemble : plusieurs établissements différents partagent exactement la même longitude et latitude.
  • Pourquoi c’est important : lorsqu’une mesure GPS n’est pas disponible, le centroïde du district ou de la chefferie est souvent utilisé comme substitut, regroupant de nombreux établissements en un seul point et faussant l’analyse de couverture et de distance.
  • Comment le détecter : compter les établissements par paire de coordonnées unique ; toute paire partagée par plusieurs établissements est suspecte, surtout s’ils partagent une unité administrative.
  • Comment le résoudre : signaler les emplacements partagés à l’équipe SNT plutôt que de les supprimer. Les établissements genuinement co-localisés peuvent être conservés ; les centroïdes de substitution doivent être re-sourcés.

Points hors de la limite du pays

  • À quoi cela ressemble : un établissement est tracé dans l’océan ou dans un pays voisin.
  • Pourquoi c’est important : le point est clairement erroné et fausse l’étendue de la carte et toute jointure spatiale aux unités administratives.
  • Comment le détecter : tester si chaque point se trouve dans la limite nationale (adm0).
  • Comment le résoudre : signaler les points hors limite pour révision ; ils résultent généralement de coordonnées inversées, imprécises ou mal saisies.

Faible précision

  • À quoi cela ressemble : des coordonnées avec seulement quelques décimales, telles que 8.4, -11.7 au lieu de 8.412356, -11.736981.
  • Pourquoi c’est important : chaque décimale manquante élargit l’incertitude de la localisation (voir Précision des coordonnées).
  • Comment le détecter : compter les décimales dans chaque valeur et comparer à un minimum de 4 ou 5.
  • Comment le résoudre : demander des coordonnées avec au moins 5 décimales à l’équipe SNT.

Incompatibilité de CRS ou de projection

  • À quoi cela ressemble : les points des établissements et le shapefile des limites utilisent des systèmes de référence de coordonnées différents, de sorte que les couches ne se superposent pas.
  • Pourquoi c’est important : des couches mal alignées rendent la carte inexploitable même si chaque couche est individuellement correcte.
  • Comment le détecter : comparer le CRS de chaque couche (voir Systèmes de référence de coordonnées (CRS)).
  • Comment le résoudre : reprojeter toutes les couches vers un CRS commun (WGS84 pour la cartographie). Cela est pris en charge à l’Étape 2.

Colonne de coordonnées combinées

Les données des établissements de santé stockent parfois les deux coordonnées dans une seule colonne plutôt que dans des colonnes de longitude et de latitude séparées. Le tableau ci-dessous présente les trois mêmes établissements écrits des deux façons pour comparaison : en pratique, les données arriveront soit avec la paire longitude et latitude, soit avec la colonne coordinates combinée, mais pas les deux.

facility_name type longitude latitude coordinates
Central Hospital HOSPITAL -13.2334 8.4820 8.4820, -13.2334
Health Post A CHC -13.1456 8.5123 8.5123; -13.1456
District Clinic CLINIC -13.0987 8.4567 8.4567 -13.0987

Notez que la colonne combinée varie également dans son séparateur (virgule, point-virgule ou espace), ce que l’Étape 3.1 normalise avant de scinder.

  • Comment le résoudre : diviser la colonne combinée en colonnes de longitude et de latitude séparées avant l’analyse. Cela est pris en charge à l’Étape 3.1.

Étape par étape

Cette section décrit le travail avec des données de coordonnées ponctuelles pour créer des cartes des établissements de santé selon plusieurs approches de visualisation. L’exemple utilise les données des établissements de santé de la Sierra Leone avec les limites administratives.

NoteNote sur les données de démonstration

Cette page utilise les données des établissements de santé de la Sierra Leone : la liste principale des établissements (mfl_hfs.xlsx) pour les localisations et les types d’établissements, et le jeu de données DHIS2-MFL fusionné (final_dhis2_mfl_integrated.rds, produit sur la page Correspondance floue de noms entre jeux de données) pour les exemples de taux de positivité. Les limites administratives sont les objets spatiaux adm2 et adm3 traités. Remplacez-les par vos propres données d’établissements et limites.

Avant de travailler avec les données de coordonnées des établissements de santé, vérifier avec l’équipe SNT les éléments suivants :

  • La précision et l’exhaustivité des coordonnées (aucune valeur de latitude ou de longitude manquante)
  • Les noms et identifiants des établissements sont cohérents et uniques
  • Les classifications du type d’établissement sont standardisées

Pour sauter l’explication étape par étape, passez au code complet en bas de cette page.

Étape 1 : Installer et charger les paquets

Installer et charger les paquets nécessaires pour la gestion des données spatiales, la lecture de différents formats de fichiers, la manipulation des données et la visualisation avancée pour la cartographie des établissements de santé.

  • R
  • Python
Afficher le code
# vérifier si 'pacman' est installé ; l'installer si absent
if (!requireNamespace("pacman", quietly = TRUE)) {
  install.packages("pacman")
}

# charger tous les paquets requis avec pacman
pacman::p_load(
  sf,           # lecture et gestion des données spatiales
  readxl,       # lecture des fichiers Excel
  readr,        # lecture des fichiers CSV
  dplyr,        # manipulation des données
  stringr,      # nettoyage et formatage des chaînes
  ggplot2,      # création de graphiques
  RColorBrewer, # palettes de couleurs
  scales,       # formatage des échelles et étiquettes
  here,         # chemins de fichiers multi-plateformes
  tidyr         # restructuration des données et séparation de coordonnées
)

# thème de carte partagé pour une apparence cohérente sur toute la page
snt_map_theme <- function() {
  ggplot2::theme_void() +
    ggplot2::theme(
      legend.position = "bottom",
      legend.direction = "horizontal",
      legend.title.position = "top",
      legend.text.position = "bottom",
      legend.title = ggplot2::element_text(
        face = "bold", size = 10, hjust = 0.5,
        margin = ggplot2::margin(b = 6)
      ),
      legend.box.margin = ggplot2::margin(t = 8),
      legend.key.width = grid::unit(0.9, "cm"),
      strip.text = ggplot2::element_text(
        face = "bold", size = 10,
        margin = ggplot2::margin(t = 2, b = 6, l = 4, r = 4)
      ),
      panel.spacing = grid::unit(4, "pt"),
      plot.title = ggplot2::element_text(
        face = "bold", size = 14, margin = ggplot2::margin(b = 8)
      ),
      plot.subtitle = ggplot2::element_text(
        size = 11, margin = ggplot2::margin(b = 10)
      ),
      plot.margin = ggplot2::margin(t = 5, r = 5, b = 5, l = 5)
    )
}

Pour adapter le code :

  • Ne modifier aucun élément du code ci-dessus
WarningInstallation dans le terminal requise

Installer les paquets dans le terminal s’ils ne sont pas déjà installés. Pour obtenir de l’aide sur l’installation des paquets, consulter la page Prise en main.

pip install pandas geopandas pyreadr matplotlib numpy pyprojroot openpyxl pyarrow
Afficher le code
from pathlib import Path

import geopandas as gpd
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
import pyreadr
from matplotlib.cm import ScalarMappable
from pyprojroot import here


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 cli_danger(message):
    print(f"ERROR: {message}")


def anti_join(left, right, on):
    """Retourner les lignes de left sans clé correspondante dans right."""
    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 finish_map(ax, title=None, subtitle=None):
    """Appliquer le style de carte statique partagé utilisé sur cette page."""
    if title and subtitle:
        ax.set_title(
            f"{title}\n{subtitle}", loc="left", fontsize=14, fontweight="bold"
        )
    elif title:
        ax.set_title(title, loc="left", fontsize=14, fontweight="bold")
    ax.set_axis_off()


def save_figure(fig, filename, width, height, dpi=300):
    """Enregistrer une figure matplotlib avec les dimensions correspondant aux exemples R."""
    Path(filename).parent.mkdir(parents=True, exist_ok=True)
    fig.set_size_inches(width, height)
    fig.savefig(filename, dpi=dpi, bbox_inches="tight")


def show_table(df, n=10, caption=None):
    """Rendu d'un tableau HTML compact défilable correspondant au helper R show_table.

    Produit un <table class="out-table"> encapsulé dans <div class="out-scroll">
    afin que la sortie corresponde exactement au rendu R basé sur kable.
    """
    from IPython.display import display, HTML
    html = df.head(n).to_html(index=False, border=0, classes="out-table")
    if caption:
        html = f"<caption>{caption}</caption>" + html
    display(HTML(f'<div class="out-scroll">{html}</div>'))

Pour adapter le code :

  • Ne modifier aucun élément du code ci-dessus

Étape 2 : Charger et préparer les données d’entrée

Cette étape charge les limites administratives traitées et la liste principale des établissements de santé, reproduisant les entrées utilisées dans Utilisation et visualisation de base des shapefiles. Nous chargeons les objets spatiaux adm3 (chefferie) et adm2 (district) nettoyés et traités, produits dans Gestion et personnalisation des shapefiles, donc aucune transformation de CRS ni renommage n’est requis ici. Les coordonnées des établissements de santé sont lues à partir de la liste principale des établissements traitée.

Si les données ponctuelles sont stockées en format shapefile plutôt qu’en tableau, suivre Utilisation et visualisation de base des shapefiles pour les charger, puis continuer à partir des étapes de visualisation. S’assurer que les données ponctuelles et les limites administratives partagent le même CRS.

  • R
  • Python
Afficher le code
# définir le chemin vers les limites administratives traitées
spat_path <- here::here(
  "01_data",
  "1.1_foundational",
  "1.1a_administrative_boundaries"
)

# charger l'objet spatial des chefferies (adm3) traité
gdf <- readRDS(
  here::here(spat_path, "processed", "sle_spatial_adm3_2021.rds")
) |>
  sf::st_as_sf()

# charger l'objet spatial des districts (adm2) traité
adm2_gdf <- readRDS(
  here::here(spat_path, "processed", "sle_spatial_adm2_2021.rds")
) |>
  sf::st_as_sf()

# définir le chemin vers les données d'établissements de santé traitées
hf_path <- here::here(
  "01_data",
  "1.1_foundational",
  "1.1b_health_facilities",
  "processed"
)

# charger la liste principale des établissements de santé
hf_data <- readxl::read_excel(
  here::here(hf_path, "mfl_hfs.xlsx")
)

# inspecter les données chargées
cli::cli_h3("Colonnes des limites administratives")
print(names(gdf))
cli::cli_h3("Colonnes des données des établissements de santé")
print(names(hf_data))
cli::cli_h3("Échantillon des données des établissements de santé")
print(dplyr::slice_head(hf_data, n = 3))
NoteSortie
── Colonnes des limites administratives 
[1] "adm0"          "adm1"          "adm2"          "adm3"         
[5] "geometry_hash" "geometry"     
── Colonnes des données des établissements de santé 
[1] "adm1"       "adm3"       "community"  "hf"         "type"      
[6] "Ownership"  "functional" "w_lat"      "w_long"    
── Échantillon des données des établissements de santé 
adm1 adm3 community hf type Ownership functional w_lat w_long
Bombali Mara Kiampkakolo Kiampkakolo MCHP MCHP Government Functional 8.610318 -12.20295
Bombali Bombali Sebora Station Road/Vincent Kanu Road Loreto Clinic CLINIC Faith Based Functional 8.886622 -12.04387
Bombali Bombali Sebora Makama Road Makeni Govt. Hospital HOSPITAL Government Functional 8.871770 -12.05627

Pour adapter le code :

  • Lignes 2–6 : Mettre à jour spat_path vers le dossier où sont stockés les fichiers .rds spatiaux traités
  • Lignes 9–12 et 15–18 : Remplacer les noms de fichiers .rds par les noms de fichiers adm3 et adm2 traités
  • Lignes 21–26 et 29–31 : Mettre à jour hf_path et le nom du fichier de la liste principale des établissements pour correspondre aux données
Afficher le code
# définir le chemin vers les limites administratives traitées
spat_path = Path(
    here(
        "01_data/1.1_foundational/"
        "1.1a_administrative_boundaries/processed"
    )
)

# charger l'objet spatial des chefferies (adm3) traité
gdf = gpd.read_file(spat_path / "sle_spatial_adm3_2021.geojson")

# charger l'objet spatial des districts (adm2) traité
adm2_gdf = gpd.read_file(spat_path / "sle_spatial_adm2_2021.geojson")

# définir le chemin vers les données d'établissements de santé traitées
hf_path = Path(
    here(
        "01_data/1.1_foundational/"
        "1.1b_health_facilities/processed"
    )
)

# charger la liste principale des établissements de santé
hf_data = pd.read_excel(hf_path / "mfl_hfs.xlsx")

# inspecter les données chargées
cli_header("Colonnes des limites administratives")
print(list(gdf.columns))
cli_header("Colonnes des données des établissements de santé")
print(list(hf_data.columns))
cli_header("Échantillon des données des établissements de santé")
print(hf_data.head(3))
NoteSortie

Colonnes des limites administratives
['adm0', 'adm1', 'adm2', 'adm3', 'geometry_hash', 'geometry']

Colonnes des données des établissements de santé
['adm1', 'adm3', 'community', 'hf', 'type', 'Ownership', 'functional', 'w_lat', 'w_long']

Échantillon des données des établissements de santé
<IPython.core.display.HTML object>

Pour adapter le code :

  • Lignes 2–7 : Mettre à jour spat_path vers le dossier où sont stockés les fichiers spatiaux traités
  • Lignes 10 et 13 : Remplacer les noms de fichiers GeoJSON par les noms de fichiers adm3 et adm2 traités
  • Lignes 16–21 et 24 : Mettre à jour hf_path et le nom du fichier de la liste principale des établissements pour correspondre aux données

Étape 3 : Définir, valider et nettoyer les coordonnées

Étape 3.1 : Séparer la colonne de coordonnées (si nécessaire)

Cette étape n’est requise que si la longitude et la latitude sont combinées dans une seule colonne plutôt que dans des colonnes séparées (voir la section Colonne de coordonnées combinées ci-dessus). Si les données ont déjà des colonnes de longitude et de latitude séparées, passer directement à l’Étape 3.2.

Lorsque les coordonnées arrivent combinées, le séparateur entre les deux valeurs est rarement constant : certaines lignes utilisent une virgule, d’autres un point-virgule, et d’autres un espace simple. Le code ci-dessous normalise d’abord tous ces séparateurs en une virgule unique, puis divise la colonne en deux champs numériques nommés w_lat et w_long. Effectuer la normalisation en une seule passe évite une analyse ligne par ligne fragile et rend la division prévisible.

  • R
  • Python
Afficher le code
# cette étape n'est requise que si longitude et latitude sont combinées
# dans une seule colonne. si les colonnes sont séparées, ignorer cette étape.

# normaliser les séparateurs (; ou espace) en virgule, puis scinder en deux
# colonnes numériques
hf_data <- hf_data |>
  dplyr::mutate(
    coordinates = stringr::str_replace_all(coordinates, "[; ]+", ",")
  ) |>
  tidyr::separate(
    col = "coordinates",
    into = c("w_lat", "w_long"),
    sep = ",",
    convert = TRUE
  )

cli::cli_alert_success("Coordonnées séparées en colonnes distinctes")
cli::cli_h3("Échantillon des coordonnées séparées")
print(utils::head(dplyr::select(hf_data, w_lat, w_long), 3))

Pour adapter le code :

  • Lignes 8 et 11 : Remplacer "coordinates" par le nom de la colonne de coordonnées combinées
  • Ligne 12 : Mettre à jour "w_lat" et "w_long" avec les noms de colonnes préférés
  • Ignorer toute cette étape si les données ont déjà des colonnes de latitude et de longitude séparées
Afficher le code
# cette étape n'est requise que si longitude et latitude sont combinées
# dans une seule colonne. si les colonnes sont séparées, ignorer cette étape.

# remplacer les séparateurs (; ou espace) par une virgule
hf_data["coordinates"] = (
    hf_data["coordinates"].str.replace(r"[; ]+", ",", regex=True)
)

# séparer la colonne combinée en deux colonnes
coord_split = hf_data["coordinates"].str.split(",", expand=True)
hf_data = hf_data.assign(
    w_lat=pd.to_numeric(coord_split[0], errors="coerce"),
    w_long=pd.to_numeric(coord_split[1], errors="coerce"),
)

cli_success("Coordonnées séparées en colonnes distinctes")
cli_header("Échantillon des coordonnées séparées")
print(hf_data[["w_lat", "w_long"]].head(3).to_string(index=False))

Pour adapter le code :

  • Ligne 5 : Remplacer "coordinates" par le nom de la colonne de coordonnées combinées
  • Lignes 11–13 : Mettre à jour "w_lat" et "w_long" avec les noms de colonnes préférés
  • Ignorer toute cette étape si les données ont déjà des colonnes de latitude et de longitude séparées
WarningImportant : vérifier l’ordre de la latitude et de la longitude

Une erreur courante consiste à confondre latitude et longitude. Le format standard est généralement (Latitude, Longitude).

  • Latitude (axe Y) : La plage est de -90° à 90°. Représente la position Nord-Sud.
  • Longitude (axe X) : La plage est de -180° à 180°. Représente la position Est-Ouest.

Toujours confirmer l’ordre avec la source de données ou l’équipe SNT. Un ordre incorrect tracera les établissements dans le mauvais hémisphère !

Étape 3.2 : Détecter les problèmes de qualité des coordonnées

Avant le nettoyage, effectuer un ensemble de vérifications pour voir lesquels des problèmes décrits dans Problèmes courants avec les données de coordonnées ponctuelles sont présents dans les données. Les vérifications ci-dessous comptent les coordonnées manquantes, hors plage, île zéro, de faible précision, en double, à emplacement partagé, hors limite et inversées. Les vérifications d’inversion et de limite comparent chaque point à la limite nationale (adm0).

TipComment nous détectons les coordonnées inversées

Une erreur courante est d’inverser la latitude et la longitude, ce qui peut placer un établissement dans le mauvais pays ou même dans le mauvais hémisphère. Pour le détecter, nous testons chaque point par rapport à la limite nationale :

  1. Vérifier si le point, tel qu’indiqué, se trouve à l’intérieur du pays.
  2. Vérifier si le point avec sa longitude et sa latitude inversées se trouve à l’intérieur à la place.

Si un point est hors limite tel quel mais à l’intérieur une fois inversé, ses colonnes étaient très probablement inversées. L’utilisation de la limite plutôt qu’une statistique récapitulative rend le test fiable pour les pays à large étendue géographique.

  • R
  • Python
Afficher le code
# charger la limite nationale (adm0) pour les vérifications spatiales
adm0 <- readRDS(
  here::here(spat_path, "processed", "sle_spatial_adm0_2021.rds")
) |>
  sf::st_as_sf()

# assistant : compter les décimales d'une valeur de coordonnée
count_decimals <- function(value) {
  if (is.na(value)) return(0L)
  text <- format(abs(value), scientific = FALSE, trim = TRUE)
  if (!grepl(".", text, fixed = TRUE)) return(0L)
  nchar(sub("0+$", "", strsplit(text, ".", fixed = TRUE)[[1]][2]))
}

# définir la longitude (x) et la latitude (y), signaler les lignes valides dans la plage,
# et précalculer la précision décimale de chaque coordonnée
hf_data <- hf_data |>
  dplyr::mutate(
    x = w_long,
    y = w_lat,
    valid = !is.na(x) & !is.na(y) &
      dplyr::between(x, -180, 180) &
      dplyr::between(y, -90, 90),
    lon_dp = vapply(x, count_decimals, integer(1)),
    lat_dp = vapply(y, count_decimals, integer(1))
  )

# comptages : manquants, hors plage, île zéro (0, 0) et faible précision
quality_counts <- hf_data |>
  dplyr::summarise(
    n_missing = sum(is.na(x) | is.na(y)),
    n_out_range = sum(!is.na(x) & !is.na(y) & !valid),
    n_zero = sum(x == 0 & y == 0, na.rm = TRUE),
    n_imprecise = sum(valid & (lon_dp < 4 | lat_dp < 4))
  )
n_missing <- quality_counts$n_missing
n_out_range <- quality_counts$n_out_range
n_zero <- quality_counts$n_zero
n_imprecise <- quality_counts$n_imprecise

# établissement + coordonnée en double et emplacements partagés
valid_data <- hf_data |> dplyr::filter(valid)
n_duplicate <- sum(duplicated(
  paste(valid_data$hf, valid_data$x, valid_data$y)
))
coord_key <- paste(valid_data$x, valid_data$y)
n_shared <- sum(coord_key %in% coord_key[duplicated(coord_key)])

# construire un objet sf des points valides pour les vérifications spatiales
hf_points <- valid_data |>
  sf::st_as_sf(
    coords = c("x", "y"),
    crs = 4326,
    remove = FALSE
  )

# points hors de la limite nationale
inside <- lengths(suppressWarnings(sf::st_within(hf_points, adm0))) > 0
n_outside <- sum(!inside)

# lon/lat probablement inversés : hors limite tel quel, mais dedans une fois inversés
swapped <- hf_points |>
  sf::st_drop_geometry() |>
  dplyr::transmute(x = y, y = x) |>
  sf::st_as_sf(coords = c("x", "y"), crs = 4326)
inside_swapped <- lengths(
  suppressWarnings(sf::st_within(swapped, adm0))
) > 0
n_flipped <- sum(!inside & inside_swapped)

# afficher le bilan des problèmes
cli::cli_h2("Bilan de qualité des coordonnées ({nrow(hf_data)} établissements)")
cli::cli_alert_info("Coordonnées manquantes : {n_missing}")
cli::cli_alert_info("Coordonnées hors plage : {n_out_range}")
cli::cli_alert_info("Île zéro (0, 0) : {n_zero}")
cli::cli_alert_info("Faible précision (< 4 dp) : {n_imprecise}")
cli::cli_alert_info("Établissement et coordonnée en double : {n_duplicate}")
cli::cli_alert_info("Enregistrements à emplacements partagés : {n_shared}")
cli::cli_alert_info("Hors de la limite nationale : {n_outside}")
cli::cli_alert_info("Lon/lat probablement inversés : {n_flipped}")
NoteSortie
── Bilan de qualité des coordonnées (1556 établissements) ──
ℹ Coordonnées manquantes : 25
ℹ Coordonnées hors plage : 0
ℹ Île zéro (0, 0) : 0
ℹ Faible précision (< 4 dp) : 30
ℹ Établissement et coordonnée en double : 0
ℹ Enregistrements à emplacements partagés : 21
ℹ Hors de la limite nationale : 0
ℹ Lon/lat probablement inversés : 0

Pour adapter le code :

  • Ligne 3 : Mettre à jour le nom du fichier adm0 avec le fichier de limite nationale
  • Lignes 19–20 : Mettre à jour w_long et w_lat avec les colonnes de longitude et de latitude
  • Ligne 34 : Ajuster le nombre minimum de décimales utilisé pour la vérification de la précision

Tracer les points par rapport à la limite nationale donne une confirmation visuelle rapide qu’ils se trouvent là où ils devraient, et facilite la détection des points hors limite ou inversés.

Afficher le code
# marquer chaque point valide selon s'il se trouve à l'intérieur de la limite nationale
hf_points <- hf_points |>
  dplyr::mutate(
    location = dplyr::if_else(inside, "Dans la limite", "Hors limite")
  )

# cartographier les points sur la limite nationale
coord_check_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = adm0, fill = "white", color = "black") +
  ggplot2::geom_sf(
    data = hf_points,
    ggplot2::aes(color = location),
    size = 0.8, alpha = 0.7
  ) +
  ggplot2::scale_color_manual(
    values = c(
      "Dans la limite" = "#2c7fb8",
      "Hors limite" = "#e31a1c"
    ),
    name = NULL
  ) +
  snt_map_theme() +
  ggplot2::labs(
    title = "Contrôle des coordonnées : établissements par rapport à la limite nationale"
  )

print(coord_check_map)
NoteSortie

Afficher le code
# charger la limite nationale (adm0) pour les vérifications spatiales
adm0 = gpd.read_file(
    Path(
        here(
            "01_data/1.1_foundational/"
            "1.1a_administrative_boundaries/processed/"
            "sle_spatial_adm0_2021.geojson"
        )
    )
)

# assistant : compter les décimales d'une valeur de coordonnée
def count_decimals(value):
    if pd.isna(value):
        return 0
    text = f"{abs(value):.10f}".rstrip("0")
    if "." not in text:
        return 0
    return len(text.split(".")[1])

# définir la longitude (x) et la latitude (y)
hf_data = hf_data.assign(x=hf_data["w_long"], y=hf_data["w_lat"])

# lignes avec coordonnées valides et dans la plage (pour les vérifications spatiales)
valid = (
    hf_data["x"].notna() & hf_data["y"].notna()
    & hf_data["x"].between(-180, 180)
    & hf_data["y"].between(-90, 90)
)

# coordonnées manquantes, hors plage et île zéro (0, 0)
n_missing = (hf_data["x"].isna() | hf_data["y"].isna()).sum()
n_out_range = (
    hf_data["x"].notna() & hf_data["y"].notna() & ~valid
).sum()
n_zero = (
    (hf_data["x"] == 0) & (hf_data["y"] == 0)
).sum()

# faible précision (moins de 4 décimales)
lon_dp = hf_data["x"].apply(count_decimals)
lat_dp = hf_data["y"].apply(count_decimals)
n_imprecise = int((valid & ((lon_dp < 4) | (lat_dp < 4))).sum())

# établissement + coordonnée en double et emplacements partagés
valid_data = hf_data.loc[valid].copy()
keys = (
    valid_data["hf"].astype(str) + "_"
    + valid_data["x"].astype(str) + "_"
    + valid_data["y"].astype(str)
)
n_duplicate = int(keys.duplicated().sum())
coord_key = (
    valid_data["x"].astype(str) + "_" + valid_data["y"].astype(str)
)
n_shared = int(
    coord_key.isin(coord_key[coord_key.duplicated()]).sum()
)

# construire un GeoDataFrame des points valides pour les vérifications spatiales
if adm0.crs is None:
    adm0 = adm0.set_crs("EPSG:4326")
hf_points = gpd.GeoDataFrame(
    valid_data,
    geometry=gpd.points_from_xy(valid_data["x"], valid_data["y"]),
    crs="EPSG:4326",
)
if hf_points.crs != adm0.crs:
    hf_points = hf_points.to_crs(adm0.crs)

# points hors de la limite nationale
inside = hf_points.within(adm0.union_all())
n_outside = int((~inside).sum())

# lon/lat probablement inversés : hors limite tel quel, mais dedans une fois inversés
swapped = gpd.GeoDataFrame(
    geometry=gpd.points_from_xy(hf_points["y"], hf_points["x"]),
    crs="EPSG:4326",
)
inside_swapped = swapped.within(adm0.union_all())
n_flipped = int((~inside & inside_swapped).sum())

# afficher le bilan des problèmes
cli_header(
    f"Bilan de qualité des coordonnées ({len(hf_data)} établissements)"
)
cli_info(f"Coordonnées manquantes : {n_missing}")
cli_info(f"Coordonnées hors plage : {n_out_range}")
cli_info(f"Île zéro (0, 0) : {n_zero}")
cli_info(f"Faible précision (< 4 dp) : {n_imprecise}")
cli_info(f"Établissement et coordonnée en double : {n_duplicate}")
cli_info(f"Enregistrements à emplacements partagés : {n_shared}")
cli_info(f"Hors de la limite nationale : {n_outside}")
cli_info(f"Lon/lat probablement inversés : {n_flipped}")
Afficher le code
# marquer chaque point valide selon s'il se trouve à l'intérieur de la limite nationale
hf_points = hf_points.assign(
    location=inside.map(
        {True: "Dans la limite", False: "Hors limite"}
    )
)

# cartographier les points sur la limite nationale
color_map = {"Dans la limite": "#2c7fb8", "Hors limite": "#e31a1c"}
fig, ax = plt.subplots(figsize=(8, 9))
adm0.plot(ax=ax, facecolor="white", edgecolor="black", linewidth=0.8,
          aspect="equal")
for label, color in color_map.items():
    subset = hf_points.loc[hf_points["location"] == label]
    subset.plot(
        ax=ax, color=color, markersize=3, alpha=0.7, label=label,
        aspect="equal",
    )
ax.legend(
    loc="lower center", bbox_to_anchor=(0.5, -0.05),
    frameon=False, fontsize=9, ncol=2,
)
finish_map(
    ax,
    title="Contrôle des coordonnées : établissements par rapport à la limite nationale",
)
NoteSortie

Bilan de qualité des coordonnées (1556 établissements)
INFO: Coordonnées manquantes : 25
INFO: Coordonnées hors plage : 0
INFO: Île zéro (0, 0) : 0
INFO: Faible précision (< 4 dp) : 2
INFO: Établissement et coordonnée en double : 0
INFO: Enregistrements à emplacements partagés : 21
INFO: Hors de la limite nationale : 0
INFO: Lon/lat probablement inversés : 0

Pour adapter le code :

  • Ligne 7 : Mettre à jour le nom du fichier adm0 avec le fichier de limite nationale
  • Ligne 22 : Mettre à jour w_long et w_lat avec les colonnes de longitude et de latitude
  • Ligne 43 : Ajuster le nombre minimum de décimales utilisé pour la vérification de la précision
NoteSortie

Étape 3.3 : Nettoyer les coordonnées

L’Étape 3.2 a mis en évidence les problèmes de qualité ; cette étape agit sur les plus bloquants. Les établissements avec des coordonnées manquantes ou des valeurs hors de la plage WGS84 valide ne peuvent pas être cartographiés et sont silencieusement ignorés par la plupart des fonctions de tracé ; nous les filtrons donc dans une table de révision séparée avant de produire le jeu de données nettoyé. La table de révision est conservée intentionnellement : ces établissements ne sont pas supprimés, ils sont mis de côté afin que l’équipe SNT puisse récupérer les coordonnées plutôt que de perdre l’établissement de la liste principale.

Les autres problèmes de l’Étape 3.2 (faible précision, doublons, emplacements partagés, points hors du pays, inversions probables) sont généralement résolus en collaboration avec l’équipe SNT plutôt que supprimés automatiquement, car chacun nécessite un jugement sur la réalité de l’établissement sous-jacent. La sortie affiche le nombre de lignes conservées, le nombre signalé pour révision, ainsi que les plages de longitude et de latitude des données nettoyées pour confirmer que les bornes correspondent à ce qui est attendu pour le pays.

  • R
  • Python
Afficher le code
# définir les coordonnées de longitude (x) et de latitude (y)
hf_data <- hf_data |>
  dplyr::mutate(
    x = w_long,
    y = w_lat
  )

# identifier les établissements avec des coordonnées invalides pour suivi
facilities_to_review <- hf_data |>
  dplyr::filter(
    is.na(x) | is.na(y) |
      !dplyr::between(x, -180, 180) |
      !dplyr::between(y, -90, 90)
  )

# filtrer pour ne conserver que les coordonnées valides et dans la plage
facilities_clean <- hf_data |>
  dplyr::filter(
    !is.na(x),
    !is.na(y),
    dplyr::between(x, -180, 180),
    dplyr::between(y, -90, 90)
  )

# afficher les résultats du nettoyage
cli::cli_h2("Résultats du nettoyage des données")
cli::cli_alert_info("Établissements de santé initiaux : {nrow(hf_data)}")
cli::cli_alert_info("Établissements de santé nettoyés : {nrow(facilities_clean)}")
cli::cli_alert_info(
  "Établissements supprimés : {nrow(hf_data) - nrow(facilities_clean)}"
)
cli::cli_alert_info(
  "Établissements signalés pour révision : {nrow(facilities_to_review)}"
)
cli::cli_alert_info(
  "Plage de longitude : {min(facilities_clean$x)} à {max(facilities_clean$x)}"
)
cli::cli_alert_info(
  "Plage de latitude : {min(facilities_clean$y)} à {max(facilities_clean$y)}"
)
NoteSortie
── Résultats du nettoyage des données ──
ℹ Établissements de santé initiaux : 1556
ℹ Établissements de santé nettoyés : 1531
ℹ Établissements supprimés : 25
ℹ Plage de longitude : -13.2920704 à -10.3074397999999
ℹ Plage de latitude : 6.9679198 à 9.9733458

Pour adapter le code :

  • Lignes 4–5 : Mettre à jour w_long et w_lat pour correspondre aux noms de colonnes de longitude et de latitude
Afficher le code
# définir les coordonnées de longitude (x) et de latitude (y)
hf_data = hf_data.assign(x=hf_data["w_long"], y=hf_data["w_lat"])

# identifier les établissements avec des coordonnées invalides pour suivi
facilities_to_review = hf_data.loc[
    hf_data["x"].isna()
    | hf_data["y"].isna()
    | (hf_data["x"] < -180) | (hf_data["x"] > 180)
    | (hf_data["y"] < -90) | (hf_data["y"] > 90)
]

# filtrer pour ne conserver que les coordonnées valides et dans la plage
facilities_clean = hf_data.loc[
    hf_data["x"].notna()
    & hf_data["y"].notna()
    & hf_data["x"].between(-180, 180)
    & hf_data["y"].between(-90, 90)
]

# afficher les résultats du nettoyage
cli_header("Résultats du nettoyage des données")
cli_info(f"Établissements de santé initiaux : {len(hf_data)}")
cli_info(f"Établissements de santé nettoyés : {len(facilities_clean)}")
cli_info(
    f"Établissements supprimés : {len(hf_data) - len(facilities_clean)}"
)
cli_info(
    f"Établissements signalés pour révision : {len(facilities_to_review)}"
)
cli_info(
    f"Plage de longitude : {facilities_clean['x'].min()} à "
    f"{facilities_clean['x'].max()}"
)
cli_info(
    f"Plage de latitude : {facilities_clean['y'].min()} à "
    f"{facilities_clean['y'].max()}"
)
NoteSortie

Résultats du nettoyage des données
INFO: Établissements de santé initiaux : 1556
INFO: Établissements de santé nettoyés : 1531
INFO: Établissements supprimés : 25
INFO: Plage de longitude : -13.2920704 à -10.3074397999999
INFO: Plage de latitude : 6.9679198 à 9.9733458

Pour adapter le code :

  • Ligne 2 : Mettre à jour w_long et w_lat pour correspondre aux noms de colonnes de longitude et de latitude

Étape 4 : Convertir en objet spatial

Convertir les données d’établissements de santé nettoyées en objet spatial pour des opérations et analyses spatiales avancées.

NotePourquoi créer un objet spatial ?

Un objet spatial est un type particulier de jeu de données qui comprend que les points se trouvent sur une carte, et non de simples nombres dans un tableau. Cela est requis pour toute opération géographique.

Effectuer cette étape si l’on envisage de :

  • Cartographier les établissements.
  • Calculer des distances ou des zones de desserte.
  • Joindre les données d’établissements aux limites administratives (par exemple, dans quel district se trouve chaque établissement ?).
  • Effectuer toute analyse qui utilise la localisation géographique des établissements.
  • R
  • Python
Afficher le code
# convertir en objet spatial
facilities_sf <- sf::st_as_sf(
  facilities_clean,
  coords = c("w_long", "w_lat"),
  crs = 4326
)

cli::cli_alert_success("Converti en objet spatial")
cli::cli_h3("Résumé de l'objet spatial")
print(dplyr::slice_head(facilities_sf, n = 3))
NoteSortie
✔ Converti en objet spatial
── Résumé de l'objet spatial 
adm1 adm3 community hf type Ownership functional x y valid lon_dp lat_dp geometry
Bombali Mara Kiampkakolo Kiampkakolo MCHP MCHP Government Functional -12.20295 8.610318 TRUE 5 6 POINT (-12.20295 8.610318)
Bombali Bombali Sebora Station Road/Vincent Kanu Road Loreto Clinic CLINIC Faith Based Functional -12.04387 8.886622 TRUE 5 6 POINT (-12.04387 8.886622)
Bombali Bombali Sebora Makama Road Makeni Govt. Hospital HOSPITAL Government Functional -12.05627 8.871770 TRUE 5 5 POINT (-12.05627 8.87177)

Pour adapter le code :

  • Ligne 4 : Mettre à jour les noms de colonnes de coordonnées pour correspondre aux données
Afficher le code
# convertir le tableau nettoyé en GeoDataFrame à partir des coordonnées ponctuelles
facilities_sf = gpd.GeoDataFrame(
    facilities_clean,
    geometry=gpd.points_from_xy(
        facilities_clean["w_long"], facilities_clean["w_lat"]
    ),
    crs="EPSG:4326",
)

cli_success("Converti en objet spatial")
cli_header("Résumé de l'objet spatial")
print(facilities_sf.head(3).to_string(index=False))
NoteSortie
SUCCESS: Converti en objet spatial

Résumé de l'objet spatial
<IPython.core.display.HTML object>

Pour adapter le code :

  • Ligne 5 : Mettre à jour les noms de colonnes de coordonnées pour correspondre aux données

Étape 5 : Assigner les établissements aux unités administratives

Avec les établissements stockés en tant qu’objet spatial, nous pouvons déterminer dans quelle unité administrative se trouve chaque établissement grâce à une jointure spatiale. Cela attribue à chaque établissement la limite dans laquelle il se situe, ce qui est nécessaire pour l’analyse des zones de desserte, les résumés par district, et pour vérifier que l’unité administrative enregistrée d’un établissement correspond à l’endroit où ses coordonnées se trouvent réellement.

R offre plusieurs façons de mettre en relation des points et des polygones dans une jointure spatiale :

  • sf::st_within : le point se trouve entièrement à l’intérieur du polygone (utilisé ci-dessous)
  • sf::st_contains : le polygone contient le point (la relation inverse)
  • sf::st_intersects : le point touche ou se trouve à l’intérieur du polygone
  • sf::st_nearest_feature : assigne le polygone le plus proche lorsqu’un point se trouve juste en dehors de toutes les limites
  • R
  • Python
Afficher le code
# conserver les colonnes admin de limite, renommées pour éviter les conflits avec
# les propres colonnes admin des données d'établissements
boundary_lookup <- gdf |>
  dplyr::select(adm2_boundary = adm2, adm3_boundary = adm3)

# assigner chaque établissement au polygone de chefferie (adm3) dans lequel il se situe
facilities_admin <- suppressWarnings(sf::st_join(
  facilities_sf,
  boundary_lookup,
  join = sf::st_within
))

# établissements qui ne se trouvaient dans aucun polygone
n_unmatched <- sum(is.na(facilities_admin$adm3_boundary))

cli::cli_h3("Établissements assignés à une unité administrative")
cli::cli_alert_info("Établissements joints : {nrow(facilities_admin)}")
cli::cli_alert_info("Non appariés (hors de tous les polygones) : {n_unmatched}")

# comparer la chefferie enregistrée avec le polygone dans lequel le point se situe
facilities_admin |>
  sf::st_drop_geometry() |>
  dplyr::select(hf, adm3, adm3_boundary) |>
  dplyr::slice_head(n = 5) |>
  print()
NoteSortie
── Établissements assignés à une unité administrative 
ℹ Établissements joints : 1531
ℹ Non appariés (hors de tous les polygones) : 0
hf adm3 adm3_boundary
Kiampkakolo MCHP Mara MARA
Loreto Clinic Bombali Sebora MAKENI CITY
Makeni Govt. Hospital Bombali Sebora MAKENI CITY
Tonko CHP Bombali Sebora MAKENI CITY
Fullah Town CHP Bombali Sebora MAKENI CITY

Pour adapter le code :

  • Ligne 4 : Remplacer adm2 et adm3 par les colonnes admin dans le fichier de limites
  • Ligne 10 : Changer la méthode de jointure (par exemple sf::st_intersects) si des points se trouvent sur les limites
WarningPoints hors des limites

Certains établissements peuvent se trouver juste en dehors d’une limite, notamment près des frontières ou lorsque les coordonnées sont imprécises. Une jointure stricte sf::st_within laisse ces points sans correspondance. Pour les récupérer, utiliser une jointure avec tampon (correspondance des points dans un périmètre d’un polygone) ou sf::st_nearest_feature (assigner le polygone le plus proche). Distances de tampon suggérées :

  • GPS des appareils mobiles (précision moindre) : 500 m à 1 km
  • Établissements près des frontières administratives : 500 m à 2 km
  • Grappes d’enquête déplacées pour des raisons de confidentialité (telles que DHS) : 100 m à 500 m

Le tampon récupère les points manqués de peu mais peut assigner des points à la mauvaise unité dans les zones frontalières denses, donc réviser les résultats avec l’équipe SNT.

Afficher le code
# conserver uniquement les colonnes admin de limite, renommées pour éviter les conflits
# avec les propres colonnes admin des données d'établissements
boundary_lookup = gdf[["adm2", "adm3", "geometry"]].rename(
    columns={"adm2": "adm2_boundary", "adm3": "adm3_boundary"}
)

# assigner un CRS par défaut si absent avant la jointure spatiale
if boundary_lookup.crs is None:
    boundary_lookup = boundary_lookup.set_crs("EPSG:4326")
if facilities_sf.crs is None:
    facilities_sf = facilities_sf.set_crs("EPSG:4326")

# assigner chaque établissement au polygone de chefferie (adm3) dans lequel il se situe
facilities_admin = gpd.sjoin(
    facilities_sf,
    boundary_lookup,
    how="left",
    predicate="within",
)

# établissements qui ne se trouvaient dans aucun polygone
n_unmatched = facilities_admin["adm3_boundary"].isna().sum()

cli_header("Établissements assignés à une unité administrative")
cli_info(f"Établissements joints : {len(facilities_admin)}")
cli_info(f"Non appariés (hors de tous les polygones) : {n_unmatched}")

# comparer la chefferie enregistrée avec le polygone dans lequel le point se situe
print(
    facilities_admin[["hf", "adm3", "adm3_boundary"]]
    .head(5)
    .to_string(index=False)
)
NoteSortie

Établissements assignés à une unité administrative
INFO: Établissements joints : 1531
INFO: Non appariés (hors de tous les polygones) : 0
<IPython.core.display.HTML object>

Pour adapter le code :

  • Lignes 3–4 : Remplacer adm2 et adm3 par les colonnes admin dans le fichier de limites
  • Ligne 18 : Changer le prédicat (par exemple "intersects") si des points se trouvent sur les limites

Étape 6 : Sauvegarder les données nettoyées et spatiales

Le tableau nettoyé et l’objet spatial sont les deux sorties de ce flux de travail que les analyses en aval utiliseront, donc nous les sauvegardons tous les deux. Le tableau nettoyé est écrit dans un fichier CSV qui peut être ouvert dans Excel ou tout outil de base de données, ce qui est le format généralement demandé par les équipes SNT pour la révision et le partage. L’objet spatial est écrit dans un shapefile (R) ou un GeoPackage (Python), ce qui préserve le CRS, le type de géométrie et les attributs des colonnes afin que les données puissent être chargées directement dans un logiciel SIG ou dans un script ultérieur sans avoir à relancer les étapes de nettoyage et de conversion.

Stocker les sorties traitées dans 01_data/.../processed/ maintient une séparation nette par rapport à la liste principale brute des établissements et prépare les données pour les analyses de zones de desserte, les calculs de distance ou les cartes d’indicateurs au niveau des établissements présentées aux Étapes 7 et 8.

  • R
  • Python
Afficher le code
# définir le répertoire de sortie pour les jeux de données traités
out_path <- here::here(
  "01_data",
  "1.1_foundational",
  "1.1b_health_facilities",
  "processed"
)

# sauvegarder le tableau d'établissements nettoyé en CSV
readr::write_csv(
  facilities_clean,
  here::here(out_path, "facilities_clean.csv")
)

# sauvegarder les points d'établissements en shapefile
sf::st_write(
  facilities_sf,
  here::here(out_path, "facilities_spatial.shp"),
  append = FALSE,
  quiet = TRUE
)

Pour adapter le code :

  • Lignes 2–7 : Mettre à jour out_path vers le dossier où vous souhaitez sauvegarder les données traitées
  • Lignes 12 et 18 : Mettre à jour les noms de fichiers de sortie selon les besoins
Afficher le code
# définir le répertoire de sortie pour les jeux de données traités
out_path = Path(
    here(
        "01_data/1.1_foundational/"
        "1.1b_health_facilities/processed"
    )
)
out_path.mkdir(parents=True, exist_ok=True)

# sauvegarder le tableau d'établissements nettoyé en CSV
facilities_clean.to_csv(
    out_path / "facilities_clean.csv", index=False
)

# sauvegarder les points d'établissements en GeoPackage
facilities_sf.to_file(
    out_path / "facilities_spatial.gpkg",
    layer="facilities",
    driver="GPKG",
)

Pour adapter le code :

  • Lignes 2–8 : Mettre à jour out_path vers le dossier où vous souhaitez sauvegarder les données traitées
  • Lignes 12 et 17 : Mettre à jour les noms de fichiers de sortie selon les besoins

Étape 7 : Créer des cartes des établissements de santé

Avec les données nettoyées et jointes aux unités administratives, l’étape suivante consiste à les porter sur une carte. Un même jeu de données de points d’établissements peut être visualisé de plusieurs façons complémentaires, et chaque vue répond à une question différente sur le système de santé. Cette étape présente trois cartes de plus en plus détaillées :

  • Une carte de vue d’ensemble de base (Étape 7.1) qui montre où se trouve chaque établissement par rapport aux limites administratives.
  • Une carte colorée par type (Étape 7.2) qui ajoute le type d’établissement comme deuxième dimension pour révéler le mix de services dans chaque zone.
  • Une carte en petits multiples (Étape 7.3) qui sépare chaque type d’établissement dans son propre panneau pour faciliter la comparaison.

Les trois cartes réutilisent les mêmes couches de limites (gdf pour les chefferies et adm2_gdf pour les districts) et les mêmes données ponctuelles nettoyées (facilities_clean), de sorte que seuls le style et les mappings esthétiques changent entre elles. Chaque carte est également sauvegardée dans 03_output/3a_figures/ afin de pouvoir être intégrée dans des rapports.

Étape 7.1 : Carte de base des établissements de santé

La carte de base est la première vérification visuelle du jeu de données nettoyé. Tracer chaque établissement sous forme d’un point unicolore sur les limites des chefferies et des districts montre la couverture géographique globale du système de santé et rend facile à repérer toute anomalie résiduelle (clusters au mauvais endroit, points isolés hors du pays, grandes lacunes entre établissements).

Nous utilisons un remplissage gris clair pour les polygones de chefferie afin que la carte reste épurée, un contour plus foncé pour les districts afin que la structure de niveau supérieur reste lisible, et une seule couleur bleue pour les points d’établissements afin que l’œil puisse se concentrer sur la densité plutôt que sur la catégorie. La taille des points est réglée suffisamment généreusement pour rester visible sur le contour du pays, avec une certaine transparence afin que les clusters denses ne se fondent pas en une seule tache solide.

  • R
  • Python
Afficher le code
# définir le titre de la carte
title <- "Établissements de santé en Sierra Leone"

# créer la carte de base
facilities_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "grey50",
                   size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = facilities_clean,
    ggplot2::aes(x = x, y = y),
    color = "#47B5FF", size = 1.8, alpha = 0.7
  ) +
  ggplot2::coord_sf() +
  snt_map_theme() +
  ggplot2::labs(
    title = title,
    subtitle = "Limites adm3 avec superposition adm2"
  )

# afficher la carte
print(facilities_map)

# sauvegarder la carte
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_locations.png"
  ),
  plot = facilities_map,
  width = 10,
  height = 7,
  dpi = 300
)
NoteSortie

Pour adapter le code :

  • Ligne 2 : Mettre à jour le titre de la carte
  • Lignes 6–11 : Mettre à jour fill, color et size pour le style des limites
  • Ligne 15 : Mettre à jour color, size et alpha pour les points des établissements de santé
Afficher le code
# définir le titre de la carte
title = "Établissements de santé en Sierra Leone"

# créer la carte de base
fig, ax = plt.subplots(figsize=(10, 8))
gdf.plot(ax=ax, facecolor="white", edgecolor="grey", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
ax.scatter(
    facilities_clean["x"],
    facilities_clean["y"],
    color="#47B5FF",
    s=15,
    alpha=0.7,
    zorder=5,
)
finish_map(ax, title=title, subtitle="Limites adm3 avec superposition adm2")

# sauvegarder la carte
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_locations.png"),
    width=10,
    height=7,
)
NoteSortie

Pour adapter le code :

  • Ligne 2 : Mettre à jour le titre de la carte
  • Lignes 6–9 : Mettre à jour facecolor, edgecolor et linewidth pour le style des limites
  • Lignes 13–15 : Mettre à jour color, s (taille) et alpha pour les points des établissements de santé

La carte résultante donne une vue nationale de la couverture des établissements. Les districts (les contours plus foncés) qui contiennent peu ou pas de points bleus indiquent des lacunes de service potentielles et sont généralement les premiers endroits que l’équipe SNT priorise pour le suivi. À l’inverse, les clusters très denses dans une seule chefferie peuvent laisser entrevoir des enregistrements en double ou des coordonnées partagées qui ont échappé à l’Étape 3.2 et méritent d’être réexaminés.

Étape 7.2 : Établissements de santé par type

La carte de base montre où se trouvent les établissements, mais ne montre pas quel type de soins chacun fournit. La cartographie du type d’établissement comme couleur catégorielle révèle le mix de services à travers le pays : quels districts dépendent principalement des postes de santé communautaires, lesquels ont un hôpital à proximité, et lesquels combinent plusieurs niveaux de soins. C’est l’une des cartes les plus utiles pour la planification de la disponibilité des services et l’analyse des zones de desserte, car chaque niveau de soins sert généralement une population et un profil de cas différents.

Le code est presque identique à l’Étape 7.1, avec un seul changement : l’esthétique color dans geom_point() (ou la boucle scatter() par type en Python) est liée à la colonne type plutôt que fixée à une valeur. Une palette catégorielle (RColorBrewer::brewer.pal("Set1") en R, la palette Set1 correspondante en Python) fournit des couleurs distinctes pour chaque type d’établissement, et une légende partagée unique liste les catégories.

  • R
  • Python
Afficher le code
# définir le titre de la carte des types d'établissements
title_type <- "Types d'établissements de santé en Sierra Leone"

# créer la carte des types d'établissements de santé
facility_type_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = facilities_clean,
    ggplot2::aes(x = x, y = y, color = type),
    size = 1, alpha = 0.8
  ) +
  ggplot2::coord_sf() +
  ggplot2::scale_color_brewer(
    palette = "Set1", name = "Type d'établissement"
  ) +
  snt_map_theme() +
  ggplot2::labs(title = title_type)

# afficher la carte des types
print(facility_type_map)

# sauvegarder la carte des types
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_by_type.png"
  ),
  plot = facility_type_map,
  width = 10,
  height = 7,
  dpi = 300
)
NoteSortie

Pour adapter le code :

  • Ligne 2 : Mettre à jour le titre de la carte
  • Ligne 13 : Remplacer type par le nom de la colonne du type d’établissement
  • Ligne 18 : Mettre à jour palette pour différents schémas de couleurs
Afficher le code
# définir le titre de la carte des types d'établissements
title_type = "Types d'établissements de santé en Sierra Leone"

# obtenir les types d'établissements ordonnés et assigner une couleur Set1 à chacun
facility_types = sorted(facilities_clean["type"].dropna().unique())
set1_colors = plt.get_cmap("Set1").colors
type_palette = {
    t: set1_colors[i % len(set1_colors)]
    for i, t in enumerate(facility_types)
}

# créer la carte des types d'établissements de santé
fig, ax = plt.subplots(figsize=(10, 9))
gdf.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
for ftype, color in type_palette.items():
    subset = facilities_clean.loc[facilities_clean["type"] == ftype]
    ax.scatter(
        subset["x"], subset["y"],
        color=color, s=5, alpha=0.8, label=ftype, zorder=5,
    )
ax.legend(
    title="Type d'établissement",
    loc="lower center",
    bbox_to_anchor=(0.5, -0.10),
    ncol=3,
    frameon=False,
    fontsize=8,
    title_fontsize=9,
)
finish_map(ax, title=title_type)

# sauvegarder la carte des types
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_by_type.png"),
    width=10,
    height=7,
)
NoteSortie

Pour adapter le code :

  • Ligne 2 : Mettre à jour le titre de la carte
  • Ligne 5 : Remplacer type par le nom de la colonne du type d’établissement
  • Lignes 6–10 : Mettre à jour set1_colors ou remplacer par une palette de couleurs différente

La lecture de la carte par couleur donne un sens immédiat de la concentration des services de niveau supérieur et des zones où dominent les soins de base. Les districts desservis uniquement par des postes de santé communautaires et des cliniques, par exemple, apparaîtront dans une ou deux couleurs, tandis que les districts urbains mélangent généralement plusieurs types. C’est souvent la première carte partagée avec l’équipe SNT lors d’une discussion sur le mix de services.

Étape 7.3 : Types spécifiques d’établissements de santé

Lorsque plusieurs types d’établissements se superposent sur la même carte, les zones denses peuvent rendre difficile la lecture de la distribution d’une seule catégorie. Les petits multiples (aussi appelés facettes) résolvent ce problème en dessinant un panneau par type, chacun avec les mêmes limites et axes pour que les panneaux puissent être comparés d’un coup d’œil. Regarder les hôpitaux seuls, par exemple, rend évidents les manques de couverture en matière de référence d’une façon que la carte combinée ne peut pas montrer.

En R, il suffit d’un seul appel à ggplot2::facet_wrap(~ type). En Python, nous reproduisons le même effet avec une grille de sous-tracés matplotlib, un par type d’établissement, partageant une mise en page commune. Le style des points est intentionnellement maintenu identique à l’Étape 7.1 afin que chaque facette se lise comme une carte de distribution à couleur unique et épurée.

  • R
  • Python
Afficher le code
# définir le titre de la carte en panneaux
title_facet <- "Répartition des établissements de santé par type en Sierra Leone"

# créer la carte en panneaux par type d'établissement
faceted_type_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.2) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = facilities_clean,
    ggplot2::aes(x = x, y = y),
    color = "#47B5FF", size = 1, alpha = 0.7
  ) +
  ggplot2::facet_wrap(~ type) +
  ggplot2::coord_sf() +
  snt_map_theme() +
  ggplot2::labs(title = title_facet)

# afficher la carte en panneaux
print(faceted_type_map)

# sauvegarder la carte en panneaux
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_faceted_by_type.png"
  ),
  plot = faceted_type_map,
  width = 12,
  height = 9,
  dpi = 300
)
NoteSortie

Pour adapter le code :

  • Ligne 2 : Mettre à jour le titre de la carte
  • Ligne 16 : Remplacer type dans facet_wrap() par le nom de la colonne du type d’établissement
  • Ligne 14 : Mettre à jour color, size et alpha des points selon les besoins
Afficher le code
# définir le titre de la carte en panneaux
title_facet = "Répartition des établissements de santé par type en Sierra Leone"

# obtenir les types d'établissements uniques pour les panneaux
facility_types = sorted(facilities_clean["type"].dropna().unique())
n_types = len(facility_types)
ncols = 3
nrows = -(-n_types // ncols)  # ceiling division

fig, axes = plt.subplots(nrows, ncols, figsize=(12, 9))
axes_flat = axes.flatten()

for i, ftype in enumerate(facility_types):
    ax = axes_flat[i]
    gdf.plot(ax=ax, facecolor="white", edgecolor="gray",
             linewidth=0.2, aspect="equal")
    adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
                  linewidth=0.5, aspect="equal")
    subset = facilities_clean.loc[facilities_clean["type"] == ftype]
    ax.scatter(
        subset["x"], subset["y"],
        color="#47B5FF", s=5, alpha=0.7, zorder=5,
    )
    ax.set_title(ftype, fontsize=10, fontweight="bold")
    ax.set_axis_off()

# masquer les panneaux d'axes inutilisés
for j in range(n_types, len(axes_flat)):
    axes_flat[j].set_visible(False)

fig.suptitle(title_facet, fontsize=14, fontweight="bold", x=0.02,
             ha="left")
plt.tight_layout()

# sauvegarder la carte en panneaux
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_faceted_by_type.png"),
    width=12,
    height=9,
)
NoteSortie

Pour adapter le code :

  • Ligne 2 : Mettre à jour le titre de la carte
  • Ligne 5 : Remplacer type par le nom de la colonne du type d’établissement
  • Ligne 22 : Mettre à jour color, s (taille) et alpha des points selon les besoins

La lecture de chaque panneau isolément facilite l’évaluation de la couverture spécifique à chaque type. Les hôpitaux et les établissements de référence n’apparaissent souvent que dans quelques districts, tandis que les postes de santé communautaires couvrent la majeure partie du pays. La vue en panneaux est la version généralement utilisée dans les briefings de stratification, car le lecteur peut voir chaque niveau de soins sans l’encombrement visuel de la carte combinée.

Étape 8 : Visualisations avancées de coordonnées ponctuelles

Les données de coordonnées ponctuelles peuvent être utilisées pour visualiser des indicateurs au niveau des établissements en plus de la localisation géographique. L’exemple ci-dessous utilise les données DHIS2-MFL fusionnées obtenues via la page de correspondance floue de noms entre jeux de données de cette bibliothèque de code.

Étape 8.1 : Résumé des données de coordonnées ponctuelles

Cet exemple utilise les données DHIS2-MFL fusionnées pour calculer le taux de positivité des tests pour chaque établissement de santé. La positivité des tests est la proportion de cas positifs parmi tous les cas testés. Nous calculons d’abord le taux de positivité des tests pour chaque établissement de santé par année.

  • R
  • Python
Afficher le code
# charger le jeu de données DHIS2-MFL fusionné
final_dhis2_mfl_integrated <- readRDS(
  here::here(
    "01_data",
    "1.1_foundational",
    "1.1b_health_facilities",
    "processed",
    "final_dhis2_mfl_integrated.rds"
  )
)

# compléter les lat/long manquants depuis la MFL par nom d'établissement ; de nombreuses
# lignes DHIS2 arrivent sans coordonnées car la correspondance floue en amont
# n'a confirmé qu'un sous-ensemble d'identités d'établissements
mfl_coords <- readxl::read_excel(
  here::here(hf_path, "mfl_hfs.xlsx")
) |>
  dplyr::select(hf, w_lat, w_long) |>
  dplyr::distinct(hf, .keep_all = TRUE)

final_dhis2_mfl_integrated <- final_dhis2_mfl_integrated |>
  dplyr::left_join(mfl_coords, by = "hf") |>
  dplyr::mutate(
    lat = dplyr::coalesce(lat, w_lat),
    long = dplyr::coalesce(long, w_long)
  )

# calculer les résumés annuels pour chaque établissement de santé
annual_summary <- final_dhis2_mfl_integrated |>
  dplyr::group_by(hf_uid, hf, year, lat, long) |>
  dplyr::summarise(
    # additionner les tests sur tous les groupes d'âge et contextes
    total_tests = sum(
      test_u5 + test_5_14 + test_ov15, na.rm = TRUE
    ),
    # additionner les tests positifs sur tous les groupes d'âge et contextes
    positive_tests = sum(
      conf_u5 + conf_5_14 + conf_ov15, na.rm = TRUE
    ),
    .groups = "drop"
  ) |>
  # calculer le taux de positivité des tests (TPR) en pourcentage
  dplyr::mutate(
    tpr = dplyr::if_else(
      total_tests > 0,
      (positive_tests / total_tests) * 100,
      NA_real_
    )
  ) |>
  # supprimer les établissements sans tests
  dplyr::filter(total_tests > 0)

# filtrer pour 2023 et assigner une catégorie de volume de tests
annual_summary_2023 <- annual_summary |>
  dplyr::filter(
    year == 2023, !is.na(tpr), total_tests > 0,
    !is.na(lat), !is.na(long)
  ) |>
  dplyr::mutate(
    size_category = dplyr::case_when(
      total_tests <= 100 ~ "≤100",
      total_tests <= 500 ~ "101-500",
      total_tests <= 1000 ~ "501-1,000",
      total_tests <= 5000 ~ "1,001-5,000",
      TRUE ~ "5,000+"
    ),
    size_category = factor(
      size_category,
      levels = c(
        "≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+"
      )
    )
  )
Afficher le code
# charger le jeu de données DHIS2-MFL fusionné
final_dhis2_mfl_integrated = pd.read_parquet(
    Path(
        here(
            "01_data/1.1_foundational/"
            "1.1b_health_facilities/processed/"
            "final_dhis2_mfl_integrated.parquet"
        )
    )
)

# la colonne year peut arriver en chaîne ; la convertir en entier nullable
# afin que le filtre par année se comporte de la même façon qu'en R
# (R : "2023" == 2023 est TRUE ; Python : c'est False)
final_dhis2_mfl_integrated["year"] = pd.to_numeric(
    final_dhis2_mfl_integrated["year"], errors="coerce"
).astype("Int64")

# compléter les lat/long manquants depuis la MFL par nom d'établissement ; de nombreuses
# lignes DHIS2 arrivent sans coordonnées car la correspondance floue en amont
# n'a confirmé qu'un sous-ensemble d'identités d'établissements
mfl_coords = (
    pd.read_excel(hf_path / "mfl_hfs.xlsx")
    [["hf", "w_lat", "w_long"]]
    .drop_duplicates("hf")
)
final_dhis2_mfl_integrated = final_dhis2_mfl_integrated.merge(
    mfl_coords, on="hf", how="left"
)
final_dhis2_mfl_integrated["lat"] = (
    final_dhis2_mfl_integrated["lat"]
    .combine_first(final_dhis2_mfl_integrated["w_lat"])
)
final_dhis2_mfl_integrated["long"] = (
    final_dhis2_mfl_integrated["long"]
    .combine_first(final_dhis2_mfl_integrated["w_long"])
)

# calculer les résumés annuels pour chaque établissement de santé
annual_summary = (
    final_dhis2_mfl_integrated
    .assign(
        total_tests=lambda d: (
            d["test_u5"].fillna(0)
            + d["test_5_14"].fillna(0)
            + d["test_ov15"].fillna(0)
        ),
        positive_tests=lambda d: (
            d["conf_u5"].fillna(0)
            + d["conf_5_14"].fillna(0)
            + d["conf_ov15"].fillna(0)
        ),
    )
    .groupby(["hf_uid", "hf", "year", "lat", "long"], as_index=False)
    .agg(
        total_tests=("total_tests", "sum"),
        positive_tests=("positive_tests", "sum"),
    )
    .assign(
        tpr=lambda d: np.where(
            d["total_tests"] > 0,
            (d["positive_tests"] / d["total_tests"]) * 100,
            np.nan,
        )
    )
    .loc[lambda d: d["total_tests"] > 0]
)

# filtrer pour 2023 et assigner une catégorie de volume de tests
size_levels = ["≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+"]
annual_summary_2023 = (
    annual_summary
    .loc[lambda d: (
        (d["year"] == 2023)
        & d["tpr"].notna()
        & (d["total_tests"] > 0)
        & d["lat"].notna()
        & d["long"].notna()
    )]
    .assign(
        size_category=lambda d: pd.Categorical(
            np.select(
                [
                    d["total_tests"] <= 100,
                    d["total_tests"] <= 500,
                    d["total_tests"] <= 1000,
                    d["total_tests"] <= 5000,
                ],
                ["≤100", "101-500", "501-1,000", "1,001-5,000"],
                default="5,000+",
            ),
            categories=size_levels,
            ordered=True,
        )
    )
)

Étape 8.2 : Visualisation de coordonnées ponctuelles à indicateur unique

Ce code trace les établissements de santé par leurs coordonnées géographiques et utilise la coloration des points pour indiquer la valeur du taux de positivité des tests pour cet établissement. Pour simplifier, ce graphique montre uniquement les taux de positivité des tests de 2023 par établissement.

  • R
  • Python
Afficher le code
tpr_only_2023 <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = annual_summary_2023,
    ggplot2::aes(x = long, y = lat, fill = tpr),
    shape = 21,
    size = 2,
    stroke = 0.5,
    alpha = 0.8
  ) +
  ggplot2::coord_sf() +
  ggplot2::scale_fill_gradientn(
    colors = rev(RColorBrewer::brewer.pal(7, "RdYlBu")),
    limits = c(0, 100),
    name = "Taux de positivité des tests (%)",
  ) +
  snt_map_theme() +
  ggplot2::labs(title = "Taux de positivité des tests par établissement (2023)")

# afficher la carte
print(tpr_only_2023)

# sauvegarder la carte
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_tpr_2023.png"
  ),
  plot = tpr_only_2023,
  width = 10,
  height = 7,
  dpi = 300
)
NoteSortie

Afficher le code
# construire la palette de couleurs correspondant à rev(brewer.pal(7, "RdYlBu")) de R
rdylbu_7_rev = [
    "#4575b4", "#91bfdb", "#e0f3f8",
    "#ffffbf", "#fee090", "#fc8d59", "#d73027",
]
cmap_tpr = mcolors.LinearSegmentedColormap.from_list(
    "rdylbu_rev", rdylbu_7_rev
)

fig, ax = plt.subplots(figsize=(10, 10))
gdf.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
norm = mcolors.Normalize(vmin=0, vmax=100)
sc = ax.scatter(
    annual_summary_2023["long"],
    annual_summary_2023["lat"],
    c=annual_summary_2023["tpr"],
    cmap=cmap_tpr,
    norm=norm,
    s=15,
    alpha=0.8,
    edgecolors="black",
    linewidths=0.5,
    zorder=5,
)
sm = ScalarMappable(cmap=cmap_tpr, norm=norm)
sm.set_array([])
cbar = fig.colorbar(
    sm, ax=ax, orientation="horizontal", fraction=0.04, pad=0.04
)
cbar.set_label("Taux de positivité des tests (%)", fontweight="bold")
finish_map(ax, title="Taux de positivité des tests par établissement (2023)")

# sauvegarder la carte
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_tpr_2023.png"),
    width=10,
    height=7,
)
NoteSortie

Étape 8.3 : Visualisation de coordonnées ponctuelles à indicateurs multiples

L’exemple précédent peut être enrichi en ajoutant des éléments graphiques supplémentaires pour représenter d’autres indicateurs. Par exemple, la taille de chaque point d’établissement de santé peut être utilisée pour représenter le volume de tests. Le code ci-dessous illustre cet exemple.

  • R
  • Python
Afficher le code
size_scale <- c("≤100" = 1.5, "101-500" = 2.0, "501-1,000" = 2.5,
                "1,001-5,000" = 3.0, "5,000+" = 3.5)

combined_2023_categorical <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = annual_summary_2023,
    ggplot2::aes(x = long, y = lat,
                 fill = tpr,
                 size = size_category),
    shape = 21,
    stroke = 0.3,  # thinner stroke
    alpha = 0.7
  ) +
  ggplot2::coord_sf() +
  ggplot2::scale_fill_gradientn(
    colors = rev(RColorBrewer::brewer.pal(7, "RdYlBu")),
    limits = c(0, 100),
    name = "Taux de positivité des tests (%)",
    na.value = "grey50"
  ) +
  ggplot2::scale_size_manual(
    name = "Nombre total de tests",
    values = size_scale,
    breaks = c("≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+")
  ) +
  snt_map_theme() +
  ggplot2::theme(
    legend.spacing.x = grid::unit(1.5, "cm"),
    legend.box.spacing = grid::unit(0.6, "cm")
  ) +
  ggplot2::labs(
    title = "Taux de positivité et volume de tests par établissement (2023)"
  )

# afficher la version catégorielle
print(combined_2023_categorical)

# sauvegarder la carte
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_tpr_test_2023.png"
  ),
  plot = combined_2023_categorical,
  width = 10,
  height = 7,
  dpi = 300
)
NoteSortie

Afficher le code
# correspondance de taille avec le size_scale de R
size_map = {
    "≤100": 20, "101-500": 30, "501-1,000": 50,
    "1,001-5,000": 80, "5,000+": 120,
}

# construire la palette de couleurs correspondant à rev(brewer.pal(7, "RdYlBu")) de R
rdylbu_7_rev = [
    "#4575b4", "#91bfdb", "#e0f3f8",
    "#ffffbf", "#fee090", "#fc8d59", "#d73027",
]
cmap_tpr = mcolors.LinearSegmentedColormap.from_list(
    "rdylbu_rev", rdylbu_7_rev
)

fig, ax = plt.subplots(figsize=(10, 12))
gdf.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
norm = mcolors.Normalize(vmin=0, vmax=100)

# dériver la taille du marqueur pour chaque ligne depuis la colonne size_category,
# en utilisant la plus petite taille par défaut si la catégorie est manquante
size_levels = ["≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+"]
point_sizes = (
    annual_summary_2023["size_category"]
    .astype(str)
    .map(size_map)
    .fillna(size_map["≤100"])
)

# tracer tous les établissements en un seul appel scatter pour que chaque point s'affiche
ax.scatter(
    annual_summary_2023["long"],
    annual_summary_2023["lat"],
    c=annual_summary_2023["tpr"],
    cmap=cmap_tpr,
    norm=norm,
    s=point_sizes,
    alpha=0.7,
    edgecolors="black",
    linewidths=0.3,
    zorder=5,
)

# barre de couleurs pour le TPR
sm = ScalarMappable(cmap=cmap_tpr, norm=norm)
sm.set_array([])
cbar = fig.colorbar(
    sm, ax=ax, orientation="horizontal", fraction=0.04, pad=0.04
)
cbar.set_label("Taux de positivité des tests (%)", fontweight="bold")

# construire des proxys pour la légende de taille afin qu'elle s'affiche toujours,
# même si une catégorie de taille n'a pas d'établissements
size_handles = [
    plt.scatter(
        [], [], s=size_map[cat], color="lightgray",
        edgecolors="black", linewidths=0.3, alpha=0.7,
    )
    for cat in size_levels
]
ax.legend(
    size_handles, size_levels,
    title="Nombre total de tests",
    loc="upper center",
    bbox_to_anchor=(0.5, -0.18),
    ncol=5,
    frameon=False,
    fontsize=8,
    title_fontsize=9,
)
finish_map(
    ax,
    title=(
        "Taux de positivité et volume de tests par établissement (2023)"
    ),
)

# sauvegarder la carte
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_tpr_test_2023.png"),
    width=10,
    height=7,
)
NoteSortie

Résumé

Dans cette section, nous avons travaillé sur l’ensemble du processus de cartographie des coordonnées des établissements de santé. Nous avons chargé les limites administratives traitées et la liste principale des établissements, puis vérifié les coordonnées pour les problèmes de qualité courants, notamment les valeurs manquantes, mal formées, hors plage, de faible précision, en double, à emplacement partagé, hors limite et inversées, avant de les nettoyer. Nous avons converti le tableau nettoyé en objet spatial, assigné chaque établissement à son unité administrative via une jointure spatiale, et produit une série de cartes, des localisations de base des établissements et des types d’établissements jusqu’au taux de positivité et au volume de tests au niveau des établissements.

Le même flux de travail s’applique à toute donnée ponctuelle dans le processus SNT, comme les agents de santé communautaires, les villages ou les grappes d’enquête. Revenez-y chaque fois que vous devez valider et cartographier des points géoréférencés par rapport aux limites administratives.

Code complet

Le code complet pour la cartographie des coordonnées des établissements de santé se trouve ci-dessous.

  • R
  • Python
Show full code
################################################################################
# ~ Coordonnées des établissements de santé et données ponctuelles full code ~ #
################################################################################

### Step -----------------------------------------------------------------------

# vérifier si 'pacman' est installé ; l'installer si absent
if (!requireNamespace("pacman", quietly = TRUE)) {
  install.packages("pacman")
}

# charger tous les paquets requis avec pacman
pacman::p_load(
  sf,           # lecture et gestion des données spatiales
  readxl,       # lecture des fichiers Excel
  readr,        # lecture des fichiers CSV
  dplyr,        # manipulation des données
  stringr,      # nettoyage et formatage des chaînes
  ggplot2,      # création de graphiques
  RColorBrewer, # palettes de couleurs
  scales,       # formatage des échelles et étiquettes
  here,         # chemins de fichiers multi-plateformes
  tidyr         # restructuration des données et séparation de coordonnées
)

# thème de carte partagé pour une apparence cohérente sur toute la page
snt_map_theme <- function() {
  ggplot2::theme_void() +
    ggplot2::theme(
      legend.position = "bottom",
      legend.direction = "horizontal",
      legend.title.position = "top",
      legend.text.position = "bottom",
      legend.title = ggplot2::element_text(
        face = "bold", size = 10, hjust = 0.5,
        margin = ggplot2::margin(b = 6)
      ),
      legend.box.margin = ggplot2::margin(t = 8),
      legend.key.width = grid::unit(0.9, "cm"),
      strip.text = ggplot2::element_text(
        face = "bold", size = 10,
        margin = ggplot2::margin(t = 2, b = 6, l = 4, r = 4)
      ),
      panel.spacing = grid::unit(4, "pt"),
      plot.title = ggplot2::element_text(
        face = "bold", size = 14, margin = ggplot2::margin(b = 8)
      ),
      plot.subtitle = ggplot2::element_text(
        size = 11, margin = ggplot2::margin(b = 10)
      ),
      plot.margin = ggplot2::margin(t = 5, r = 5, b = 5, l = 5)
    )
}

# définir le chemin vers les limites administratives traitées
spat_path <- here::here(
  "01_data",
  "1.1_foundational",
  "1.1a_administrative_boundaries"
)

# charger l'objet spatial des chefferies (adm3) traité
gdf <- readRDS(
  here::here(spat_path, "processed", "sle_spatial_adm3_2021.rds")
) |>
  sf::st_as_sf()

# charger l'objet spatial des districts (adm2) traité
adm2_gdf <- readRDS(
  here::here(spat_path, "processed", "sle_spatial_adm2_2021.rds")
) |>
  sf::st_as_sf()

# définir le chemin vers les données d'établissements de santé traitées
hf_path <- here::here(
  "01_data",
  "1.1_foundational",
  "1.1b_health_facilities",
  "processed"
)

# charger la liste principale des établissements de santé
hf_data <- readxl::read_excel(
  here::here(hf_path, "mfl_hfs.xlsx")
)

# inspecter les données chargées
cli::cli_h3("Colonnes des limites administratives")
print(names(gdf))
cli::cli_h3("Colonnes des données des établissements de santé")
print(names(hf_data))
cli::cli_h3("Échantillon des données des établissements de santé")
print(dplyr::slice_head(hf_data, n = 3))

# cette étape n'est requise que si longitude et latitude sont combinées
# dans une seule colonne. si les colonnes sont séparées, ignorer cette étape.

# normaliser les séparateurs (; ou espace) en virgule, puis scinder en deux
# colonnes numériques
hf_data <- hf_data |>
  dplyr::mutate(
    coordinates = stringr::str_replace_all(coordinates, "[; ]+", ",")
  ) |>
  tidyr::separate(
    col = "coordinates",
    into = c("w_lat", "w_long"),
    sep = ",",
    convert = TRUE
  )

cli::cli_alert_success("Coordonnées séparées en colonnes distinctes")
cli::cli_h3("Échantillon des coordonnées séparées")
print(utils::head(dplyr::select(hf_data, w_lat, w_long), 3))

# charger la limite nationale (adm0) pour les vérifications spatiales
adm0 <- readRDS(
  here::here(spat_path, "processed", "sle_spatial_adm0_2021.rds")
) |>
  sf::st_as_sf()

# assistant : compter les décimales d'une valeur de coordonnée
count_decimals <- function(value) {
  if (is.na(value)) return(0L)
  text <- format(abs(value), scientific = FALSE, trim = TRUE)
  if (!grepl(".", text, fixed = TRUE)) return(0L)
  nchar(sub("0+$", "", strsplit(text, ".", fixed = TRUE)[[1]][2]))
}

# définir la longitude (x) et la latitude (y), signaler les lignes valides dans la plage,
# et précalculer la précision décimale de chaque coordonnée
hf_data <- hf_data |>
  dplyr::mutate(
    x = w_long,
    y = w_lat,
    valid = !is.na(x) & !is.na(y) &
      dplyr::between(x, -180, 180) &
      dplyr::between(y, -90, 90),
    lon_dp = vapply(x, count_decimals, integer(1)),
    lat_dp = vapply(y, count_decimals, integer(1))
  )

# comptages : manquants, hors plage, île zéro (0, 0) et faible précision
quality_counts <- hf_data |>
  dplyr::summarise(
    n_missing = sum(is.na(x) | is.na(y)),
    n_out_range = sum(!is.na(x) & !is.na(y) & !valid),
    n_zero = sum(x == 0 & y == 0, na.rm = TRUE),
    n_imprecise = sum(valid & (lon_dp < 4 | lat_dp < 4))
  )
n_missing <- quality_counts$n_missing
n_out_range <- quality_counts$n_out_range
n_zero <- quality_counts$n_zero
n_imprecise <- quality_counts$n_imprecise

# établissement + coordonnée en double et emplacements partagés
valid_data <- hf_data |> dplyr::filter(valid)
n_duplicate <- sum(duplicated(
  paste(valid_data$hf, valid_data$x, valid_data$y)
))
coord_key <- paste(valid_data$x, valid_data$y)
n_shared <- sum(coord_key %in% coord_key[duplicated(coord_key)])

# construire un objet sf des points valides pour les vérifications spatiales
hf_points <- valid_data |>
  sf::st_as_sf(
    coords = c("x", "y"),
    crs = 4326,
    remove = FALSE
  )

# points hors de la limite nationale
inside <- lengths(suppressWarnings(sf::st_within(hf_points, adm0))) > 0
n_outside <- sum(!inside)

# lon/lat probablement inversés : hors limite tel quel, mais dedans une fois inversés
swapped <- hf_points |>
  sf::st_drop_geometry() |>
  dplyr::transmute(x = y, y = x) |>
  sf::st_as_sf(coords = c("x", "y"), crs = 4326)
inside_swapped <- lengths(
  suppressWarnings(sf::st_within(swapped, adm0))
) > 0
n_flipped <- sum(!inside & inside_swapped)

# afficher le bilan des problèmes
cli::cli_h2("Bilan de qualité des coordonnées ({nrow(hf_data)} établissements)")
cli::cli_alert_info("Coordonnées manquantes : {n_missing}")
cli::cli_alert_info("Coordonnées hors plage : {n_out_range}")
cli::cli_alert_info("Île zéro (0, 0) : {n_zero}")
cli::cli_alert_info("Faible précision (< 4 dp) : {n_imprecise}")
cli::cli_alert_info("Établissement et coordonnée en double : {n_duplicate}")
cli::cli_alert_info("Enregistrements à emplacements partagés : {n_shared}")
cli::cli_alert_info("Hors de la limite nationale : {n_outside}")
cli::cli_alert_info("Lon/lat probablement inversés : {n_flipped}")

# marquer chaque point valide selon s'il se trouve à l'intérieur de la limite nationale
hf_points <- hf_points |>
  dplyr::mutate(
    location = dplyr::if_else(inside, "Dans la limite", "Hors limite")
  )

# cartographier les points sur la limite nationale
coord_check_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = adm0, fill = "white", color = "black") +
  ggplot2::geom_sf(
    data = hf_points,
    ggplot2::aes(color = location),
    size = 0.8, alpha = 0.7
  ) +
  ggplot2::scale_color_manual(
    values = c(
      "Dans la limite" = "#2c7fb8",
      "Hors limite" = "#e31a1c"
    ),
    name = NULL
  ) +
  snt_map_theme() +
  ggplot2::labs(
    title = "Contrôle des coordonnées : établissements par rapport à la limite nationale"
  )

print(coord_check_map)

# définir les coordonnées de longitude (x) et de latitude (y)
hf_data <- hf_data |>
  dplyr::mutate(
    x = w_long,
    y = w_lat
  )

# identifier les établissements avec des coordonnées invalides pour suivi
facilities_to_review <- hf_data |>
  dplyr::filter(
    is.na(x) | is.na(y) |
      !dplyr::between(x, -180, 180) |
      !dplyr::between(y, -90, 90)
  )

# filtrer pour ne conserver que les coordonnées valides et dans la plage
facilities_clean <- hf_data |>
  dplyr::filter(
    !is.na(x),
    !is.na(y),
    dplyr::between(x, -180, 180),
    dplyr::between(y, -90, 90)
  )

# afficher les résultats du nettoyage
cli::cli_h2("Résultats du nettoyage des données")
cli::cli_alert_info("Établissements de santé initiaux : {nrow(hf_data)}")
cli::cli_alert_info("Établissements de santé nettoyés : {nrow(facilities_clean)}")
cli::cli_alert_info(
  "Établissements supprimés : {nrow(hf_data) - nrow(facilities_clean)}"
)
cli::cli_alert_info(
  "Établissements signalés pour révision : {nrow(facilities_to_review)}"
)
cli::cli_alert_info(
  "Plage de longitude : {min(facilities_clean$x)} à {max(facilities_clean$x)}"
)
cli::cli_alert_info(
  "Plage de latitude : {min(facilities_clean$y)} à {max(facilities_clean$y)}"
)

# convertir en objet spatial
facilities_sf <- sf::st_as_sf(
  facilities_clean,
  coords = c("w_long", "w_lat"),
  crs = 4326
)

cli::cli_alert_success("Converti en objet spatial")
cli::cli_h3("Résumé de l'objet spatial")
print(dplyr::slice_head(facilities_sf, n = 3))

# conserver les colonnes admin de limite, renommées pour éviter les conflits avec
# les propres colonnes admin des données d'établissements
boundary_lookup <- gdf |>
  dplyr::select(adm2_boundary = adm2, adm3_boundary = adm3)

# assigner chaque établissement au polygone de chefferie (adm3) dans lequel il se situe
facilities_admin <- suppressWarnings(sf::st_join(
  facilities_sf,
  boundary_lookup,
  join = sf::st_within
))

# établissements qui ne se trouvaient dans aucun polygone
n_unmatched <- sum(is.na(facilities_admin$adm3_boundary))

cli::cli_h3("Établissements assignés à une unité administrative")
cli::cli_alert_info("Établissements joints : {nrow(facilities_admin)}")
cli::cli_alert_info("Non appariés (hors de tous les polygones) : {n_unmatched}")

# comparer la chefferie enregistrée avec le polygone dans lequel le point se situe
facilities_admin |>
  sf::st_drop_geometry() |>
  dplyr::select(hf, adm3, adm3_boundary) |>
  dplyr::slice_head(n = 5) |>
  print()

# définir le répertoire de sortie pour les jeux de données traités
out_path <- here::here(
  "01_data",
  "1.1_foundational",
  "1.1b_health_facilities",
  "processed"
)

# sauvegarder le tableau d'établissements nettoyé en CSV
readr::write_csv(
  facilities_clean,
  here::here(out_path, "facilities_clean.csv")
)

# sauvegarder les points d'établissements en shapefile
sf::st_write(
  facilities_sf,
  here::here(out_path, "facilities_spatial.shp"),
  append = FALSE,
  quiet = TRUE
)

# définir le titre de la carte
title <- "Établissements de santé en Sierra Leone"

# créer la carte de base
facilities_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "grey50",
                   size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = facilities_clean,
    ggplot2::aes(x = x, y = y),
    color = "#47B5FF", size = 1.8, alpha = 0.7
  ) +
  ggplot2::coord_sf() +
  snt_map_theme() +
  ggplot2::labs(
    title = title,
    subtitle = "Limites adm3 avec superposition adm2"
  )

# afficher la carte
print(facilities_map)

# sauvegarder la carte
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_locations.png"
  ),
  plot = facilities_map,
  width = 10,
  height = 7,
  dpi = 300
)

# définir le titre de la carte des types d'établissements
title_type <- "Types d'établissements de santé en Sierra Leone"

# créer la carte des types d'établissements de santé
facility_type_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = facilities_clean,
    ggplot2::aes(x = x, y = y, color = type),
    size = 1, alpha = 0.8
  ) +
  ggplot2::coord_sf() +
  ggplot2::scale_color_brewer(
    palette = "Set1", name = "Type d'établissement"
  ) +
  snt_map_theme() +
  ggplot2::labs(title = title_type)

# afficher la carte des types
print(facility_type_map)

# sauvegarder la carte des types
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_by_type.png"
  ),
  plot = facility_type_map,
  width = 10,
  height = 7,
  dpi = 300
)

# définir le titre de la carte en panneaux
title_facet <- "Répartition des établissements de santé par type en Sierra Leone"

# créer la carte en panneaux par type d'établissement
faceted_type_map <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.2) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = facilities_clean,
    ggplot2::aes(x = x, y = y),
    color = "#47B5FF", size = 1, alpha = 0.7
  ) +
  ggplot2::facet_wrap(~ type) +
  ggplot2::coord_sf() +
  snt_map_theme() +
  ggplot2::labs(title = title_facet)

# afficher la carte en panneaux
print(faceted_type_map)

# sauvegarder la carte en panneaux
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_faceted_by_type.png"
  ),
  plot = faceted_type_map,
  width = 12,
  height = 9,
  dpi = 300
)

# charger le jeu de données DHIS2-MFL fusionné
final_dhis2_mfl_integrated <- readRDS(
  here::here(
    "01_data",
    "1.1_foundational",
    "1.1b_health_facilities",
    "processed",
    "final_dhis2_mfl_integrated.rds"
  )
)

# compléter les lat/long manquants depuis la MFL par nom d'établissement ; de nombreuses
# lignes DHIS2 arrivent sans coordonnées car la correspondance floue en amont
# n'a confirmé qu'un sous-ensemble d'identités d'établissements
mfl_coords <- readxl::read_excel(
  here::here(hf_path, "mfl_hfs.xlsx")
) |>
  dplyr::select(hf, w_lat, w_long) |>
  dplyr::distinct(hf, .keep_all = TRUE)

final_dhis2_mfl_integrated <- final_dhis2_mfl_integrated |>
  dplyr::left_join(mfl_coords, by = "hf") |>
  dplyr::mutate(
    lat = dplyr::coalesce(lat, w_lat),
    long = dplyr::coalesce(long, w_long)
  )

# calculer les résumés annuels pour chaque établissement de santé
annual_summary <- final_dhis2_mfl_integrated |>
  dplyr::group_by(hf_uid, hf, year, lat, long) |>
  dplyr::summarise(
    # additionner les tests sur tous les groupes d'âge et contextes
    total_tests = sum(
      test_u5 + test_5_14 + test_ov15, na.rm = TRUE
    ),
    # additionner les tests positifs sur tous les groupes d'âge et contextes
    positive_tests = sum(
      conf_u5 + conf_5_14 + conf_ov15, na.rm = TRUE
    ),
    .groups = "drop"
  ) |>
  # calculer le taux de positivité des tests (TPR) en pourcentage
  dplyr::mutate(
    tpr = dplyr::if_else(
      total_tests > 0,
      (positive_tests / total_tests) * 100,
      NA_real_
    )
  ) |>
  # supprimer les établissements sans tests
  dplyr::filter(total_tests > 0)

# filtrer pour 2023 et assigner une catégorie de volume de tests
annual_summary_2023 <- annual_summary |>
  dplyr::filter(
    year == 2023, !is.na(tpr), total_tests > 0,
    !is.na(lat), !is.na(long)
  ) |>
  dplyr::mutate(
    size_category = dplyr::case_when(
      total_tests <= 100 ~ "≤100",
      total_tests <= 500 ~ "101-500",
      total_tests <= 1000 ~ "501-1,000",
      total_tests <= 5000 ~ "1,001-5,000",
      TRUE ~ "5,000+"
    ),
    size_category = factor(
      size_category,
      levels = c(
        "≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+"
      )
    )
  )

tpr_only_2023 <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = annual_summary_2023,
    ggplot2::aes(x = long, y = lat, fill = tpr),
    shape = 21,
    size = 2,
    stroke = 0.5,
    alpha = 0.8
  ) +
  ggplot2::coord_sf() +
  ggplot2::scale_fill_gradientn(
    colors = rev(RColorBrewer::brewer.pal(7, "RdYlBu")),
    limits = c(0, 100),
    name = "Taux de positivité des tests (%)",
  ) +
  snt_map_theme() +
  ggplot2::labs(title = "Taux de positivité des tests par établissement (2023)")

# afficher la carte
print(tpr_only_2023)

# sauvegarder la carte
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_tpr_2023.png"
  ),
  plot = tpr_only_2023,
  width = 10,
  height = 7,
  dpi = 300
)

size_scale <- c("≤100" = 1.5, "101-500" = 2.0, "501-1,000" = 2.5,
                "1,001-5,000" = 3.0, "5,000+" = 3.5)

combined_2023_categorical <- ggplot2::ggplot() +
  ggplot2::geom_sf(data = gdf, fill = "white",
                   color = "gray50", size = 0.3) +
  ggplot2::geom_sf(data = adm2_gdf, fill = NA,
                   color = "black",
                   size = 0.5) +
  ggplot2::geom_point(
    data = annual_summary_2023,
    ggplot2::aes(x = long, y = lat,
                 fill = tpr,
                 size = size_category),
    shape = 21,
    stroke = 0.3,  # thinner stroke
    alpha = 0.7
  ) +
  ggplot2::coord_sf() +
  ggplot2::scale_fill_gradientn(
    colors = rev(RColorBrewer::brewer.pal(7, "RdYlBu")),
    limits = c(0, 100),
    name = "Taux de positivité des tests (%)",
    na.value = "grey50"
  ) +
  ggplot2::scale_size_manual(
    name = "Nombre total de tests",
    values = size_scale,
    breaks = c("≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+")
  ) +
  snt_map_theme() +
  ggplot2::theme(
    legend.spacing.x = grid::unit(1.5, "cm"),
    legend.box.spacing = grid::unit(0.6, "cm")
  ) +
  ggplot2::labs(
    title = "Taux de positivité et volume de tests par établissement (2023)"
  )

# afficher la version catégorielle
print(combined_2023_categorical)

# sauvegarder la carte
ggplot2::ggsave(
  filename = here::here(
    "03_output", "3a_figures", "health_facility_tpr_test_2023.png"
  ),
  plot = combined_2023_categorical,
  width = 10,
  height = 7,
  dpi = 300
)
Show full code
################################################################################
# ~ Coordonnées des établissements de santé et données ponctuelles full code ~ #
################################################################################

### Step -----------------------------------------------------------------------

from pathlib import Path

import geopandas as gpd
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
import pyreadr
from matplotlib.cm import ScalarMappable
from pyprojroot import here

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 cli_danger(message):
    print(f"ERROR: {message}")

def anti_join(left, right, on):
    """Retourner les lignes de left sans clé correspondante dans right."""
    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 finish_map(ax, title=None, subtitle=None):
    """Appliquer le style de carte statique partagé utilisé sur cette page."""
    if title and subtitle:
        ax.set_title(
            f"{title}\n{subtitle}", loc="left", fontsize=14, fontweight="bold"
        )
    elif title:
        ax.set_title(title, loc="left", fontsize=14, fontweight="bold")
    ax.set_axis_off()

def save_figure(fig, filename, width, height, dpi=300):
    """Enregistrer une figure matplotlib avec les dimensions correspondant aux exemples R."""
    Path(filename).parent.mkdir(parents=True, exist_ok=True)
    fig.set_size_inches(width, height)
    fig.savefig(filename, dpi=dpi, bbox_inches="tight")

def show_table(df, n=10, caption=None):
    """Rendu d'un tableau HTML compact défilable correspondant au helper R show_table.

    Produit un <table class="out-table"> encapsulé dans <div class="out-scroll">
    afin que la sortie corresponde exactement au rendu R basé sur kable.
    """
    from IPython.display import display, HTML
    html = df.head(n).to_html(index=False, border=0, classes="out-table")
    if caption:
        html = f"<caption>{caption}</caption>" + html
    display(HTML(f'<div class="out-scroll">{html}</div>'))

# définir le chemin vers les limites administratives traitées
spat_path = Path(
    here(
        "01_data/1.1_foundational/"
        "1.1a_administrative_boundaries/processed"
    )
)

# charger l'objet spatial des chefferies (adm3) traité
gdf = gpd.read_file(spat_path / "sle_spatial_adm3_2021.geojson")

# charger l'objet spatial des districts (adm2) traité
adm2_gdf = gpd.read_file(spat_path / "sle_spatial_adm2_2021.geojson")

# définir le chemin vers les données d'établissements de santé traitées
hf_path = Path(
    here(
        "01_data/1.1_foundational/"
        "1.1b_health_facilities/processed"
    )
)

# charger la liste principale des établissements de santé
hf_data = pd.read_excel(hf_path / "mfl_hfs.xlsx")

# inspecter les données chargées
cli_header("Colonnes des limites administratives")
print(list(gdf.columns))
cli_header("Colonnes des données des établissements de santé")
print(list(hf_data.columns))
cli_header("Échantillon des données des établissements de santé")
print(hf_data.head(3))

# cette étape n'est requise que si longitude et latitude sont combinées
# dans une seule colonne. si les colonnes sont séparées, ignorer cette étape.

# remplacer les séparateurs (; ou espace) par une virgule
hf_data["coordinates"] = (
    hf_data["coordinates"].str.replace(r"[; ]+", ",", regex=True)
)

# séparer la colonne combinée en deux colonnes
coord_split = hf_data["coordinates"].str.split(",", expand=True)
hf_data = hf_data.assign(
    w_lat=pd.to_numeric(coord_split[0], errors="coerce"),
    w_long=pd.to_numeric(coord_split[1], errors="coerce"),
)

cli_success("Coordonnées séparées en colonnes distinctes")
cli_header("Échantillon des coordonnées séparées")
print(hf_data[["w_lat", "w_long"]].head(3).to_string(index=False))

# charger la limite nationale (adm0) pour les vérifications spatiales
adm0 = gpd.read_file(
    Path(
        here(
            "01_data/1.1_foundational/"
            "1.1a_administrative_boundaries/processed/"
            "sle_spatial_adm0_2021.geojson"
        )
    )
)

# assistant : compter les décimales d'une valeur de coordonnée
def count_decimals(value):
    if pd.isna(value):
        return 0
    text = f"{abs(value):.10f}".rstrip("0")
    if "." not in text:
        return 0
    return len(text.split(".")[1])

# définir la longitude (x) et la latitude (y)
hf_data = hf_data.assign(x=hf_data["w_long"], y=hf_data["w_lat"])

# lignes avec coordonnées valides et dans la plage (pour les vérifications spatiales)
valid = (
    hf_data["x"].notna() & hf_data["y"].notna()
    & hf_data["x"].between(-180, 180)
    & hf_data["y"].between(-90, 90)
)

# coordonnées manquantes, hors plage et île zéro (0, 0)
n_missing = (hf_data["x"].isna() | hf_data["y"].isna()).sum()
n_out_range = (
    hf_data["x"].notna() & hf_data["y"].notna() & ~valid
).sum()
n_zero = (
    (hf_data["x"] == 0) & (hf_data["y"] == 0)
).sum()

# faible précision (moins de 4 décimales)
lon_dp = hf_data["x"].apply(count_decimals)
lat_dp = hf_data["y"].apply(count_decimals)
n_imprecise = int((valid & ((lon_dp < 4) | (lat_dp < 4))).sum())

# établissement + coordonnée en double et emplacements partagés
valid_data = hf_data.loc[valid].copy()
keys = (
    valid_data["hf"].astype(str) + "_"
    + valid_data["x"].astype(str) + "_"
    + valid_data["y"].astype(str)
)
n_duplicate = int(keys.duplicated().sum())
coord_key = (
    valid_data["x"].astype(str) + "_" + valid_data["y"].astype(str)
)
n_shared = int(
    coord_key.isin(coord_key[coord_key.duplicated()]).sum()
)

# construire un GeoDataFrame des points valides pour les vérifications spatiales
if adm0.crs is None:
    adm0 = adm0.set_crs("EPSG:4326")
hf_points = gpd.GeoDataFrame(
    valid_data,
    geometry=gpd.points_from_xy(valid_data["x"], valid_data["y"]),
    crs="EPSG:4326",
)
if hf_points.crs != adm0.crs:
    hf_points = hf_points.to_crs(adm0.crs)

# points hors de la limite nationale
inside = hf_points.within(adm0.union_all())
n_outside = int((~inside).sum())

# lon/lat probablement inversés : hors limite tel quel, mais dedans une fois inversés
swapped = gpd.GeoDataFrame(
    geometry=gpd.points_from_xy(hf_points["y"], hf_points["x"]),
    crs="EPSG:4326",
)
inside_swapped = swapped.within(adm0.union_all())
n_flipped = int((~inside & inside_swapped).sum())

# afficher le bilan des problèmes
cli_header(
    f"Bilan de qualité des coordonnées ({len(hf_data)} établissements)"
)
cli_info(f"Coordonnées manquantes : {n_missing}")
cli_info(f"Coordonnées hors plage : {n_out_range}")
cli_info(f"Île zéro (0, 0) : {n_zero}")
cli_info(f"Faible précision (< 4 dp) : {n_imprecise}")
cli_info(f"Établissement et coordonnée en double : {n_duplicate}")
cli_info(f"Enregistrements à emplacements partagés : {n_shared}")
cli_info(f"Hors de la limite nationale : {n_outside}")
cli_info(f"Lon/lat probablement inversés : {n_flipped}")

# marquer chaque point valide selon s'il se trouve à l'intérieur de la limite nationale
hf_points = hf_points.assign(
    location=inside.map(
        {True: "Dans la limite", False: "Hors limite"}
    )
)

# cartographier les points sur la limite nationale
color_map = {"Dans la limite": "#2c7fb8", "Hors limite": "#e31a1c"}
fig, ax = plt.subplots(figsize=(8, 9))
adm0.plot(ax=ax, facecolor="white", edgecolor="black", linewidth=0.8,
          aspect="equal")
for label, color in color_map.items():
    subset = hf_points.loc[hf_points["location"] == label]
    subset.plot(
        ax=ax, color=color, markersize=3, alpha=0.7, label=label,
        aspect="equal",
    )
ax.legend(
    loc="lower center", bbox_to_anchor=(0.5, -0.05),
    frameon=False, fontsize=9, ncol=2,
)
finish_map(
    ax,
    title="Contrôle des coordonnées : établissements par rapport à la limite nationale",
)

# définir les coordonnées de longitude (x) et de latitude (y)
hf_data = hf_data.assign(x=hf_data["w_long"], y=hf_data["w_lat"])

# identifier les établissements avec des coordonnées invalides pour suivi
facilities_to_review = hf_data.loc[
    hf_data["x"].isna()
    | hf_data["y"].isna()
    | (hf_data["x"] < -180) | (hf_data["x"] > 180)
    | (hf_data["y"] < -90) | (hf_data["y"] > 90)
]

# filtrer pour ne conserver que les coordonnées valides et dans la plage
facilities_clean = hf_data.loc[
    hf_data["x"].notna()
    & hf_data["y"].notna()
    & hf_data["x"].between(-180, 180)
    & hf_data["y"].between(-90, 90)
]

# afficher les résultats du nettoyage
cli_header("Résultats du nettoyage des données")
cli_info(f"Établissements de santé initiaux : {len(hf_data)}")
cli_info(f"Établissements de santé nettoyés : {len(facilities_clean)}")
cli_info(
    f"Établissements supprimés : {len(hf_data) - len(facilities_clean)}"
)
cli_info(
    f"Établissements signalés pour révision : {len(facilities_to_review)}"
)
cli_info(
    f"Plage de longitude : {facilities_clean['x'].min()} à "
    f"{facilities_clean['x'].max()}"
)
cli_info(
    f"Plage de latitude : {facilities_clean['y'].min()} à "
    f"{facilities_clean['y'].max()}"
)

# convertir le tableau nettoyé en GeoDataFrame à partir des coordonnées ponctuelles
facilities_sf = gpd.GeoDataFrame(
    facilities_clean,
    geometry=gpd.points_from_xy(
        facilities_clean["w_long"], facilities_clean["w_lat"]
    ),
    crs="EPSG:4326",
)

cli_success("Converti en objet spatial")
cli_header("Résumé de l'objet spatial")
print(facilities_sf.head(3).to_string(index=False))

# conserver uniquement les colonnes admin de limite, renommées pour éviter les conflits
# avec les propres colonnes admin des données d'établissements
boundary_lookup = gdf[["adm2", "adm3", "geometry"]].rename(
    columns={"adm2": "adm2_boundary", "adm3": "adm3_boundary"}
)

# assigner un CRS par défaut si absent avant la jointure spatiale
if boundary_lookup.crs is None:
    boundary_lookup = boundary_lookup.set_crs("EPSG:4326")
if facilities_sf.crs is None:
    facilities_sf = facilities_sf.set_crs("EPSG:4326")

# assigner chaque établissement au polygone de chefferie (adm3) dans lequel il se situe
facilities_admin = gpd.sjoin(
    facilities_sf,
    boundary_lookup,
    how="left",
    predicate="within",
)

# établissements qui ne se trouvaient dans aucun polygone
n_unmatched = facilities_admin["adm3_boundary"].isna().sum()

cli_header("Établissements assignés à une unité administrative")
cli_info(f"Établissements joints : {len(facilities_admin)}")
cli_info(f"Non appariés (hors de tous les polygones) : {n_unmatched}")

# comparer la chefferie enregistrée avec le polygone dans lequel le point se situe
print(
    facilities_admin[["hf", "adm3", "adm3_boundary"]]
    .head(5)
    .to_string(index=False)
)

# définir le répertoire de sortie pour les jeux de données traités
out_path = Path(
    here(
        "01_data/1.1_foundational/"
        "1.1b_health_facilities/processed"
    )
)
out_path.mkdir(parents=True, exist_ok=True)

# sauvegarder le tableau d'établissements nettoyé en CSV
facilities_clean.to_csv(
    out_path / "facilities_clean.csv", index=False
)

# sauvegarder les points d'établissements en GeoPackage
facilities_sf.to_file(
    out_path / "facilities_spatial.gpkg",
    layer="facilities",
    driver="GPKG",
)

# définir le titre de la carte
title = "Établissements de santé en Sierra Leone"

# créer la carte de base
fig, ax = plt.subplots(figsize=(10, 8))
gdf.plot(ax=ax, facecolor="white", edgecolor="grey", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
ax.scatter(
    facilities_clean["x"],
    facilities_clean["y"],
    color="#47B5FF",
    s=15,
    alpha=0.7,
    zorder=5,
)
finish_map(ax, title=title, subtitle="Limites adm3 avec superposition adm2")

# sauvegarder la carte
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_locations.png"),
    width=10,
    height=7,
)

# définir le titre de la carte des types d'établissements
title_type = "Types d'établissements de santé en Sierra Leone"

# obtenir les types d'établissements ordonnés et assigner une couleur Set1 à chacun
facility_types = sorted(facilities_clean["type"].dropna().unique())
set1_colors = plt.get_cmap("Set1").colors
type_palette = {
    t: set1_colors[i % len(set1_colors)]
    for i, t in enumerate(facility_types)
}

# créer la carte des types d'établissements de santé
fig, ax = plt.subplots(figsize=(10, 9))
gdf.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
for ftype, color in type_palette.items():
    subset = facilities_clean.loc[facilities_clean["type"] == ftype]
    ax.scatter(
        subset["x"], subset["y"],
        color=color, s=5, alpha=0.8, label=ftype, zorder=5,
    )
ax.legend(
    title="Type d'établissement",
    loc="lower center",
    bbox_to_anchor=(0.5, -0.10),
    ncol=3,
    frameon=False,
    fontsize=8,
    title_fontsize=9,
)
finish_map(ax, title=title_type)

# sauvegarder la carte des types
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_by_type.png"),
    width=10,
    height=7,
)

# définir le titre de la carte en panneaux
title_facet = "Répartition des établissements de santé par type en Sierra Leone"

# obtenir les types d'établissements uniques pour les panneaux
facility_types = sorted(facilities_clean["type"].dropna().unique())
n_types = len(facility_types)
ncols = 3
nrows = -(-n_types // ncols)  # ceiling division

fig, axes = plt.subplots(nrows, ncols, figsize=(12, 9))
axes_flat = axes.flatten()

for i, ftype in enumerate(facility_types):
    ax = axes_flat[i]
    gdf.plot(ax=ax, facecolor="white", edgecolor="gray",
             linewidth=0.2, aspect="equal")
    adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
                  linewidth=0.5, aspect="equal")
    subset = facilities_clean.loc[facilities_clean["type"] == ftype]
    ax.scatter(
        subset["x"], subset["y"],
        color="#47B5FF", s=5, alpha=0.7, zorder=5,
    )
    ax.set_title(ftype, fontsize=10, fontweight="bold")
    ax.set_axis_off()

# masquer les panneaux d'axes inutilisés
for j in range(n_types, len(axes_flat)):
    axes_flat[j].set_visible(False)

fig.suptitle(title_facet, fontsize=14, fontweight="bold", x=0.02,
             ha="left")
plt.tight_layout()

# sauvegarder la carte en panneaux
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_faceted_by_type.png"),
    width=12,
    height=9,
)

# charger le jeu de données DHIS2-MFL fusionné
final_dhis2_mfl_integrated = pd.read_parquet(
    Path(
        here(
            "01_data/1.1_foundational/"
            "1.1b_health_facilities/processed/"
            "final_dhis2_mfl_integrated.parquet"
        )
    )
)

# la colonne year peut arriver en chaîne ; la convertir en entier nullable
# afin que le filtre par année se comporte de la même façon qu'en R
# (R : "2023" == 2023 est TRUE ; Python : c'est False)
final_dhis2_mfl_integrated["year"] = pd.to_numeric(
    final_dhis2_mfl_integrated["year"], errors="coerce"
).astype("Int64")

# compléter les lat/long manquants depuis la MFL par nom d'établissement ; de nombreuses
# lignes DHIS2 arrivent sans coordonnées car la correspondance floue en amont
# n'a confirmé qu'un sous-ensemble d'identités d'établissements
mfl_coords = (
    pd.read_excel(hf_path / "mfl_hfs.xlsx")
    [["hf", "w_lat", "w_long"]]
    .drop_duplicates("hf")
)
final_dhis2_mfl_integrated = final_dhis2_mfl_integrated.merge(
    mfl_coords, on="hf", how="left"
)
final_dhis2_mfl_integrated["lat"] = (
    final_dhis2_mfl_integrated["lat"]
    .combine_first(final_dhis2_mfl_integrated["w_lat"])
)
final_dhis2_mfl_integrated["long"] = (
    final_dhis2_mfl_integrated["long"]
    .combine_first(final_dhis2_mfl_integrated["w_long"])
)

# calculer les résumés annuels pour chaque établissement de santé
annual_summary = (
    final_dhis2_mfl_integrated
    .assign(
        total_tests=lambda d: (
            d["test_u5"].fillna(0)
            + d["test_5_14"].fillna(0)
            + d["test_ov15"].fillna(0)
        ),
        positive_tests=lambda d: (
            d["conf_u5"].fillna(0)
            + d["conf_5_14"].fillna(0)
            + d["conf_ov15"].fillna(0)
        ),
    )
    .groupby(["hf_uid", "hf", "year", "lat", "long"], as_index=False)
    .agg(
        total_tests=("total_tests", "sum"),
        positive_tests=("positive_tests", "sum"),
    )
    .assign(
        tpr=lambda d: np.where(
            d["total_tests"] > 0,
            (d["positive_tests"] / d["total_tests"]) * 100,
            np.nan,
        )
    )
    .loc[lambda d: d["total_tests"] > 0]
)

# filtrer pour 2023 et assigner une catégorie de volume de tests
size_levels = ["≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+"]
annual_summary_2023 = (
    annual_summary
    .loc[lambda d: (
        (d["year"] == 2023)
        & d["tpr"].notna()
        & (d["total_tests"] > 0)
        & d["lat"].notna()
        & d["long"].notna()
    )]
    .assign(
        size_category=lambda d: pd.Categorical(
            np.select(
                [
                    d["total_tests"] <= 100,
                    d["total_tests"] <= 500,
                    d["total_tests"] <= 1000,
                    d["total_tests"] <= 5000,
                ],
                ["≤100", "101-500", "501-1,000", "1,001-5,000"],
                default="5,000+",
            ),
            categories=size_levels,
            ordered=True,
        )
    )
)

# construire la palette de couleurs correspondant à rev(brewer.pal(7, "RdYlBu")) de R
rdylbu_7_rev = [
    "#4575b4", "#91bfdb", "#e0f3f8",
    "#ffffbf", "#fee090", "#fc8d59", "#d73027",
]
cmap_tpr = mcolors.LinearSegmentedColormap.from_list(
    "rdylbu_rev", rdylbu_7_rev
)

fig, ax = plt.subplots(figsize=(10, 10))
gdf.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
norm = mcolors.Normalize(vmin=0, vmax=100)
sc = ax.scatter(
    annual_summary_2023["long"],
    annual_summary_2023["lat"],
    c=annual_summary_2023["tpr"],
    cmap=cmap_tpr,
    norm=norm,
    s=15,
    alpha=0.8,
    edgecolors="black",
    linewidths=0.5,
    zorder=5,
)
sm = ScalarMappable(cmap=cmap_tpr, norm=norm)
sm.set_array([])
cbar = fig.colorbar(
    sm, ax=ax, orientation="horizontal", fraction=0.04, pad=0.04
)
cbar.set_label("Taux de positivité des tests (%)", fontweight="bold")
finish_map(ax, title="Taux de positivité des tests par établissement (2023)")

# sauvegarder la carte
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_tpr_2023.png"),
    width=10,
    height=7,
)

# correspondance de taille avec le size_scale de R
size_map = {
    "≤100": 20, "101-500": 30, "501-1,000": 50,
    "1,001-5,000": 80, "5,000+": 120,
}

# construire la palette de couleurs correspondant à rev(brewer.pal(7, "RdYlBu")) de R
rdylbu_7_rev = [
    "#4575b4", "#91bfdb", "#e0f3f8",
    "#ffffbf", "#fee090", "#fc8d59", "#d73027",
]
cmap_tpr = mcolors.LinearSegmentedColormap.from_list(
    "rdylbu_rev", rdylbu_7_rev
)

fig, ax = plt.subplots(figsize=(10, 12))
gdf.plot(ax=ax, facecolor="white", edgecolor="gray", linewidth=0.3,
         aspect="equal")
adm2_gdf.plot(ax=ax, facecolor="none", edgecolor="black",
              linewidth=0.5, aspect="equal")
norm = mcolors.Normalize(vmin=0, vmax=100)

# dériver la taille du marqueur pour chaque ligne depuis la colonne size_category,
# en utilisant la plus petite taille par défaut si la catégorie est manquante
size_levels = ["≤100", "101-500", "501-1,000", "1,001-5,000", "5,000+"]
point_sizes = (
    annual_summary_2023["size_category"]
    .astype(str)
    .map(size_map)
    .fillna(size_map["≤100"])
)

# tracer tous les établissements en un seul appel scatter pour que chaque point s'affiche
ax.scatter(
    annual_summary_2023["long"],
    annual_summary_2023["lat"],
    c=annual_summary_2023["tpr"],
    cmap=cmap_tpr,
    norm=norm,
    s=point_sizes,
    alpha=0.7,
    edgecolors="black",
    linewidths=0.3,
    zorder=5,
)

# barre de couleurs pour le TPR
sm = ScalarMappable(cmap=cmap_tpr, norm=norm)
sm.set_array([])
cbar = fig.colorbar(
    sm, ax=ax, orientation="horizontal", fraction=0.04, pad=0.04
)
cbar.set_label("Taux de positivité des tests (%)", fontweight="bold")

# construire des proxys pour la légende de taille afin qu'elle s'affiche toujours,
# même si une catégorie de taille n'a pas d'établissements
size_handles = [
    plt.scatter(
        [], [], s=size_map[cat], color="lightgray",
        edgecolors="black", linewidths=0.3, alpha=0.7,
    )
    for cat in size_levels
]
ax.legend(
    size_handles, size_levels,
    title="Nombre total de tests",
    loc="upper center",
    bbox_to_anchor=(0.5, -0.18),
    ncol=5,
    frameon=False,
    fontsize=8,
    title_fontsize=9,
)
finish_map(
    ax,
    title=(
        "Taux de positivité et volume de tests par établissement (2023)"
    ),
)

# sauvegarder la carte
save_figure(
    fig,
    here("03_output/3a_figures/health_facility_tpr_test_2023.png"),
    width=10,
    height=7,
)
 

©2026 Applied Health Analytics for Delivery and Innovation. All rights reserved