|
11 min de lecture
|

Calculer les mesures de qualité CMS en SQL on FHIR — sans moteur CQL

Résumé de l'article

Les mesures de qualité CMS rédigées en CQL peuvent être exécutées en SQL ordinaire sur Aidbox/PostgreSQL via SQL on FHIR — sans moteur CQL à l'exécution. Les ViewDefinitions aplatissent FHIR en tables, l'appartenance à un ValueSet devient un JOIN, et chaque mesure est une requête SQL empaquetée comme bibliothèque SQLQuery et exposée via l'API standard Measure/$evaluate-measure. Un exemple complet et fonctionnel (une douzaine de mesures CMS) est disponible sur GitHub.

Résumer cet article avec :
ChatGPTPerplexityClaudeGrok

Le problème de l'exécution des eCQM

Les mesures cliniques électroniques de qualité (eCQM) permettent aux programmes de mesurer la qualité des soins — par exemple, si les patients admissibles ont subi leur dépistage colorectal ou si leur hypertension est contrôlée. Les mesures CMS et HEDIS que chaque programme de soins basé sur la valeur doit déclarer sont rédigées en CQL (Clinical Quality Language), et la méthode habituelle pour les exécuter est un moteur CQL dédié : une couche d'exécution distincte qui analyse la mesure, interroge votre serveur FHIR pour les données terminologiques et les données patients, puis retourne un MeasureReport.

Cette approche fonctionne bien, mais à un coût :

  • Une couche de calcul distincte. Un moteur CQL est son propre environnement d'exécution — vous le déployez et le faites évoluer parallèlement à votre serveur FHIR, et il effectue le calcul des populations en dehors de la base de données. C'est une infrastructure réelle, et un enjeu de mise à l'échelle pour de grandes populations.
  • Difficile à expliquer. Lorsqu'un patient apparaît comme un écart de soins, pouvez-vous montrer à un clinicien exactement pourquoi? Cela implique de retracer les mécanismes internes du moteur et les expansions de jeux de valeurs — pas de lire une requête.

Voici donc la question à laquelle ce billet répond : et si la logique de la mesure n'était que du SQL, s'exécutant là où vos données résident déjà?

Il s'avère que c'est possible — et le résultat va bien au-delà d'une simple optimisation des performances : avec SQL on FHIR, l'ensemble du savoir nécessaire au calcul d'une mesure devient des artefacts FHIR portables et standardisés que vous pouvez empaqueter dans un paquet FHIR et installer sur un autre serveur. Voici une présentation technique de la démarche, avec un exemple complet et fonctionnel que vous pouvez cloner et exécuter.

L'idée : les mesures sont une logique d'ensembles, et SQL est un langage d'ensembles

Une mesure de qualité est fondamentalement de l'arithmétique d'ensembles sur une population de patients :

  • Population initiale — qui est admissible (âge, consultations, une condition).
  • Dénominateur / Exclusions — qui est comptabilisé, moins qui est exclu (soins palliatifs, soins de fin de vie, fragilité…).
  • Numérateur — qui a satisfait à la mesure (un dépistage, une valeur contrôlée).
  • Score — numérateur / (dénominateur − exclusions).

Chacun de ces éléments est un ensemble de patients. SQL est très efficace pour les ensembles. La seule chose qui sépare les données FHIR d'une requête SQL, c'est que les ressources FHIR sont du JSON profondément imbriqué, et que l'appartenance terminologique (« ce code fait-il partie du jeu de valeurs? ») n'est pas une colonne sur laquelle on peut filtrer. SQL on FHIR résout ces deux problèmes.

ViewDefinition +$materialize flatten Aidbox — FHIR JSONB sof.* flat tablespatient, encounter, condition, observation… ValueSet expansions concepts table(valueset_url, system, code) Measure SQL (CTEs)IP → exclusions → numerator MeasureReport

Couche 1 : aplatir FHIR en tables avec des ViewDefinitions

Une ViewDefinition est une ressource FHIR qui décrit comment aplatir un type de ressource en une table plane. Pointez-en une sur Encounter, matérialisez-la, et vous obtenez une table sof.encounter_flat bien structurée avec patient_id, type_system, type_code, status, period_start, etc. — votre logique de mesure lit des colonnes simples plutôt que d'explorer des structures FHIR imbriquées.

L'exemple fournit une ViewDefinition par type de ressource que les mesures utilisent — patient, encounter, condition, observation, procedure, et quelques autres — chacune matérialisée dans le schéma sof.* et exposée via une vue enveloppante légère. Cette couche plate partagée est réutilisée par toutes les mesures.

Couche 2 : la terminologie comme JOIN, non comme $expand

L'autre partie difficile d'une mesure est l'appartenance à un jeu de codes : « cette consultation est-elle l'un des sept types de visites admissibles? » Dans un environnement avec moteur CQL, cela est typiquement résolu à l'exécution contre un service de terminologie. Avec SQL on FHIR, vous aplatissez les expansions de ValueSet une seule fois dans une table concepts — une ligne par triplet (valueset_url, system, code) — et l'appartenance devient une jointure ordinaire :

JOIN concepts c
  ON  c.system = e.type_system
  AND c.code   = e.type_code
  AND c.valueset_url = 'http://cts.nlm.nih.gov/fhir/ValueSet/…'

Aucun aller-retour réseau, aucune expansion par patient. Le jeu de valeurs est étendu hors ligne et indexé, de sorte que la vérification « le code de ce patient est-il comptabilisé? » est basée sur des ensembles et rapide.

Couche 3 : la mesure est une seule requête SQL

Avec des tables plates et un JOIN sur concepts disponibles, une mesure entière devient une seule requête composée de CTE — et elle ressemble remarquablement à la définition en langage courant de la mesure. Voici la Population initiale de CMS130 (Dépistage du cancer colorectal) — les patients âgés de 46 à 75 ans ayant eu une consultation admissible durant la période de mesure :

WITH mp AS (
  SELECT '2026-01-01'::timestamptz AS mp_start,
         '2026-12-31'::timestamptz AS mp_end
),

qualifying_encounters AS (
  SELECT DISTINCT e.patient_id
  FROM encounter_flat e
  JOIN concepts c
    ON  c.system = e.type_system
    AND c.code   = e.type_code
    AND c.valueset_url IN (
      -- ValueSet URLs shortened for readability; full URLs in the example repo
      'http://cts.nlm.nih.gov/fhir/ValueSet/…',  -- Office Visit
      'http://cts.nlm.nih.gov/fhir/ValueSet/…',  -- Annual Wellness Visit
      '…'                                        -- + 5 more
    )
  CROSS JOIN mp
  WHERE e.status = 'finished'
    AND e.period_start BETWEEN mp.mp_start AND mp.mp_end
),

initial_population AS (
  SELECT p.id AS patient_id
  FROM patient_flat p
  CROSS JOIN mp
  WHERE EXTRACT(YEAR FROM AGE(mp.mp_end, p.birth_date::date)) BETWEEN 46 AND 75
    AND p.id IN (SELECT patient_id FROM qualifying_encounters)
)
-- … denominator exclusions, numerator, and the final score follow as more CTEs

Le numérateur ajoute quelques CTE supplémentaires (coloscopie dans les 9 dernières années, FOBT durant la période, etc.), les exclusions regroupent les soins palliatifs et de fin de vie et la fragilité, et un SELECT final calcule le score. L'essentiel : la mesure est lisible. Vous pouvez la lire, la comparer à la spécification, et faire SELECT * FROM initial_population pour voir exactement qui s'est qualifié.

La logique partagée reste partagée

Des exclusions comme les soins palliatifs, les soins de fin de vie et la maladie avancée avec fragilité se retrouvent dans de nombreuses mesures. Dans l'exemple, elles sont factorisées en éléments réutilisables plutôt que copiées-collées, de sorte que corriger la logique des soins palliatifs la corrige partout à la fois.

Mais qui écrit tout ce SQL?

L'objection évidente : traduire manuellement le CQL d'une mesure en SQL ressemble à un travail long et sujet aux erreurs — et il existe des dizaines d'eCQM. En pratique, c'est exactement le genre de tâche que les assistants de codage basés sur l'IA gèrent bien. CQL et SQL sont tous deux des langages structurés et bien spécifiés, et la logique d'une mesure se mappe proprement sur le patron CTE présenté ci-dessus (population initiale → exclusions → numérateur → score).

Ce n'est pas un espoir — c'est ainsi que l'exemple lui-même a été construit. Chaque mesure a été traduite de son CQL publié vers SQL avec un assistant IA, puis vérifiée patient par patient contre les données de référence MeasureReport de CMS jusqu'à ce que les chiffres concordent exactement. L'IA effectue la traduction mécanique; les données de référence la maintiennent honnête.

Les données de référence sont ce qui rend la traduction assistée par IA digne de confiance : les résultats MeasureReport attendus pour les patients de test sont publiés avec le contenu des mesures dans le dépôt dqm-content-qicore-2025, de sorte que chaque mesure traduite est vérifiable par rapport à une vérité terrain — et non acceptée sur la foi.

Rendre cela conforme aux standards : SQLQuery + Measure/$evaluate-measure

Exécuter du SQL en interne est bien, mais l'objectif est un service FHIR conforme, pas un script de base de données. Deux éléments SQL on FHIR comblent cet écart :

  • Le SQL de chaque mesure est stocké dans Aidbox comme ressource Library SQLQuery (un profil SQL on FHIR sur Library) et invoqué avec l'opération $sqlquery-run. La logique de calcul réside dans le serveur FHIR, comme des ressources de première classe — pas dans le code applicatif.
  • Le service répond à l'opération FHIR R4 standard Measure/$evaluate-measure et retourne un MeasureReport conforme — de sorte que tout client FHIR le consomme de la même façon qu'un résultat d'un moteur CQL.

Chaque bibliothèque SQLQuery déclare également une lignée depends-on vers les ViewDefinitions qu'elle lit, vous donnant un graphe interrogeable : mesure → vues → ressources. Tout ce dont une mesure a besoin — les vues plates, la terminologie, le calcul — est maintenant exprimé sous forme de ressources FHIR standard résidant dans le serveur FHIR. Ce qui prépare le vrai avantage.

POST /Measure/$evaluate-measure routes to $sqlquery-run reads builds Client Aidbox evaluate-measure app SQLQuery Library(the measure SQL) sof.* views + concepts MeasureReport→ back to the client

L'application evaluate-measure au centre ne contient aucune logique de mesure : elle résout la bonne bibliothèque SQLQuery, invoque $sqlquery-run, et structure les lignes retournées en un MeasureReport. Le calcul lui-même s'exécute dans la base de données.

L'avantage : toute la suite de mesures est un paquet FHIR portable

Parce que chaque partie d'une mesure est maintenant une ressource FHIR standard, l'ensemble de la suite s'emballe dans un seul paquet FHIR NPM : la terminologie (CodeSystems + ValueSets), les ViewDefinitions qui aplatissent les données, et les bibliothèques SQLQuery qui contiennent la logique de calcul. Dans l'exemple, ce paquet transporte 170 ressources sur une douzaine de mesures — 10 CodeSystems, 107 ValueSets, 10 ViewDefinitions, et 43 bibliothèques SQLQuery.

Le format du paquet est standard — le même format FHIR NPM que les Implementation Guides utilisent — de sorte que n'importe quel serveur FHIR peut le charger avec son propre mécanisme d'installation de paquet; dans Aidbox, c'est un appel $fhir-package-install au démarrage du serveur, et les définitions sont simplement disponibles. Et voici ce qui est important : ce paquet n'est pas lié à Aidbox. Ce sont des blocs de construction SQL on FHIR standard — des ViewDefinitions et des bibliothèques SQLQuery définies par la spécification SQL on FHIR. Déplacez le paquet vers n'importe quel serveur FHIR qui implémente ces blocs de construction, installez-le, et les mêmes mesures calculent de la même façon. La façon dont chaque serveur les exécute est un détail d'implémentation — certains font tourner un moteur SQL on FHIR distinct, d'autres exécutent en base de données.

package install package install package install FHIR packageterminology + ViewDefinitions + SQLQuery Libraries FHIR server A(SQL on FHIR) FHIR server B(SQL on FHIR) …any SQL-on-FHIR server

Aidbox emprunte le chemin en base de données : les ViewDefinitions se matérialisent en tables ou vues et les bibliothèques SQLQuery s'exécutent en SQL natif, directement dans son PostgreSQL — là où les données résident déjà. Ainsi, le paquet s'installe et fonctionne sans rien de supplémentaire à déployer pour le calcul lui-même : aucun moteur d'exécution à déployer, à faire évoluer ou à maintenir synchronisé. La logique de mesure se distribue de la même façon qu'un Implementation Guide — sous forme d'artefacts FHIR standard et partageables.

Analyser un résultat

Vous souvenez-vous du problème « difficile à expliquer »? Il trouve ici une réponse directe. Demandez pour un seul patient, et le MeasureReport contient un tableau evaluatedResource : des références réelles à des ressources FHIR, chacune étiquetée avec la population qu'elle satisfait via l'extension standard cqf-criteriaReference.

"evaluatedResource": [
  {
    "reference": "Encounter/abc",
    "extension": [
      { "url": ".../cqf-criteriaReference", "valueString": "initial-population" },
      { "url": ".../cqf-criteriaReference", "valueString": "denominator" }
    ]
  },
  {
    "reference": "Procedure/xyz",
    "extension": [
      { "url": ".../cqf-criteriaReference", "valueString": "numerator" }
    ]
  }
]

« Pourquoi ce patient est-il dans le numérateur? » — cette Procedure. « Pourquoi est-il exclu? » — cette consultation de soins palliatifs. Chaque référence pointe vers une ressource qui existe dans le serveur, de sorte que l'étape suivante de l'enquête est simplement un GET.

Pour l'analyse à l'échelle d'une population, chaque mesure inclut également une bibliothèque SQLQuery -evidence : une ligne par patient avec la chaîne de décision complète — quel chemin a satisfait au numérateur (coloscopie ou FOBT ou aucun), la ressource déclencheuse avec son code et sa date, et quelle exclusion s'est appliquée. C'est une liste de travail sur les écarts de soins — qui manque un dépistage, et exactement ce qui manque — comme une seule requête. L'application de démonstration de l'exemple est ces requêtes rendues cliquables : une liste de travail multi-mesures, une vue à 360° par patient avec exploration des preuves, et des listes d'actions exportables.

Patient 360 view in the demo app: the CMS130 decision chain with a per-CTE verdict for every population step, and the qualifying encounter shown as evidence
Vue à 360° du patient dans l'application de démonstration : un écart de dépistage colorectal ouvert, la chaîne de décision CMS130 complète (un verdict par CTE), et la ressource de preuve derrière l'appartenance du patient à la Population initiale.

Et lorsqu'un chiffre semble encore incorrect, chaque population est un CTE nommé : SELECT * FROM initial_population et affinez pas à pas — sans avoir à retracer les mécanismes internes d'un moteur.

Essayez-le vous-même

Tout ce qui précède est un exemple fonctionnel et à source ouverte dans le dépôt d'exemples Aidbox — une douzaine de mesures CMS (CMS130, CMS165, CMS125, CMS131, et plus) avec des données patients d'exemple et une application de démonstration interactive.

github.com/Aidbox/examples · aidbox-custom-operations/measure-evaluate

Suivez les instructions dans le README pour faire tourner l'ensemble de la pile localement : démarrez Aidbox avec le paquet de mesures installé au démarrage, chargez le jeu de données d'exemple, calculez les mesures via l'opération standard Measure/$evaluate-measure, et explorez les preuves de chaque patient dans l'interface de l'application de démonstration. Ce qui est retourné est un MeasureReport FHIR ordinaire avec les décomptes de population et le score — calculé par SQL, à l'intérieur de Aidbox, sans moteur CQL nulle part dans la pile.

Demo app Overview: twelve CMS measure cards with scores, and a sidebar summarizing patients with gaps and open gaps
Vue d'ensemble de l'application de démonstration : une douzaine de mesures CMS calculées par SQL, avec les scores et les décomptes d'écarts ouverts sur 530 patients d'exemple.

À retenir

CQL est un bon langage de rédaction pour les mesures de qualité. Mais il n'a pas à être votre moteur d'exécution. Lorsque vous aplatissez FHIR avec des ViewDefinitions, transformez la terminologie en jointure, et exprimez chaque mesure comme une bibliothèque SQLQuery derrière Measure/$evaluate-measure, la logique de mesure cesse d'être enfermée dans un moteur et devient ce que SQL on FHIR promet : des artefacts FHIR standard que vous pouvez empaqueter une fois et exécuter n'importe où. Distribuez toute la suite comme un paquet FHIR, installez-la sur n'importe quel serveur SQL on FHIR, et les mêmes mesures calculent de la même façon — explicables jusqu'à la ressource individuelle. Sur Aidbox, elles s'exécutent nativement dans PostgreSQL, il n'y a donc aucun moteur d'exécution distinct à opérer. Et y arriver est plus accessible que cela en a l'air : les assistants IA traduisent le CQL en SQL, et les données de référence propres à CMS vérifient chaque mesure par rapport aux résultats attendus.

Vous souhaitez essayer cette approche de calcul des mesures sur vos propres données? Communiquez avec nous — nous serions ravis de vous guider à travers le processus.

Partager cet article
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.