Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
import org.opencds.cqf.fhir.cr.measure.common.ConceptDef;
import org.opencds.cqf.fhir.cr.measure.common.ContinuousVariableObservationAggregateMethod;
import org.opencds.cqf.fhir.cr.measure.common.GroupDef;
import org.opencds.cqf.fhir.cr.measure.common.InvalidMeasureDefinitionException;
import org.opencds.cqf.fhir.cr.measure.common.MeasureDef;
import org.opencds.cqf.fhir.cr.measure.common.MeasureDefBuilder;
import org.opencds.cqf.fhir.cr.measure.common.MeasurePopulationType;
Expand All @@ -54,6 +53,11 @@
@SuppressWarnings("squid:S1135")
public class R4MeasureDefBuilder implements MeasureDefBuilder<Measure> {

// Non-fatal structural issues collected during build (e.g. stratifiers with no
// criteria.expression and no components). Surfaced as MeasureReport contained
// OperationOutcomes so the rest of the measure still evaluates.
private final List<String> buildErrors = new ArrayList<>();

@Override
public MeasureDef build(Measure measure) {
checkId(measure);
Expand All @@ -73,9 +77,11 @@ public MeasureDef build(Measure measure) {
groups.add(groupDef);
}

return new MeasureDef(
var measureDef = new MeasureDef(
// We don't need either the version of the "Measure" qualifier here
measure.getIdElement(), measure.getUrl(), measure.getVersion(), groups, getSdeDefs(measure));
buildErrors.forEach(measureDef::addError);
return measureDef;
}

private GroupDef buildGroupDef(
Expand Down Expand Up @@ -106,7 +112,9 @@ private GroupDef buildGroupDef(
final Optional<PopulationDef> optPopulationDefDateOfCompliance = buildPopulationDefForDateOfCompliance(
measure.getUrl(), group, populationsWithCriteriaReference, populationBasisDef);

// Stratifiers
// Stratifiers — empty stratifiers (no criteria.expression, no components) yield a
// StratifierDef with a null type that the evaluator and report builder skip.
// Errors collected in buildErrors are surfaced as contained OperationOutcomes.
var stratifiers = group.getStratifier().stream()
.map(mgsc -> buildStratifierDef(measure.getUrl(), mgsc, populationBasisDef))
.toList();
Expand Down Expand Up @@ -349,6 +357,21 @@ private StratifierDef buildStratifierDef(
String measureUrl, MeasureGroupStratifierComponent mgsc, CodeDef populationBasisDef) {
checkId(mgsc);

final boolean hasCriteriaExpression =
mgsc.hasCriteria() && mgsc.getCriteria().hasExpression();
final boolean hasAnyComponentCriteria =
mgsc.getComponent().stream().anyMatch(MeasureGroupStratifierComponentComponent::hasCriteria);

if (!hasCriteriaExpression && !hasAnyComponentCriteria) {
// Build an un-evaluable StratifierDef (null type) and record a non-fatal
// error so the rest of the measure still evaluates and the report contains
// an OperationOutcome. Keeping the StratifierDef in the list preserves the
// 1:1 ordering with Measure.group.stratifier expected by the report builder.
buildErrors.add("Stratifier '%s' has no criteria.expression and no components for measure: %s"
.formatted(mgsc.getId(), measureUrl));
return new StratifierDef(mgsc.getId(), conceptToConceptDef(mgsc.getCode()), null, null, List.of());
}

boolean isBooleanBasis = isBooleanPopulationBasis(populationBasisDef);
// Components
var components = new ArrayList<StratifierComponentDef>();
Expand Down Expand Up @@ -393,15 +416,10 @@ private List<SdeDef> getSdeDefs(Measure measure) {
return sdes;
}

@Nullable
private static MeasureStratifierType getStratifierType(
String measureUrl,
MeasureGroupStratifierComponent measureGroupStratifierComponent,
boolean isBooleanBasis) {
if (measureGroupStratifierComponent == null) {
return null;
}

final boolean hasCriteria = measureGroupStratifierComponent.hasCriteria()
&& measureGroupStratifierComponent.getCriteria().hasExpression();

Expand All @@ -414,12 +432,6 @@ private static MeasureStratifierType getStratifierType(
.formatted(hasCriteria, hasAnyComponentCriteria, measureUrl));
}

if (!hasCriteria && !hasAnyComponentCriteria) {
throw new InvalidMeasureDefinitionException(
"Stratifier '%s' has no criteria.expression and no components for measure: %s"
.formatted(measureGroupStratifierComponent.getId(), measureUrl));
}

if (hasCriteria) {
return MeasureStratifierType.CRITERIA;
} else if (!isBooleanBasis) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,16 @@ private void validateReference(String reference) {

public void addOperationOutcomes() {
var errorMsgs = this.measureDef.errors();
for (var error : errorMsgs) {
addContained(createOperationOutcome(error));
for (int i = 0; i < errorMsgs.size(); i++) {
// Distinct ids so addContained's putIfAbsent does not collapse
// multiple errors into a single contained OperationOutcome.
addContained(createOperationOutcome(errorMsgs.get(i), "operation-outcome-" + (i + 1)));
}
}

private OperationOutcome createOperationOutcome(String errorMsg) {
private OperationOutcome createOperationOutcome(String errorMsg, String id) {
OperationOutcome op = new OperationOutcome();
op.setId(id);
op.addIssue()
.setSeverity(OperationOutcome.IssueSeverity.ERROR)
.setCode(IssueType.EXCEPTION)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import org.hl7.fhir.r4.model.CodeableConcept;
import org.hl7.fhir.r4.model.MeasureReport.MeasureReportStatus;
import org.junit.jupiter.api.Test;
import org.opencds.cqf.fhir.cr.measure.common.InvalidMeasureDefinitionException;
import org.opencds.cqf.fhir.cr.measure.common.MeasurePopulationType;
import org.opencds.cqf.fhir.cr.measure.r4.Measure.Given;
import org.opencds.cqf.fhir.cr.measure.r4.Measure.When;
Expand All @@ -28,20 +27,29 @@ class MeasureStratifierTest {
Measure.given().repositoryFor("CriteriaBasedStratifiersComplex");
private static final Given GIVEN_SIMPLE = Measure.given().repositoryFor("MeasureTest");

/**
* Stratifiers with no {@code criteria.expression} and no components are skipped and
* surfaced as one contained OperationOutcome per offending stratifier; the rest of
* the measure still evaluates.
*/
@Test
void emptyStratifier() {
final When when = GIVEN_MEASURE_STRATIFIER_TEST
GIVEN_MEASURE_STRATIFIER_TEST
.when()
.measureId("EmptyStratifier")
.evaluate();
try {
when.then();
fail("expected InvalidMeasureDefinitionException for stratifier without expression or components");
} catch (InvalidMeasureDefinitionException e) {
assertEquals(
"Stratifier 'stratifier-1' has no criteria.expression and no components for measure: https://example.com/Measure/EmptyStratifier",
e.getMessage());
}
.evaluate()
.then()
.report()
.logReportJson()
.hasContainedOperationOutcome()
.hasContainedOperationOutcomeMsg(
"Stratifier 'stratifier-1' has no criteria.expression and no components for measure: http://example.com/Measure/EmptyStratifier")
.hasContainedOperationOutcomeMsg(
"Stratifier 'stratifier-2' has no criteria.expression and no components for measure: http://example.com/Measure/EmptyStratifier")
.firstGroup()
.hasStratifierCount(2)
.population(MeasurePopulationType.INITIALPOPULATION)
.hasCount(10);
}

/**
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,47 +1,30 @@
{
"resourceType": "Measure",
"id": "EmptyStratifier",
"url": "https://example.com/Measure/EmptyStratifier",
"name": "EmptyStratifier",
"resourceType": "Measure",
"url": "http://example.com/Measure/EmptyStratifier",
"library": [
"https://example.com/Library/EmptyStratifier"
"http://example.com/Library/LibrarySimple"
],
"extension": [
{
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-populationBasis",
"valueCode": "boolean"
}
],
"scoring": {
"coding": [
{
"system": "http://hl7.org/fhir/measure-scoring",
"code": "cohort"
}
]
},
"group": [
{
"id": "group-1",
"extension": [
{
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-scoring",
"valueCodeableConcept": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/measure-scoring",
"code": "proportion",
"display": "Proportion"
}
]
}
},
{
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-populationBasis",
"valueCode": "boolean"
},
{
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-improvementNotation",
"valueCodeableConcept": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/measure-improvement-notation",
"code": "decrease",
"display": "increase"
}
]
}
}
],
"population": [
{
"id": "initial-population-1",
"id": "initial-population",
"code": {
"coding": [
{
Expand All @@ -53,39 +36,7 @@
},
"criteria": {
"language": "text/cql-identifier",
"expression": "Initial Population"
}
},
{
"id": "denominator-1",
"code": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/measure-population",
"code": "denominator",
"display": "Denominator"
}
]
},
"criteria": {
"language": "text/cql-identifier",
"expression": "Denominator"
}
},
{
"id": "numerator-1",
"code": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/measure-population",
"code": "numerator",
"display": "Numerator"
}
]
},
"criteria": {
"language": "text/cql-identifier",
"expression": "Numerator"
"expression": "Initial Population Boolean"
}
}
],
Expand Down
Loading