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
407 changes: 407 additions & 0 deletions .claude/skills/measure-scoring/skill.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.opencds.cqf.fhir.cr.measure.common;

import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import jakarta.annotation.Nullable;
import java.util.Collections;
import java.util.List;
Expand All @@ -13,7 +14,10 @@ public class GroupDef {
private final ConceptDef code;
private final List<StratifierDef> stratifiers;
private final List<PopulationDef> populations;

@Nullable
private final MeasureScoring measureScoring;

private final boolean isGroupImpNotation;
private final CodeDef populationBasis;
private final CodeDef improvementNotation;
Expand All @@ -28,7 +32,7 @@ public GroupDef(
ConceptDef code,
List<StratifierDef> stratifiers,
List<PopulationDef> populations,
MeasureScoring measureScoring,
@Nullable MeasureScoring measureScoring,
boolean isGroupImprovementNotation,
CodeDef improvementNotation,
CodeDef populationBasis) {
Expand Down Expand Up @@ -149,6 +153,17 @@ private Map<MeasurePopulationType, List<PopulationDef>> index(List<PopulationDef
return populations.stream().collect(Collectors.groupingBy(PopulationDef::type));
}

public boolean hasMeasureScoring() {
return this.measureScoring != null;
}

/**
* This method should only be called from production code that builds a MeasureReport group,
* setting the scoring extension on that group.
*
* @return MeasureScoring associated directly with the group, if it exists at all.
*/
@Nullable
public MeasureScoring measureScoring() {
return this.measureScoring;
}
Expand Down Expand Up @@ -204,13 +219,27 @@ public Double getScore() {
return this.score;
}

public void setScoreAndAdaptToImprovementNotation(Double originalScore) {
if ((MeasureScoring.RATIO == measureScoring && hasPopulationType(MeasurePopulationType.MEASUREOBSERVATION))
|| MeasureScoring.PROPORTION == measureScoring) {
public void setScoreAndAdaptToImprovementNotation(Double originalScore, MeasureScoring measureOrGroupScoring) {
if ((MeasureScoring.RATIO == measureOrGroupScoring
&& hasPopulationType(MeasurePopulationType.MEASUREOBSERVATION))
|| MeasureScoring.PROPORTION == measureOrGroupScoring) {
this.score = MeasureScoreCalculator.scoreGroupAccordingToIncreaseImprovementNotation(
originalScore, isIncreaseImprovementNotation());
} else {
this.score = originalScore;
}
}

public MeasureScoring getMeasureOrGroupScoring(MeasureDef measureDef) {
if (measureDef.hasMeasureScoring()) {
return measureDef.measureScoring();
}

if (hasMeasureScoring()) {
return measureScoring();
}

throw new InternalErrorException("Must have scoring either at the measure or group level for measure URL: %s"
.formatted(measureDef.url()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,30 @@ public class MeasureDef {
private final String url;

private final String version;

@Nullable
private final MeasureScoring measureScoring;

private final List<GroupDef> groups;
private final List<SdeDef> sdes;
private final List<String> errors;

public static MeasureDef fromIdAndUrl(IIdType idType, @Nullable String url) {
return new MeasureDef(idType, url, null, List.of(), List.of());
return new MeasureDef(idType, url, null, null, List.of(), List.of());
}

public MeasureDef(IIdType idType, @Nullable String url, String version, List<GroupDef> groups, List<SdeDef> sdes) {
public MeasureDef(
IIdType idType,
@Nullable String url,
String version,
@Nullable MeasureScoring measureScoring,
List<GroupDef> groups,
List<SdeDef> sdes) {

this.idType = idType;
this.url = url;
this.version = version;
this.measureScoring = measureScoring;
this.groups = groups;
this.sdes = sdes;

Expand All @@ -53,6 +65,15 @@ public String version() {
return this.version;
}

public boolean hasMeasureScoring() {
return this.measureScoring != null;
}

@Nullable
public MeasureScoring measureScoring() {
return this.measureScoring;
}

public List<SdeDef> sdes() {
return this.sdes;
}
Expand Down Expand Up @@ -93,6 +114,7 @@ public String toString() {
.add("idType='" + idType.getValueAsString() + "'")
.add("url='" + url + "'")
.add("version='" + version + "'")
.add("measureScoring='" + measureScoring + "'")
.add("groups=" + groups.size())
.add("sdes=" + sdes.size())
.add("errors=" + errors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ protected void validateRatioContinuousVariable(GroupDef groupDef) {
}

protected void evaluateProportion(
MeasureScoring measureOrGroupScoring,
GroupDef groupDef,
String subjectType,
String subjectId,
Expand All @@ -228,7 +229,7 @@ protected void evaluateProportion(
boolean applyScoring) {
// check populations
R4MeasureScoringTypePopulations.validateScoringTypePopulations(
groupDef.populations().stream().map(PopulationDef::type).toList(), groupDef.measureScoring());
groupDef.populations().stream().map(PopulationDef::type).toList(), measureOrGroupScoring);

PopulationDef initialPopulation = groupDef.getSingle(INITIALPOPULATION);
PopulationDef numerator = groupDef.getSingle(NUMERATOR);
Expand Down Expand Up @@ -624,10 +625,18 @@ protected void evaluateGroup(

evaluateStratifiers(subjectId, groupDef.stratifiers(), evaluationResult, groupDef);

var scoring = groupDef.measureScoring();
switch (scoring) {
final MeasureScoring measureOrGroupScoring = groupDef.getMeasureOrGroupScoring(measureDef);

switch (measureOrGroupScoring) {
case PROPORTION, RATIO:
evaluateProportion(groupDef, subjectType, subjectId, reportType, evaluationResult, applyScoring);
evaluateProportion(
measureOrGroupScoring,
groupDef,
subjectType,
subjectId,
reportType,
evaluationResult,
applyScoring);
break;
case CONTINUOUSVARIABLE:
evaluateContinuousVariable(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,29 +90,27 @@ public class MeasureReportDefScorer {
public void score(String measureUrl, MeasureDef measureDef) {
// Def-first iteration: iterate over MeasureDef.groups()
for (GroupDef groupDef : measureDef.groups()) {
scoreGroup(measureUrl, groupDef);
scoreGroup(measureDef, groupDef);
}
}

/**
* Score a single group including all its stratifiers - MUTATES GroupDef and StratumDef objects.
*
* @param measureUrl the measure URL for error reporting
* @param groupDef the group definition to score (will be mutated with setScore())
*/
public void scoreGroup(String measureUrl, GroupDef groupDef) {
MeasureScoring measureScoring = checkMissingScoringType(measureUrl, groupDef.measureScoring());
public void scoreGroup(MeasureDef measureDef, GroupDef groupDef) {

var measureUrl = measureDef.url();
var measureOrGroupScoring = groupDef.getMeasureOrGroupScoring(measureDef);

checkMissingScoringType(measureUrl, measureOrGroupScoring);

// Calculate group-level score
Double groupScore = calculateGroupScore(measureUrl, groupDef, measureScoring);
Double groupScore = calculateGroupScore(measureUrl, groupDef, measureOrGroupScoring);

// MUTATE: Set score on GroupDef
groupDef.setScoreAndAdaptToImprovementNotation(groupScore);
groupDef.setScoreAndAdaptToImprovementNotation(groupScore, measureOrGroupScoring);

// Score all stratifiers using Def-first iteration
// Modified from R4MeasureReportScorer to iterate over Def classes instead of FHIR components
for (StratifierDef stratifierDef : groupDef.stratifiers()) {
scoreStratifier(measureUrl, groupDef, stratifierDef, measureScoring);
scoreStratifier(measureUrl, groupDef, stratifierDef, measureOrGroupScoring);
}
}

Expand All @@ -121,8 +119,7 @@ public void scoreGroup(String measureUrl, GroupDef groupDef) {
*/
private Double calculateGroupScore(String measureUrl, GroupDef groupDef, MeasureScoring measureScoring) {
switch (measureScoring) {
case PROPORTION:
case RATIO:
case PROPORTION, RATIO:
// Special case: RATIO with separate numerator/denominator observations
if (measureScoring == MeasureScoring.RATIO
&& groupDef.hasPopulationType(MeasurePopulationType.MEASUREOBSERVATION)) {
Expand Down Expand Up @@ -263,12 +260,11 @@ private Double getStratumScoreOrNull(
String measureUrl, GroupDef groupDef, StratumDef stratumDef, MeasureScoring measureScoring) {

switch (measureScoring) {
case PROPORTION:
case RATIO:
case PROPORTION, RATIO:
// Check for special RATIO continuous variable case
if (measureScoring.equals(MeasureScoring.RATIO)
&& groupDef.hasPopulationType(MeasurePopulationType.MEASUREOBSERVATION)) {
return scoreRatioMeasureObservationStratum(measureUrl, stratumDef);
return scoreRatioMeasureObservationStratum(stratumDef);
} else {
return scoreProportionRatioStratum(groupDef, stratumDef);
}
Expand All @@ -286,12 +282,11 @@ private Double getStratumScoreOrNull(
* Handles continuous variable ratio scoring where numerator and denominator have separate observations.
* Uses pre-computed cache to eliminate redundant lookups during scoring.
*
* @param measureUrl the measure URL for error reporting
* @param stratumDef the stratum definition
* @return the calculated score or null
*/
@Nullable
private Double scoreRatioMeasureObservationStratum(String measureUrl, StratumDef stratumDef) {
private Double scoreRatioMeasureObservationStratum(StratumDef stratumDef) {

if (stratumDef == null) {
return null;
Expand All @@ -311,8 +306,7 @@ private Double scoreRatioMeasureObservationStratum(String measureUrl, StratumDef
PopulationDef numPopDef = stratumPopulationDefNum.populationDef();
PopulationDef denPopDef = stratumPopulationDefDen.populationDef();

return scoreRatioContVariableStratum(
measureUrl, stratumPopulationDefNum, stratumPopulationDefDen, numPopDef, denPopDef);
return scoreRatioContVariableStratum(stratumPopulationDefNum, stratumPopulationDefDen, numPopDef, denPopDef);
}

/**
Expand Down Expand Up @@ -372,15 +366,13 @@ private Double scoreContinuousVariableStratum(String measureUrl, GroupDef groupD
* Score ratio continuous variable for a stratum.
* Copied from R4MeasureReportScorer#scoreRatioContVariableStratum.
*
* @param measureUrl the measure URL for error reporting
* @param measureObsNumStratum stratum population for numerator measure observation
* @param measureObsDenStratum stratum population for denominator measure observation
* @param numPopDef numerator population definition
* @param denPopDef denominator population definition
* @return the ratio score or null
*/
private Double scoreRatioContVariableStratum(
String measureUrl,
StratumPopulationDef measureObsNumStratum,
StratumPopulationDef measureObsDenStratum,
PopulationDef numPopDef,
Expand Down Expand Up @@ -502,7 +494,7 @@ private static Collection<Object> getResultsForStratum(
.filter(entry -> stratumSubjectsUnqualified.contains(entry.getKey()))
.map(Map.Entry::getValue)
.flatMap(Collection::stream)
.collect(Collectors.toList());
.toList();
}

/**
Expand Down Expand Up @@ -561,7 +553,7 @@ private static Collection<Object> getResultsForStratumByResourceIds(
}
return false;
})
.collect(Collectors.toList());
.toList();
}

/**
Expand Down Expand Up @@ -601,21 +593,19 @@ private static QuantityDef calculateContinuousVariableAggregateQuantity(
}

private static void setAggregateResultIfPopNonNull(@Nullable PopulationDef populationDef, QuantityDef quantityDef) {
Optional.ofNullable(populationDef).ifPresent(nonNullPopulationDef -> {
nonNullPopulationDef.setAggregationResult(quantityDef);
});
Optional.ofNullable(populationDef)
.ifPresent(nonNullPopulationDef -> nonNullPopulationDef.setAggregationResult(quantityDef));
}

/**
* Validate scoring type is present.
* Reused from BaseMeasureReportScorer pattern.
*/
protected MeasureScoring checkMissingScoringType(String measureUrl, MeasureScoring measureScoring) {
private void checkMissingScoringType(String measureUrl, MeasureScoring measureScoring) {
if (measureScoring == null) {
throw new InvalidRequestException(
"Measure does not have a scoring methodology defined. Add a \"scoring\" property to the measure definition or the group definition for measure: "
+ measureUrl);
}
return measureScoring;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static org.opencds.cqf.fhir.cr.measure.constant.MeasureReportConstants.IMPROVEMENT_NOTATION_SYSTEM_INCREASE;

import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import jakarta.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
Expand Down Expand Up @@ -53,7 +54,8 @@ public MeasureDef build(Measure measure) {
}

// scoring
MeasureScoring measureScoring =
@Nullable
final MeasureScoring measureScoring =
MeasureScoring.fromCode(measure.getScoring().getCodingFirstRep().getCode());
// populationBasis
var measureBasis = getMeasureBasis(measure);
Expand Down Expand Up @@ -115,7 +117,8 @@ public MeasureDef build(Measure measure) {
groups.add(groupDef);
}

return new MeasureDef(measure.getIdElement(), measure.getUrl(), measure.getVersion(), groups, sdes);
return new MeasureDef(
measure.getIdElement(), measure.getUrl(), measure.getVersion(), measureScoring, groups, sdes);
}

private ConceptDef conceptToConceptDef(CodeableConcept codeable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public MeasureDef build(Measure measure) {
checkId(measure);

// scoring
var measureScoring = R4MeasureUtils.getMeasureScoring(measure);
@Nullable final MeasureScoring measureScoring = R4MeasureUtils.getMeasureScoring(measure);
// populationBasis
var measureBasis = getMeasureBasis(measure);
// improvement Notation
Expand All @@ -73,18 +73,30 @@ public MeasureDef build(Measure measure) {

return new MeasureDef(
// We don't need either the version of the "Measure" qualifier here
measure.getIdElement(), measure.getUrl(), measure.getVersion(), groups, getSdeDefs(measure));
measure.getIdElement(),
measure.getUrl(),
measure.getVersion(),
measureScoring,
groups,
getSdeDefs(measure));
}

private GroupDef buildGroupDef(
Measure measure,
MeasureGroupComponent group,
MeasureScoring measureScoring,
@Nullable MeasureScoring measureScoring,
CodeDef measureImpNotation,
CodeDef measureBasis) {

// group Measure Scoring
var groupScoring = getGroupMeasureScoring(measure, group);

if (measureScoring != null && groupScoring != null) {
throw new InvalidRequestException(
"Scoring should be at the measure level or the group level, but not both for measure: %s"
.formatted(measure.getUrl()));
}

// populationBasis
var groupBasis = getGroupPopulationBasis(group);
// improvement Notation
Expand Down Expand Up @@ -114,7 +126,7 @@ private GroupDef buildGroupDef(
conceptToConceptDef(group.getCode()),
stratifiers,
mergePopulations(populationsWithCriteriaReference, optPopulationDefDateOfCompliance.orElse(null)),
R4MeasureUtils.computeScoring(measure.getUrl(), measureScoring, groupScoring),
groupScoring,
hasGroupImpNotation,
getImprovementNotation(measureImpNotation, groupImpNotation),
populationBasisDef);
Expand Down
Loading
Loading