Skip to content

Commit

Permalink
LNK-2950: Ability to run measure eval using a command line interface (#…
Browse files Browse the repository at this point in the history
…460)

Adding FileSystemInvocation class that allows evaluating a measure using
a data from the locally running file system

Format: `java -jar measureeval-cli.jar
"<measure-bundle-path>" "<patient-bundle-path>" "<start>" "<end>"`

Example: `java -jar measureeval-cli.jar
"C:/path/to/measure-bundle.json" "C:/path/to/patient-bundle.json"
"2021-01-01" "2021-12-31"`

Or directly via IntelliJ with a debug configuration.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a command-line interface for evaluating healthcare measures
using files from the filesystem.
- Users can specify paths for measure and patient bundles, along with
evaluation date ranges.
- Comprehensive documentation added to guide users on building and
running the module.
- Enhanced flexibility with multiple execution profiles for different
use cases.
  - New logging configuration for improved monitoring and debugging.

- **Bug Fixes**
- Improved error handling during file loading and evaluation processes
to provide clearer feedback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
seanmcilvenna authored Sep 24, 2024
2 parents fcf59bd + 67dc39f commit 4574752
Show file tree
Hide file tree
Showing 5 changed files with 279 additions and 2 deletions.
35 changes: 35 additions & 0 deletions Java/measureeval/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# measureeval

## Building for use as CLI

Perform the following from the `/Java` directory, which builds the measureeval JAR file _and_ the dependent shared
module:

```bash
mvn -P cli -pl measureeval -am clean package
```

We use the `cli` Maven profile to ensure that `FileSystemInvocation` is used as the main class.

### Parameters

| Parameter | Description |
|---------------------|--------------------------------------------------------------------------------------|
| measure-bundle-path | The path to the measure bundle JSON file or a directory of resource JSON/XML files. |
| patient-bundle-path | The path to the patient bundle JSON file or a directory of resources JSON/XML files. |
| start | The start date for the measurement period in FHIR Date or DateTime format. |
| end | The end date for the measurement period in FHIR Date or DateTime format. |

### Format/Example

Format:

```bash
java -jar measureeval-cli.jar "<measure-bundle-path>" "<patient-bundle-path>" "<start>" "<end>"
```

Example:

```bash
java -jar measureeval-cli.jar "C:/path/to/measure-bundle.json" "C:/path/to/patient-bundle.json" "2021-01-01" "2021-12-31"
```
24 changes: 24 additions & 0 deletions Java/measureeval/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
</execution>
</executions>
<configuration>
<mainClass>${mainClass}</mainClass>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
Expand All @@ -156,4 +157,27 @@
</plugin>
</plugins>
</build>

<profiles>
<profile>
<id>api</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<mainClass>com.lantanagroup.link.measureeval.MeasureEvalApplication</mainClass>
</properties>
</profile>

<profile>
<id>cli</id>
<properties>
<mainClass>com.lantanagroup.link.measureeval.FileSystemInvocation</mainClass>
<logback.configurationFile>src/main/resources/logback-cli.xml</logback.configurationFile>
</properties>
<build>
<finalName>measureeval-cli</finalName>
</build>
</profile>
</profiles>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package com.lantanagroup.link.measureeval;

import ca.uhn.fhir.context.FhirContext;
import com.lantanagroup.link.measureeval.services.MeasureEvaluator;
import com.lantanagroup.link.measureeval.utils.StreamUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.hl7.fhir.r4.model.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;

/**
* This class is used to invoke measure evaluation using arbitrary artifacts on the file system for ease of testing and debugging new measures.
* See the main measureeval project's README for more details about how to execute the JAR from the command line.
* Careful when specifying file paths to use forward-slash instead of windows' backslash, as that will cause java to interpret the backslash as an escape character and not as a path separator, leading to potentially incorrect parameter interpretation.
* The `start` and `end` parameters must be in a valid DateTime format from the FHIR specification.
* The `measure-bundle-path` can be a path to a single measure bundle file or a directory containing each of the resources needed for the measure.
* The `patient-bundle-path` must be a path to a single (JSON or XML) Bundle file or a directory of files containing the patient data to be used in the evaluation.
* The response from the operation is the MeasureReport resource being printed to the console in JSON format.
*/
public class FileSystemInvocation {
private static final FhirContext fhirContext = FhirContext.forR4Cached();
private static final Logger logger = LoggerFactory.getLogger(FileSystemInvocation.class);

private static Bundle getBundle(String measureBundlePath) throws IOException {
logger.info("Loading measure bundle from: {}", measureBundlePath);

try {
File measureBundleFile = new File(measureBundlePath);

if (!measureBundleFile.exists()) {
throw new IllegalArgumentException("Measure bundle file does not exist: " + measureBundlePath);
}

if (measureBundleFile.isFile()) {
String measureBundleContent = FileUtils.readFileToString(measureBundleFile, "UTF-8");
if (measureBundlePath.toLowerCase().endsWith(".json")) {
return fhirContext.newJsonParser().parseResource(Bundle.class, measureBundleContent);
} else if (measureBundlePath.toLowerCase().endsWith(".xml")) {
return fhirContext.newXmlParser().parseResource(Bundle.class, measureBundleContent);
} else {
throw new IllegalArgumentException("Unsupported measure bundle file format: " + measureBundlePath);
}
} else {
// Parse and load each file in the directory
Bundle bundle = new Bundle();
bundle.setType(Bundle.BundleType.COLLECTION);

File[] files = measureBundleFile.listFiles();

if (files != null) {
HashSet<String> loaded = new HashSet<>();

for (File file : files) {
String filePath = file.getAbsolutePath();
String fileExtension = file.isFile() ? FilenameUtils.getExtension(filePath) : null;

if (file.isFile() && (fileExtension.equalsIgnoreCase("json") || fileExtension.equalsIgnoreCase("xml"))) {
String fileName = FilenameUtils.getBaseName(filePath);

if (loaded.contains(fileName)) {
logger.warn("Skipping duplicate file: {}", filePath);
}

Resource resource;

if (filePath.endsWith(".json")) {
resource = (Resource) fhirContext.newJsonParser().parseResource(FileUtils.readFileToString(file, "UTF-8"));
} else {
resource = (Resource) fhirContext.newXmlParser().parseResource(FileUtils.readFileToString(file, "UTF-8"));
}

bundle.addEntry(new Bundle.BundleEntryComponent().setResource(resource));
loaded.add(fileName);
} else {
logger.warn("Skipping file: {}", filePath);
}
}
}

logger.info("Loaded " + bundle.getEntry().size() + " resources from directory: " + measureBundlePath);

return bundle;
}
} catch (IOException ex) {
logger.error("Error occurred while loading measure bundle: {}", ex.getMessage());
throw ex;
}
}


public static List<Bundle> getBundlesFromDirectoryAndSubDirectories(String directory) {
List<Bundle> bundles = new ArrayList<>();
File[] files = new File(directory).listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
bundles.addAll(getBundlesFromDirectoryAndSubDirectories(file.getAbsolutePath()));
} else {
try {
if (!file.getAbsolutePath().toLowerCase().endsWith(".json") && !file.getAbsolutePath().toLowerCase().endsWith(".xml")) {
continue;
}
Bundle bundle = getBundle(file.getAbsolutePath());
bundles.add(bundle);
} catch (IOException e) {
System.err.println("Error occurred while loading bundle: " + e.getMessage());
continue;
}
}
}
}
return bundles;
}

public static String getGroupPopulations(MeasureReport measureReport) {
StringBuilder populations = new StringBuilder();
for (MeasureReport.MeasureReportGroupComponent group : measureReport.getGroup()) {
populations.append("Group: ").append(group.getId()).append("\n");
for (MeasureReport.MeasureReportGroupPopulationComponent population : group.getPopulation()) {
populations.append("Population: ").append(population.getCode().getCodingFirstRep().getDisplay()).append(" - ").append(population.getCount()).append("\n");
}
}
return populations.toString();
}

private static Patient findPatient(Bundle bundle) {
return bundle.getEntry().stream()
.filter(e -> e.getResource() instanceof Patient)
.map(e -> (Patient) e.getResource())
.reduce(StreamUtils::toOnlyElement)
.orElseThrow(() -> new IllegalArgumentException("Patient resource not found in bundle"));
}

private static void evaluatePatientBundle(String patientBundlePath, Bundle patientBundle, String start, String end, MeasureEvaluator evaluator) {
Patient patient = findPatient(patientBundle);
var report = evaluator.evaluate(
new DateTimeType(start),
new DateTimeType(end),
new StringType("Patient/" + patient.getIdElement().getIdPart()),
patientBundle);
String json = fhirContext.newJsonParser().encodeResourceToString(report);
logger.info("Summary of evaluate for patient/groups/populations:\nPatient: {}\n{}\nJSON: {}", patient.getIdElement().getIdPart(), getGroupPopulations(report), json);
}

public static void main(String[] args) {
if (args.length != 4) {
System.err.println("Invalid number of arguments. Expected 4 arguments: <measure-bundle-path> <patient-bundle-path> <start> <end>");
System.exit(1);
}

String measureBundlePath = args[0];
String patientBundlePath = args[1];
String start = args[2];
String end = args[3];

try {
Bundle measureBundle = getBundle(measureBundlePath);
MeasureEvaluator evaluator = MeasureEvaluator.compile(fhirContext, measureBundle, true);

File patientBundleFile = new File(patientBundlePath);

if (patientBundleFile.isDirectory()) {
List<Bundle> patientBundles = getBundlesFromDirectoryAndSubDirectories(patientBundlePath);

for (Bundle patientBundle : patientBundles) {
logger.info("\n===================================================");
evaluatePatientBundle(patientBundlePath, patientBundle, start, end, evaluator);
}
} else {
Bundle patientBundle = getBundle(patientBundlePath);
evaluatePatientBundle(patientBundlePath, patientBundle, start, end, evaluator);
}
} catch (Exception e) {
System.err.println("Error occurred while evaluating measure: " + e.getMessage());
e.printStackTrace();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public class MeasureEvaluator {
private final Bundle bundle;
private final Measure measure;

private MeasureEvaluator(FhirContext fhirContext, Bundle bundle) {
public MeasureEvaluator(FhirContext fhirContext, Bundle bundle) {
this(fhirContext, bundle, false);
}

private MeasureEvaluator(FhirContext fhirContext, Bundle bundle, boolean isDebug) {
this.fhirContext = fhirContext;
options = MeasureEvaluationOptions.defaultOptions();
EvaluationSettings evaluationSettings = options.getEvaluationSettings();
Expand All @@ -42,6 +46,8 @@ private MeasureEvaluator(FhirContext fhirContext, Bundle bundle) {
.setTerminologyParameterMode(RetrieveSettings.TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY)
.setSearchParameterMode(RetrieveSettings.SEARCH_FILTER_MODE.FILTER_IN_MEMORY)
.setProfileMode(RetrieveSettings.PROFILE_MODE.DECLARED);
evaluationSettings.getCqlOptions().getCqlEngineOptions().setDebugLoggingEnabled(isDebug);

this.bundle = bundle;
measure = bundle.getEntry().stream()
.map(Bundle.BundleEntryComponent::getResource)
Expand All @@ -52,7 +58,11 @@ private MeasureEvaluator(FhirContext fhirContext, Bundle bundle) {
}

public static MeasureEvaluator compile(FhirContext fhirContext, Bundle bundle) {
MeasureEvaluator instance = new MeasureEvaluator(fhirContext, bundle);
return compile(fhirContext, bundle, false);
}

public static MeasureEvaluator compile(FhirContext fhirContext, Bundle bundle, boolean isDebug) {
MeasureEvaluator instance = new MeasureEvaluator(fhirContext, bundle, isDebug);
instance.compile();
return instance;
}
Expand Down
23 changes: 23 additions & 0 deletions Java/measureeval/src/main/resources/logback-cli.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<configuration>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
</Pattern>
</layout>
</appender>

<logger name="com.lantanagroup.link" level="info" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>

<logger name="org.opencds.cqf" level="debug" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>

<root level="error">
<appender-ref ref="CONSOLE"/>
</root>

</configuration>

0 comments on commit 4574752

Please sign in to comment.