From 5f7623b9de400657a870c3201651aedc8bf1e97b Mon Sep 17 00:00:00 2001 From: elisa lee Date: Thu, 10 Oct 2024 11:28:12 -0500 Subject: [PATCH] Map single entry symptom responses to FHIR --- .../usds/simplereport/api/Translators.java | 45 +++++ .../api/converter/FhirConstants.java | 2 + .../api/converter/FhirConverter.java | 46 +++++- .../utils/BulkUploadResultsToFhir.java | 3 +- .../api/converter/FhirConverterTest.java | 33 +++- .../fhir/bundle-integration-testing.json | 49 +++++- .../resources/fhir/observationAllAoe.json | 38 +++++ .../resources/fhir/observationSymptom.json | 154 ++++++++++++++++++ 8 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 backend/src/test/resources/fhir/observationSymptom.json diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/Translators.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/Translators.java index 243d08114a..15b156851c 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/Translators.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/Translators.java @@ -378,6 +378,51 @@ public static String parseState(String s) { throw IllegalGraphqlArgumentException.invalidInput(s, "state"); } + private static final Map RESPIRATORY_SYMPTOMS = + Map.ofEntries( + Map.entry("426000000", "Fever over 100.4F"), + Map.entry("103001002", "Feeling feverish"), + Map.entry("43724002", "Chills"), + Map.entry("49727002", "Cough"), + Map.entry("267036007", "Shortness of breath"), + Map.entry("230145002", "Difficulty breathing"), + Map.entry("84229001", "Fatigue"), + Map.entry("68962001", "Muscle or body aches"), + Map.entry("25064002", "Headache"), + Map.entry("36955009", "New loss of taste"), + Map.entry("44169009", "New loss of smell"), + Map.entry("162397003", "Sore throat"), + Map.entry("68235000", "Nasal congestion"), + Map.entry("64531003", "Runny nose"), + Map.entry("422587007", "Nausea"), + Map.entry("422400008", "Vomiting"), + Map.entry("62315008", "Diarrhea"), + Map.entry("261665006", "Other symptom not listed")); + + private static final Map SYPHILIS_SYMPTOMS = + Map.ofEntries( + Map.entry("724386005", "Genital sore/lesion"), + Map.entry("195469007", "Anal sore/lesion"), + Map.entry("26284000", "Sore(s) in mouth/lips"), + Map.entry("266128007", "Body Rash"), + Map.entry("56940005", "Palmar (hand)/plantar (foot) rash"), + Map.entry("91554004", "Flat white warts"), + Map.entry("15188001", "Hearing loss"), + Map.entry("246636008", "Blurred vision"), + Map.entry("56317004", "Alopecia")); + + private static Map getSupportedSymptoms() { + Map supportedSymptoms = new HashMap<>(); + supportedSymptoms.putAll(RESPIRATORY_SYMPTOMS); + supportedSymptoms.putAll(SYPHILIS_SYMPTOMS); + return supportedSymptoms; + } + + public static String getSymptomName(String snomedCode) { + Map supportedSymptoms = getSupportedSymptoms(); + return supportedSymptoms.get(snomedCode); + } + public static Map parseSymptoms(String symptoms) { if (symptoms == null) { return Collections.emptyMap(); diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConstants.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConstants.java index b85b88bdc3..71533afddf 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConstants.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConstants.java @@ -51,6 +51,8 @@ private FhirConstants() { public static final String LOINC_AOE_IDENTIFIER = "81959-9"; public static final String LOINC_AOE_SYMPTOMATIC = "95419-8"; + public static final String LOINC_SYMPTOM_TIMING_PANEL = "99582-9"; + public static final String LOINC_SYMPTOM = "75325-1"; public static final String LOINC_AOE_SYMPTOM_ONSET = "11368-8"; public static final String LOINC_AOE_PREGNANCY_STATUS = "82810-3"; public static final String LOINC_AOE_EMPLOYED_IN_HEALTHCARE = "95418-0"; diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConverter.java b/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConverter.java index 87862a1380..fafda85a4c 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConverter.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/api/converter/FhirConverter.java @@ -10,6 +10,7 @@ import static gov.cdc.usds.simplereport.api.Translators.REFUSED; import static gov.cdc.usds.simplereport.api.Translators.TRANS_MAN; import static gov.cdc.usds.simplereport.api.Translators.TRANS_WOMAN; +import static gov.cdc.usds.simplereport.api.Translators.getSymptomName; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.ABNORMAL_FLAGS_CODE_SYSTEM; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.ABNORMAL_FLAG_ABNORMAL; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.ABNORMAL_FLAG_NORMAL; @@ -34,6 +35,8 @@ import static gov.cdc.usds.simplereport.api.converter.FhirConstants.LOINC_AOE_SYMPTOM_ONSET; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.LOINC_CODE_SYSTEM; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.LOINC_GENDER_IDENTITY; +import static gov.cdc.usds.simplereport.api.converter.FhirConstants.LOINC_SYMPTOM; +import static gov.cdc.usds.simplereport.api.converter.FhirConstants.LOINC_SYMPTOM_TIMING_PANEL; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.NOTE_TYPE_CODING_SYSTEM; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.NOTE_TYPE_CODING_SYSTEM_CODE; import static gov.cdc.usds.simplereport.api.converter.FhirConstants.NOTE_TYPE_CODING_SYSTEM_CODE_INDEX_EXTENSION_URL; @@ -114,12 +117,15 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.TimeZone; import java.util.UUID; import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -802,7 +808,7 @@ private void addCorrectionNote( } } - public Set convertToAOESymptomObservation( + public Set convertToAOESymptomaticObservation( String eventId, Boolean symptomatic, LocalDate symptomOnsetDate) { var observations = new LinkedHashSet(); var symptomaticCode = @@ -827,6 +833,33 @@ public Set convertToAOESymptomObservation( return observations; } + public Observation createSymptomObservation(CodeableConcept code, Type value) { + Observation observation = + new Observation().setStatus(ObservationStatus.FINAL).setCode(code).setValue(value); + observation.setId(uuidGenerator.randomUUID().toString()); + observation + .addIdentifier() + .setUse(IdentifierUse.OFFICIAL) + .setType(createLoincConcept(LOINC_SYMPTOM_TIMING_PANEL, "Symptom and timing panel", null)); + return observation; + } + + public Set convertToSymptomsObservations(List symptoms) { + HashSet observations = new HashSet<>(); + + CodeableConcept symptomStatusCode = createLoincConcept(LOINC_SYMPTOM, "Symptom", "Symptom"); + + symptoms.forEach( + symptom -> { + String symptomName = getSymptomName(symptom); + if (symptomName != null && !symptomName.isBlank()) { + CodeableConcept symptomValueCode = createSNOMEDConcept(symptom, symptomName, null); + observations.add(createSymptomObservation(symptomStatusCode, symptomValueCode)); + } + }); + return observations; + } + public Set convertToAOEGenderOfSexualPartnersObservation( Set sexualPartners) { HashSet observations = new LinkedHashSet<>(); @@ -972,10 +1005,12 @@ public Set convertToAOEObservations( } else if (surveyData.getSymptoms() != null && surveyData.getSymptoms().containsValue(Boolean.TRUE)) { symptomatic = true; + List positiveSymptoms = getPositiveSymptoms(surveyData.getSymptoms()); + observations.addAll(convertToSymptomsObservations(positiveSymptoms)); } // implied else: AoE form was not completed. Symptomatic set to null var symptomOnsetDate = surveyData.getSymptomOnsetDate(); - observations.addAll(convertToAOESymptomObservation(eventId, symptomatic, symptomOnsetDate)); + observations.addAll(convertToAOESymptomaticObservation(eventId, symptomatic, symptomOnsetDate)); String pregnancyStatus = surveyData.getPregnancy(); if (pregnancyStatus != null && pregnancyStatusSnomedMap.values().contains(pregnancyStatus)) { @@ -1616,6 +1651,13 @@ private Patient assignPotentiallyAbsentName( return patient; } + private List getPositiveSymptoms(Map symptomsMap) { + return symptomsMap.entrySet().stream() + .filter(symptom -> Boolean.TRUE.equals(symptom.getValue())) + .map(Entry::getKey) + .collect(Collectors.toList()); + } + public Bundle createFhirBundle(ConditionAgnosticCreateFhirBundleProps props) { var patientFullUrl = ResourceType.Patient + "/" + props.getPatient().getId(); diff --git a/backend/src/main/java/gov/cdc/usds/simplereport/utils/BulkUploadResultsToFhir.java b/backend/src/main/java/gov/cdc/usds/simplereport/utils/BulkUploadResultsToFhir.java index 77b1b47aea..0a8997e076 100644 --- a/backend/src/main/java/gov/cdc/usds/simplereport/utils/BulkUploadResultsToFhir.java +++ b/backend/src/main/java/gov/cdc/usds/simplereport/utils/BulkUploadResultsToFhir.java @@ -458,7 +458,8 @@ private Bundle convertRowToFhirBundle(TestResultRow row, UUID orgId) { } aoeObservations.addAll( - fhirConverter.convertToAOESymptomObservation(testEventId, symptomatic, symptomOnsetDate)); + fhirConverter.convertToAOESymptomaticObservation( + testEventId, symptomatic, symptomOnsetDate)); String pregnancyValue = row.getPregnant().getValue(); if (StringUtils.isNotBlank(pregnancyValue)) { diff --git a/backend/src/test/java/gov/cdc/usds/simplereport/api/converter/FhirConverterTest.java b/backend/src/test/java/gov/cdc/usds/simplereport/api/converter/FhirConverterTest.java index ec4e2f7f72..959039efa3 100644 --- a/backend/src/test/java/gov/cdc/usds/simplereport/api/converter/FhirConverterTest.java +++ b/backend/src/test/java/gov/cdc/usds/simplereport/api/converter/FhirConverterTest.java @@ -1026,7 +1026,7 @@ void convertToAoeObservation_noSymptoms_matchesJson() throws IOException { AskOnEntrySurvey.builder() .pregnancy(null) .syphilisHistory(null) - .symptoms(Map.of("fake", false)) + .symptoms(Map.of("195469007", true)) .noSymptoms(true) .symptomOnsetDate(null) .genderOfSexualPartners(null) @@ -1270,6 +1270,35 @@ void convertToAoeObservation_syphilisHistory_matchesJson() throws IOException { JSONAssert.assertEquals(expectedSerialized, actualSerialized, true); } + @Test + void convertToAoeObservation_symptoms_matchesJson() throws IOException { + AskOnEntrySurvey answers = + AskOnEntrySurvey.builder() + .pregnancy(null) + .syphilisHistory(null) + .symptoms( + Map.of("36955009", true, "261665006", false, "68962001", true, "195469007", true)) + .genderOfSexualPartners(null) + .symptomOnsetDate(null) + .noSymptoms(false) + .build(); + + String testId = "fakeId"; + + Set actual = + fhirConverter.convertToAOEObservations( + testId, answers, new Person("first", "last", "middle", "suffix", null)); + + String actualSerialized = + actual.stream().map(parser::encodeResourceToString).collect(Collectors.toSet()).toString(); + String expectedSerialized = + IOUtils.toString( + Objects.requireNonNull( + getClass().getClassLoader().getResourceAsStream("fhir/observationSymptom.json")), + StandardCharsets.UTF_8); + JSONAssert.assertEquals(expectedSerialized, actualSerialized, true); + } + @Test void convertToAoeObservation_allAOE_matchesJson() throws IOException { List sexualPartners = List.of("male", "female"); @@ -1277,7 +1306,7 @@ void convertToAoeObservation_allAOE_matchesJson() throws IOException { AskOnEntrySurvey.builder() .pregnancy(PREGNANT_UNKNOWN_SNOMED) .syphilisHistory(NO_SYPHILIS_HISTORY_SNOMED) - .symptoms(Map.of("fake", true)) + .symptoms(Map.of("36955009", true)) .symptomOnsetDate(LocalDate.of(2023, 3, 4)) .genderOfSexualPartners(sexualPartners) .noSymptoms(false) diff --git a/backend/src/test/resources/fhir/bundle-integration-testing.json b/backend/src/test/resources/fhir/bundle-integration-testing.json index daed53bdca..ee88857044 100644 --- a/backend/src/test/resources/fhir/bundle-integration-testing.json +++ b/backend/src/test/resources/fhir/bundle-integration-testing.json @@ -379,6 +379,9 @@ { "reference": "Observation/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }, + { + "reference": "Observation/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + }, { "reference": "Observation/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } @@ -889,6 +892,50 @@ ] } } + }, + { + "fullUrl": "Observation/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "resource": { + "resourceType": "Observation", + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "99582-9", + "display": "Symptom and timing panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "75325-1", + "display": "Symptom" + } + ], + "text": "Symptom" + }, + "subject": { + "reference": "Patient/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "84229001", + "display": "Fatigue" + } + ] + } + } } ] -} \ No newline at end of file +} diff --git a/backend/src/test/resources/fhir/observationAllAoe.json b/backend/src/test/resources/fhir/observationAllAoe.json index fe953f45b9..b2dbfb9d6c 100644 --- a/backend/src/test/resources/fhir/observationAllAoe.json +++ b/backend/src/test/resources/fhir/observationAllAoe.json @@ -1,4 +1,42 @@ [ + { + "resourceType": "Observation", + "id": "5db534ea-5e97-4861-ba18-d74acc46db15", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "99582-9", + "display": "Symptom and timing panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "75325-1", + "display": "Symptom" + } + ], + "text": "Symptom" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "36955009", + "display": "New loss of taste" + } + ] + } + }, { "resourceType": "Observation", "id": "8526bd3b-42e4-3a6f-88ff-3fc43b69dc20", diff --git a/backend/src/test/resources/fhir/observationSymptom.json b/backend/src/test/resources/fhir/observationSymptom.json new file mode 100644 index 0000000000..52231c5f5e --- /dev/null +++ b/backend/src/test/resources/fhir/observationSymptom.json @@ -0,0 +1,154 @@ +[ + { + "resourceType": "Observation", + "id": "5db534ea-5e97-4861-ba18-d74acc46db15", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "99582-9", + "display": "Symptom and timing panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "75325-1", + "display": "Symptom" + } + ], + "text": "Symptom" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "36955009", + "display": "New loss of taste" + } + ] + } + }, + { + "resourceType": "Observation", + "id": "5f3026ea-845b-3649-8b62-d3dbfd3e7b62", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "81959-9", + "display": "Public health laboratory ask at order entry panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "95419-8", + "display": "Has symptoms related to condition of interest" + } + ], + "text": "Has symptoms related to condition of interest" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/ValueSet/v2-0136", + "code": "Y", + "display": "Yes" + } + ] + } + }, + { + "resourceType": "Observation", + "id": "5db534ea-5e97-4861-ba18-d74acc46db15", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "99582-9", + "display": "Symptom and timing panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "75325-1", + "display": "Symptom" + } + ], + "text": "Symptom" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "68962001", + "display": "Muscle or body aches" + } + ] + } + }, + { + "resourceType": "Observation", + "id": "5db534ea-5e97-4861-ba18-d74acc46db15", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "99582-9", + "display": "Symptom and timing panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "75325-1", + "display": "Symptom" + } + ], + "text": "Symptom" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "195469007", + "display": "Anal sore/lesion" + } + ] + } + } +] \ No newline at end of file