diff --git a/README.md b/README.md index 4f9ef05f..f12f44b1 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,48 @@ fqm-execution reports -m /path/to/measure/bundle.json -p /path/to/patient1/bundl # Guides and Concepts +## Stratification + +The results for each stratifier on a Measure (if they exist) are reported on the DetailedResults array for each group. The StratifierResult object contains two result attributes: `result` and `appliesResult`. `result` is simply the raw result of the stratifier and `appliesResult` is the same unless that stratifier contains a [cqfm-appliesTo extension](https://hl7.org/fhir/us/cqfmeasures/STU4/StructureDefinition-cqfm-appliesTo.html). In the case that a stratifier applies to a specified population, the `appliesResult` is the result of the stratifier result AND the result of the specified population. The following is an example of what the DetailedResults would look like for a Measure whose single stratifier has a result of `true` but appliesTo the numerator population that has a result of `false`. + +```typescript +[ + { + patientId: 'patient-1', + detailedResults: [ + { + groupId: 'group-1', + populationResults: [ + { + populationType: 'initial-population', + criteriaExpression: 'Initial Population', + result: false + }, + { + populationType: 'denominator', + criteriaExpression: 'Denominator', + result: false + }, + { + populationType: 'numerator', + criteriaExpression: 'Numerator', + result: false + } + ], + stratifierResults: [ + { + strataCode: 'strata-1', + result: true, + appliesResult: false, + strataId: 'strata-1' + } + ] + } + ] + } +]; +``` + ## Measures with Observation Functions For [Continuous Variable Measures](https://build.fhir.org/ig/HL7/cqf-measures/measure-conformance.html#continuous-variable-measure) and [Ratio Measures](https://build.fhir.org/ig/HL7/cqf-measures/measure-conformance.html#ratio-measures), some of the measure populations can have an associated "measure observation", which is a CQL function that will return some result based on some information present on the data during calculation. In the case of these measures, `fqm-execution` will include an `observations` property on the `populationResult` object for each measure observation population. This `observations` property diff --git a/src/calculation/DetailedResultsBuilder.ts b/src/calculation/DetailedResultsBuilder.ts index 5b70067a..fa2c94c6 100644 --- a/src/calculation/DetailedResultsBuilder.ts +++ b/src/calculation/DetailedResultsBuilder.ts @@ -33,6 +33,9 @@ export function createPopulationValues( populationResults = popAndStratResults.populationResults; stratifierResults = popAndStratResults.stratifierResults; populationResults = handlePopulationValues(populationResults, populationGroup, measureScoringCode); + if (stratifierResults) { + stratifierResults = handleStratificationValues(populationGroup, populationResults, stratifierResults); + } } else { // episode of care based measure // collect results per episode @@ -45,6 +48,9 @@ export function createPopulationValues( const popAndStratResults = createPatientPopulationValues(populationGroup, patientResults); populationResults = popAndStratResults.populationResults; stratifierResults = popAndStratResults.stratifierResults; + if (stratifierResults) { + stratifierResults = handleStratificationValues(populationGroup, populationResults, stratifierResults); + } } else { populationResults = []; stratifierResults = []; @@ -92,11 +98,15 @@ export function createPopulationValues( stratifierResults?.push({ result: strat.result, strataCode: strat.strataCode, + appliesResult: strat.result, ...(strat.strataId ? { strataId: strat.strataId } : {}) }); } }); }); + if (stratifierResults) { + stratifierResults = handleStratificationValues(populationGroup, populationResults, stratifierResults); + } } } const detailedResult: DetailedPopulationGroupResult = { @@ -305,9 +315,11 @@ export function createPatientPopulationValues( if (strata.criteria?.expression) { const value = patientResults[strata.criteria?.expression]; const result = isStatementValueTruthy(value); + stratifierResults?.push({ strataCode: strata.code?.text ?? strata.id ?? `strata-${strataIndex++}`, result, + appliesResult: result, ...(strata.id ? { strataId: strata.id } : {}) }); } @@ -320,6 +332,37 @@ export function createPatientPopulationValues( }; } +export function handleStratificationValues( + populationGroup: fhir4.MeasureGroup, + populationResults: PopulationResult[], + stratifierResults: StratifierResult[] +): StratifierResult[] { + if (populationGroup.stratifier) { + stratifierResults.forEach(stratRes => { + const strata = + populationGroup.stratifier?.find(s => s.id === stratRes.strataCode) || + populationGroup.stratifier?.find(s => s.code && s.code.text === stratRes.strataCode); + if (strata) { + // if the cqfm-appliesTo extension is present, then we want to consider the result of that + // population in our stratifier result + const appliesToExtension = strata.extension?.find( + e => e.url === 'http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-appliesTo' + ); + + let popValue = true; + if (appliesToExtension) { + const popCode = appliesToExtension.valueCodeableConcept?.coding?.[0].code; + if (popCode) { + popValue = populationResults.find(pr => pr.populationType === popCode)?.result ?? true; + } + } + stratRes.appliesResult = popValue && stratRes.result; + } + }); + } + return stratifierResults; +} + function isStatementValueTruthy(value: any): boolean { if (Array.isArray(value) && value.length > 0) { return true; @@ -441,6 +484,9 @@ export function createEpisodePopulationValues( populationGroup, measureScoringCode ); + if (episodeResults.stratifierResults) { + handleStratificationValues(populationGroup, episodeResults.populationResults, episodeResults.stratifierResults); + } }); // TODO: Remove any episode that don't fall in any populations or stratifications after the above code @@ -521,7 +567,8 @@ function createOrSetValueOfEpisodes( newEpisodeResults.stratifierResults?.push({ ...(strataId ? { strataId } : {}), strataCode: newStrataCode, - result: newStrataCode === strataCode ? true : false + result: newStrataCode === strataCode ? true : false, + appliesResult: newStrataCode === strataCode ? true : false }); }); } diff --git a/src/calculation/MeasureReportBuilder.ts b/src/calculation/MeasureReportBuilder.ts index 514ff695..29338f77 100644 --- a/src/calculation/MeasureReportBuilder.ts +++ b/src/calculation/MeasureReportBuilder.ts @@ -140,23 +140,9 @@ export default class MeasureReportBuilder exten const strat = {}; // use existing populations, but reduce count as appropriate // Deep copy population with matching attributes but different interface - // if a stratifier has a cqfm-appliesTo extension, then we only want to - // include that population. If none is specified, the stratification applies - // to all populations in a group - const appliesToExtension = s.extension?.find( - e => e.url === 'http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-appliesTo' + strat.population = ( + JSON.parse(JSON.stringify(group.population)) ); - if (appliesToExtension) { - const popCode = appliesToExtension.valueCodeableConcept?.coding?.[0].code; - const matchingPop = group.population?.find(p => p.code?.coding?.[0].code === popCode); - strat.population = ( - JSON.parse(JSON.stringify([matchingPop])) - ); - } else { - strat.population = ( - JSON.parse(JSON.stringify(group.population)) - ); - } reportStratifier.stratum = [strat]; group.stratifier?.push(reportStratifier); @@ -278,7 +264,7 @@ export default class MeasureReportBuilder exten if (group.stratifier) { groupResults.stratifierResults?.forEach(stratResults => { // only add to results if this patient is in the strata - if (stratResults.result) { + if (stratResults.appliesResult) { // the strataCode has the potential to be a couple of things, either s.code[0].text (previous measures) // or s.id (newer measures) const strata: MeasureReportGroupStratifier | undefined = diff --git a/src/types/Calculator.ts b/src/types/Calculator.ts index 8b007950..dd48a91a 100644 --- a/src/types/Calculator.ts +++ b/src/types/Calculator.ts @@ -231,6 +231,12 @@ export interface StratifierResult { * True if patient or episode is in stratifier. False if not. */ result: boolean; + /** + * True if patient or episode is in stratifier AND the population + * result it appliesTo is true. False if not. Only implemented for + * patient based measures currently. + */ + appliesResult: boolean; strataId?: string; } diff --git a/test/unit/DetailedResultsBuilder.test.ts b/test/unit/DetailedResultsBuilder.test.ts index 380b36ad..819a6dd5 100644 --- a/test/unit/DetailedResultsBuilder.test.ts +++ b/test/unit/DetailedResultsBuilder.test.ts @@ -3,7 +3,7 @@ import * as DetailedResultsBuilder from '../../src/calculation/DetailedResultsBu import { getJSONFixture } from './helpers/testHelpers'; import { MeasureScoreType, PopulationType } from '../../src/types/Enums'; import { StatementResults } from '../../src/types/CQLTypes'; -import { PopulationResult, EpisodeResults } from '../../src/types/Calculator'; +import { PopulationResult, EpisodeResults, StratifierResult } from '../../src/types/Calculator'; import { ELMExpressionRef, ELMQuery, ELMTuple, ELMFunctionRef } from '../../src/types/ELMTypes'; type MeasureWithGroup = fhir4.Measure & { @@ -1111,6 +1111,122 @@ describe('DetailedResultsBuilder', () => { }); }); + describe('handleStratificationValues', () => { + test('it should take population result into consider when appliesTo extension exists', () => { + const populationGroup: fhir4.MeasureGroup = { + stratifier: [ + { + id: 'example-strata-id', + extension: [ + { + url: 'http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-appliesTo', + valueCodeableConcept: { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/measure-population', + code: 'initial-population', + display: 'Initial Population' + } + ] + } + } + ], + criteria: { + expression: 'strat1', + language: 'text/cql' + } + } + ] + }; + + const populationResults: PopulationResult[] = [ + { + populationType: PopulationType.IPP, + criteriaExpression: 'Initial Population', + result: false, + populationId: 'exampleId' + } + ]; + + const stratifierResults: StratifierResult[] = [ + { + strataCode: 'example-strata-id', + result: true, + appliesResult: true, + strataId: 'example-strata-id' + } + ]; + + const newStratifierResults = DetailedResultsBuilder.handleStratificationValues( + populationGroup, + populationResults, + stratifierResults + ); + + expect(newStratifierResults).toBeDefined(); + expect(newStratifierResults).toHaveLength(1); + expect(newStratifierResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + strataId: 'example-strata-id', + result: true, + appliesResult: false + }) + ]) + ); + }); + + test('it should not take population result into consideration when appliesTo extension does not exist', () => { + const populationGroup: fhir4.MeasureGroup = { + stratifier: [ + { + id: 'example-strata-id', + criteria: { + expression: 'strat1', + language: 'text/cql' + } + } + ] + }; + + const populationResults: PopulationResult[] = [ + { + populationType: PopulationType.IPP, + criteriaExpression: 'Initial Population', + result: false, + populationId: 'exampleId' + } + ]; + + const stratifierResults: StratifierResult[] = [ + { + strataCode: 'example-strata-id', + result: true, + appliesResult: true, + strataId: 'example-strata-id' + } + ]; + + const newStratifierResults = DetailedResultsBuilder.handleStratificationValues( + populationGroup, + populationResults, + stratifierResults + ); + + expect(newStratifierResults).toBeDefined(); + expect(newStratifierResults).toHaveLength(1); + expect(newStratifierResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + strataId: 'example-strata-id', + result: true, + appliesResult: true + }) + ]) + ); + }); + }); + describe('ELM JSON Function', () => { test('should properly generate episode-based ELM JSON given name and parameter', () => { const exampleFunctionName = 'exampleFunction'; diff --git a/test/unit/MeasureReportBuilder.test.ts b/test/unit/MeasureReportBuilder.test.ts index 043ed595..80543c14 100644 --- a/test/unit/MeasureReportBuilder.test.ts +++ b/test/unit/MeasureReportBuilder.test.ts @@ -265,21 +265,25 @@ const propWithStratExecutionResults: ExecutionResult { result!.stratifierResults!.forEach(sr => { const stratifierResult = group.stratifier?.find(s => s.id === sr.strataId); expect(stratifierResult).toBeDefined(); - expect(stratifierResult!.stratum?.[0].population?.length).toEqual(1); + expect(stratifierResult!.stratum?.[0].population?.length).toEqual(4); expect(stratifierResult!.stratum?.[0].measureScore?.value).toEqual(0); }); }); @@ -441,11 +445,13 @@ describe('MeasureReportBuilder Static', () => { { strataCode: '93f5f1c7-8638-40a4-a596-8b5831599209', result: false, + appliesResult: false, strataId: '93f5f1c7-8638-40a4-a596-8b5831599209' }, { strataCode: '5baf37c7-8887-4576-837e-ea20a8938282', result: false, + appliesResult: false, strataId: '5baf37c7-8887-4576-837e-ea20a8938282' } ] @@ -462,11 +468,13 @@ describe('MeasureReportBuilder Static', () => { { strataCode: '125b3d95-2d00-455f-8a6e-d53614a2a50e', result: false, + appliesResult: false, strataId: '125b3d95-2d00-455f-8a6e-d53614a2a50e' }, { strataCode: 'c06647b9-e134-4189-858d-80cee23c0f8d', result: false, + appliesResult: false, strataId: 'c06647b9-e134-4189-858d-80cee23c0f8d' } ] @@ -475,10 +483,10 @@ describe('MeasureReportBuilder Static', () => { }); }); - test('should generate a summary MeasureReport whose stratifierResults only contain one population in the stratum', () => { + test('should generate a summary MeasureReport whose stratifierResults contain all populations', () => { const { report } = builder; expect(report).toBeDefined(); - expect(report.group?.[0].stratifier?.[0].stratum?.[0].population?.length).toEqual(1); + expect(report.group?.[0].stratifier?.[0].stratum?.[0].population?.length).toEqual(4); }); });