From 3120e50d8b50f87bc19444ce6aff7307ec97a52b Mon Sep 17 00:00:00 2001 From: Marc Hadley Date: Tue, 15 Oct 2024 16:02:55 -0400 Subject: [PATCH] Add support for mapping condition codes to ICD-10-CM in the FHIR R4 exporter. Fix some checkstyle warnings. --- src/main/java/App.java | 15 ++++--- .../org/mitre/synthea/engine/Generator.java | 1 + .../org/mitre/synthea/export/Exporter.java | 44 +++++++++++++++++++ .../java/org/mitre/synthea/export/FhirR4.java | 29 +++++++++++- .../mitre/synthea/export/rif/CodeMapper.java | 39 ++++++++++++++++ .../synthea/helpers/RandomCollection.java | 15 ++++++- src/main/resources/synthea.properties | 6 +++ .../synthea/export/rif/CodeMapperTest.java | 19 ++++++++ 8 files changed, 157 insertions(+), 11 deletions(-) diff --git a/src/main/java/App.java b/src/main/java/App.java index 10cf7171ed..444e6d5fc3 100644 --- a/src/main/java/App.java +++ b/src/main/java/App.java @@ -280,14 +280,15 @@ public static void main(String[] args) throws Exception { * Reset the fields of the provided options to the current values in the Config. */ private static void resetOptionsFromConfig(Generator.GeneratorOptions options, - Exporter.ExporterRuntimeOptions exportOptions) { - // Any options that are automatically set by reading the configuration - // file during options initialization need to be reset here. - options.population = Config.getAsInteger("generate.default_population", 1); - options.threadPoolSize = Config.getAsInteger("generate.thread_pool_size", -1); + Exporter.ExporterRuntimeOptions exportOptions) { + // Any options that are automatically set by reading the configuration + // file during options initialization need to be reset here. + options.population = Config.getAsInteger("generate.default_population", 1); + options.threadPoolSize = Config.getAsInteger("generate.thread_pool_size", -1); - exportOptions.yearsOfHistory = Config.getAsInteger("exporter.years_of_history", 10); - exportOptions.terminologyService = !Config.get("generate.terminology_service_url", "").isEmpty(); + exportOptions.yearsOfHistory = Config.getAsInteger("exporter.years_of_history", 10); + exportOptions.terminologyService = !Config.get("generate.terminology_service_url", "") + .isEmpty(); } private static boolean validateConfig(Generator.GeneratorOptions options, diff --git a/src/main/java/org/mitre/synthea/engine/Generator.java b/src/main/java/org/mitre/synthea/engine/Generator.java index b40f8a6bcb..6e1242dc07 100644 --- a/src/main/java/org/mitre/synthea/engine/Generator.java +++ b/src/main/java/org/mitre/synthea/engine/Generator.java @@ -218,6 +218,7 @@ private void init() { CDWExporter.getInstance().setKeyStart((stateIndex * 1_000_000) + 1); } Exporter.loadCustomExporters(); + Exporter.loadCodeMappers(); this.populationRandom = new DefaultRandomNumberGenerator(options.seed); this.clinicianRandom = new DefaultRandomNumberGenerator(options.clinicianSeed); diff --git a/src/main/java/org/mitre/synthea/export/Exporter.java b/src/main/java/org/mitre/synthea/export/Exporter.java index 4c179db22c..943b33d9f3 100644 --- a/src/main/java/org/mitre/synthea/export/Exporter.java +++ b/src/main/java/org/mitre/synthea/export/Exporter.java @@ -14,14 +14,17 @@ import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.ServiceLoader; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; @@ -34,6 +37,7 @@ import org.mitre.synthea.export.flexporter.FlexporterJavascriptContext; import org.mitre.synthea.export.flexporter.Mapping; import org.mitre.synthea.export.rif.BB2RIFExporter; +import org.mitre.synthea.export.rif.CodeMapper; import org.mitre.synthea.helpers.Config; import org.mitre.synthea.helpers.TransitionMetrics; import org.mitre.synthea.helpers.Utilities; @@ -69,6 +73,7 @@ public enum SupportedFhirVersion { private static List patientExporters; private static List postCompletionExporters; + private static Map codeMappers; /** * If the config setting "exporter.enable_custom_exporters" is enabled, @@ -95,6 +100,45 @@ public static void loadCustomExporters() { } } + /** + * Load any configured code mappers. Code mappers are configured via the + * synthea.properties file and a sample configuration is shown below: + *
+   * exporter.code_map.icd_10=export/anti_amyloid_code_map.json
+   * exporter.code_map.cpt=export/phlebotomy_code_map.json,export/neurology_code_map.json
+   * 
+ * The above define a single code map for ICD-10 codes and two code maps for CPT codes. + */ + public static void loadCodeMappers() { + codeMappers = new HashMap(); + List codeSystemProperties = Config.allPropertyNames() + .stream() + .filter((key) -> key.startsWith("exporter.code_map")) + .collect(Collectors.toList()); + codeSystemProperties.forEach(codeSystemProperty -> { + String codeSystem = codeSystemProperty.strip().replace( + "exporter.code_map.", "").toUpperCase(); + String[] resources = Config.get(codeSystemProperty).split(","); + for (String resource: resources) { + CodeMapper mapper = new CodeMapper(resource); + if (codeMappers.containsKey(codeSystem)) { + codeMappers.get(codeSystem).merge(mapper); + } else { + codeMappers.put(codeSystem, mapper); + } + } + }); + } + + /** + * Get the code mapper for the supplied code system. + * @param codeSystem the code system + * @return the corresponding code mapper or null if none configured + */ + public static CodeMapper getCodeMapper(String codeSystem) { + return codeMappers.get(codeSystem); + } + /** * Runtime configuration of the record exporter. */ diff --git a/src/main/java/org/mitre/synthea/export/FhirR4.java b/src/main/java/org/mitre/synthea/export/FhirR4.java index f71f2c6967..9c81f0d7c0 100644 --- a/src/main/java/org/mitre/synthea/export/FhirR4.java +++ b/src/main/java/org/mitre/synthea/export/FhirR4.java @@ -10,6 +10,7 @@ import java.awt.geom.Point2D; import java.io.IOException; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -139,7 +140,10 @@ import org.mitre.synthea.engine.Components; import org.mitre.synthea.engine.Components.Attachment; +import org.mitre.synthea.export.rif.CodeMapper; import org.mitre.synthea.helpers.Config; +import org.mitre.synthea.helpers.RandomNumberGenerator; +import org.mitre.synthea.helpers.RandomValueGenerator; import org.mitre.synthea.helpers.SimpleCSV; import org.mitre.synthea.helpers.Utilities; import org.mitre.synthea.identity.Entity; @@ -404,7 +408,7 @@ public static Bundle convertToFHIR(Person person, long stopTime) { if (shouldExport(Condition.class)) { for (HealthRecord.Entry condition : encounter.conditions) { - condition(personEntry, bundle, encounterEntry, condition); + condition(person, personEntry, bundle, encounterEntry, condition); } } @@ -894,6 +898,16 @@ private static BundleEntryComponent encounter(Person person, BundleEntryComponen if (encounter.reason != null) { encounterResource.addReasonCode().addCoding().setCode(encounter.reason.code) .setDisplay(encounter.reason.display).setSystem(SNOMED_URI); + CodeMapper mapper = Exporter.getCodeMapper("ICD10-CM"); + if (mapper != null && mapper.canMap(encounter.reason.code)) { + Coding coding = new Coding(); + Map.Entry mappedCode = mapper.mapToCodeAndDescription( + encounter.reason, person); + coding.setCode(mappedCode.getKey()); + coding.setDisplay(mappedCode.getValue()); + coding.setSystem(ExportHelper.getSystemURI("ICD10-CM")); + encounterResource.getReasonCodeFirstRep().addCoding(coding); + } } Provider provider = encounter.provider; @@ -1607,6 +1621,7 @@ private static BundleEntryComponent explanationOfBenefit(BundleEntryComponent pe * @return The added Entry */ private static BundleEntryComponent condition( + RandomNumberGenerator rand, BundleEntryComponent personEntry, Bundle bundle, BundleEntryComponent encounterEntry, HealthRecord.Entry condition) { Condition conditionResource = new Condition(); @@ -1630,7 +1645,17 @@ private static BundleEntryComponent condition( conditionResource.setEncounter(new Reference(encounterEntry.getFullUrl())); Code code = condition.codes.get(0); - conditionResource.setCode(mapCodeToCodeableConcept(code, SNOMED_URI)); + CodeableConcept concept = mapCodeToCodeableConcept(code, SNOMED_URI); + CodeMapper mapper = Exporter.getCodeMapper("ICD10-CM"); + if (mapper != null && mapper.canMap(code)) { + Coding coding = new Coding(); + Map.Entry mappedCode = mapper.mapToCodeAndDescription(code, rand); + coding.setCode(mappedCode.getKey()); + coding.setDisplay(mappedCode.getValue()); + coding.setSystem(ExportHelper.getSystemURI("ICD10-CM")); + concept.addCoding(coding); + } + conditionResource.setCode(concept); CodeableConcept verification = new CodeableConcept(); verification.getCodingFirstRep() diff --git a/src/main/java/org/mitre/synthea/export/rif/CodeMapper.java b/src/main/java/org/mitre/synthea/export/rif/CodeMapper.java index b5e3ed4e3b..f7a1564f6b 100644 --- a/src/main/java/org/mitre/synthea/export/rif/CodeMapper.java +++ b/src/main/java/org/mitre/synthea/export/rif/CodeMapper.java @@ -5,6 +5,7 @@ import com.google.gson.reflect.TypeToken; import java.io.IOException; import java.lang.reflect.Type; +import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -86,6 +87,26 @@ public CodeMapper(String jsonMapResource) { } } + /** + * Merge the mappings from the supplied code mapper. This method performs a deep + * merge such that destination codes will be merged if this and other contain + * mappings for the same source code. Care is required with the use of weights + * such that this and other use the same weighting scale (e.g. 0-1) otherwise one + * set of mappings could overwhelm the other. + * @param other the other code mapper from which mappings will be copied. + */ + public void merge(CodeMapper other) { + if (other.hasMap()) { + other.map.forEach((source, mapped) -> { + if (map.containsKey(source)) { + map.get(source).addAll(mapped); + } else { + map.put(source, mapped); + } + }); + } + } + /** * Determines whether this mapper has an entry for the supplied code. * @param codeToMap the Synthea code to look for @@ -249,6 +270,24 @@ public String map(Code codeToMap, String bfdCodeType, RandomNumberGenerator rand return map(codeToMap.code, bfdCodeType, rand, stripDots); } + /** + * Get one of the BFD codes for the supplied Synthea code. Equivalent to + * {@code map(codeToMap, "code", rand)}. + * @param codeToMap the Synthea code to look for + * @param rand a source of random numbers used to pick one of the list of BFD codes + * @return the BFD code and display string or null if the code can't be mapped + */ + public SimpleEntry mapToCodeAndDescription(Code codeToMap, + RandomNumberGenerator rand) { + if (!canMap(codeToMap)) { + return null; + } + RandomCollection> options = map.get(codeToMap.code); + Map mappedCode = options.next(rand); + return new SimpleEntry<>(mappedCode.get("code"), + mappedCode.get("description")); + } + /** * Get the missing code as a list of maps, where each map includes the mapper name, a missing * code, a description, and the count of times the code was requested. diff --git a/src/main/java/org/mitre/synthea/helpers/RandomCollection.java b/src/main/java/org/mitre/synthea/helpers/RandomCollection.java index d2884d0c75..bcd49084bb 100644 --- a/src/main/java/org/mitre/synthea/helpers/RandomCollection.java +++ b/src/main/java/org/mitre/synthea/helpers/RandomCollection.java @@ -2,7 +2,6 @@ import java.io.Serializable; import java.util.Map.Entry; -import java.util.NavigableMap; import java.util.TreeMap; /** @@ -10,7 +9,7 @@ * gem. Adapted from https://stackoverflow.com/a/6409791/630384 */ public class RandomCollection implements Serializable { - private final NavigableMap map = new TreeMap(); + private final TreeMap map = new TreeMap(); private double total = 0; /** @@ -28,6 +27,18 @@ public void add(double weight, E result) { map.put(total, result); } + /** + * Add all of the entries from the supplied RandomCollection. + * @param other the collection from which to copy entries. + */ + public void addAll(RandomCollection other) { + Double weightAdj = 0.0; + for (Entry e: other.map.entrySet()) { + add(e.getKey() - weightAdj, e.getValue()); + weightAdj = e.getKey(); + } + } + /** * Select an item from the collection at random by the weight of the items. * Selecting an item from one draw, does not remove the item from the collection diff --git a/src/main/resources/synthea.properties b/src/main/resources/synthea.properties index 91ef1d2a26..008b3f9382 100644 --- a/src/main/resources/synthea.properties +++ b/src/main/resources/synthea.properties @@ -98,6 +98,12 @@ exporter.symptoms.text.export = false # enable searching for custom exporter implementations exporter.enable_custom_exporters = true +# enable mapping of Synthea-native code systems to others +# each key is for a specific code system and the values are comma separated paths to +# JSON mapping files +# see the CodeMapper class for code mapping file format and functionality +#exporter.code_map.icd10-cm=export/anti_amyloid_code_map.json + # the number of patients to generate, by default # this can be overridden by passing a different value to the Generator constructor generate.default_population = 1 diff --git a/src/test/java/org/mitre/synthea/export/rif/CodeMapperTest.java b/src/test/java/org/mitre/synthea/export/rif/CodeMapperTest.java index e077630d26..f5c5212abe 100644 --- a/src/test/java/org/mitre/synthea/export/rif/CodeMapperTest.java +++ b/src/test/java/org/mitre/synthea/export/rif/CodeMapperTest.java @@ -50,6 +50,25 @@ public void testWeightedCodeMapper() { assertTrue(defCount > abcCount); } + @Test + public void testCodeMapperMerge() { + Config.set("exporter.bfd.require_code_maps", "true"); + CodeMapper mapper = new CodeMapper("export/unweighted_code_map.json"); + mapper.merge(new CodeMapper("export/weighted_code_map.json")); + assertTrue(mapper.canMap("10509002")); + int abcCount = 0; + int defCount = 0; + for (int i = 0; i < 10; i++) { + String code = mapper.map("10509002", random); + if (code.equals("ABC20.9")) { + abcCount++; + } else if (code.equals("DEF20.9")) { + defCount++; + } + } + assertTrue(defCount > abcCount); + } + @Test(expected = MissingResourceException.class) public void testThrowsExceptionWhenMapFileMissing() { Config.set("exporter.bfd.require_code_maps", "true");