1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.healthconnect.cts.phr.utils;
18 
19 import android.net.Uri;
20 
21 import androidx.annotation.NonNull;
22 import androidx.annotation.Nullable;
23 
24 import org.json.JSONArray;
25 import org.json.JSONException;
26 import org.json.JSONObject;
27 
28 import java.util.LinkedHashMap;
29 import java.util.List;
30 import java.util.Map;
31 
32 /**
33  * A helper class that supports making FHIR Observations data for tests.
34  *
35  * <p>The Default result will be a valid FHIR Observation, but that is all that should be relied
36  * upon. Anything else that is relied upon by a test should be set by one of the methods.
37  */
38 public final class ObservationBuilder extends FhirResourceBuilder<ObservationBuilder> {
39 
40     /** URI representing the LOINC coding system. */
41     public static final Uri LOINC = Uri.parse("http://loinc.org");
42 
43     /** URI representing the SNOMED coding system. */
44     public static final Uri SNOMED_CT = Uri.parse("http://snomed.info/sct");
45 
46     /**
47      * Values from the default <a
48      * href="http://terminology.hl7.org/CodeSystem/observation-category">FHIR value set</a> for
49      * observation category.
50      */
51     public enum ObservationCategory {
52         SOCIAL_HISTORY("social-history", "Social History"),
53         VITAL_SIGNS("vital-signs", "Vital Signs"),
54         IMAGING("imaging", "Imaging"),
55         LABORATORY("laboratory", "Laboratory"),
56         PROCEDURE("procedure", "Procedure"),
57         SURVEY("survey", "Survey"),
58         EXAM("exam", "Exam"),
59         THERAPY("therapy", "Therapy"),
60         ACTIVITY("activity", "Activity"),
61         ;
62         private final String mCode;
63         private final String mDisplay;
64 
ObservationCategory(String code, String display)65         ObservationCategory(String code, String display) {
66             mCode = code;
67             mDisplay = display;
68         }
69 
getCode()70         public String getCode() {
71             return mCode;
72         }
73 
getDisplay()74         public String getDisplay() {
75             return mDisplay;
76         }
77 
78         /**
79          * Returns a FHIR <a
80          * href="https://www.hl7.org/fhir/R4/datatypes.html#CodeableConcept">CodeableConcept</a>
81          * version of this category.
82          */
toFhirCodeableConcept()83         public JSONObject toFhirCodeableConcept() {
84             return makeCodeableConcept(
85                     Uri.parse("http://terminology.hl7.org/CodeSystem/observation-category"),
86                     getCode(),
87                     getDisplay());
88         }
89     }
90 
91     /** Various common units from http://unitsofmeasure.org. Not complete. */
92     public enum QuantityUnits {
93         COUNT("1", "1"),
94         PERCENT("%", "%"),
95         MMOL_PER_L("mmol/l", "mmol/l"),
96         BREATHS_PER_MINUTE("breaths/minute", "/min"),
97         BEATS_PER_MINUTE("beats/minute", "/min"),
98         CELSIUS("C", "Cel"),
99         CENTIMETERS("cm", "cm"),
100         KILOGRAMS("kg", "kg"),
101         POUNDS("lbs", "[lb_av]"),
102         KILOGRAMS_PER_M2("kg/m2", "kg/m2"),
103         MILLIMETERS_OF_MERCURY("mmHg", "mm[Hg]"),
104         GLASSES_OF_WINE_PER_DAY("wine glasses per day", "/d"),
105         ;
106 
107         private final String mUnit;
108         private final String mUnitCode;
109 
QuantityUnits(String unit, String unitCode)110         QuantityUnits(String unit, String unitCode) {
111             mUnit = unit;
112             mUnitCode = unitCode;
113         }
114 
getUnit()115         public String getUnit() {
116             return mUnit;
117         }
118 
getUnitCode()119         public String getUnitCode() {
120             return mUnitCode;
121         }
122 
123         /**
124          * Returns a JSON Object representing a <a
125          * href="https://hl7.org/fhir/R4/datatypes.html#Quantity">Quantity</a>, in these units..
126          *
127          * @param value the unitless value for the quantity
128          * @throws JSONException if any JSON problem occurs.
129          */
makeFhirQuantity(Number value)130         public JSONObject makeFhirQuantity(Number value) throws JSONException {
131             LinkedHashMap<String, Object> contents = new LinkedHashMap<>();
132             contents.put("value", value);
133             contents.put("unit", mUnit);
134             contents.put("system", "http://unitsofmeasure.org");
135             contents.put("code", mUnitCode);
136             return new JSONObject(contents);
137         }
138     }
139 
140     /**
141      * Representation of the International Patient Summary recommended <a
142      * href="https://build.fhir.org/ig/HL7/fhir-ips/ValueSet-pregnancy-status-uv-ips.html">ValueSet</a>
143      * for pregnancy status.
144      */
145     public enum PregnancyStatus {
146         PREGNANT("77386006"),
147         NOT_PREGNANT("60001007"),
148         PREGNANCY_NOT_CONFIRMED("152231000119106"),
149         POSSIBLE_PREGNANCY("146799005"),
150         ;
151         private final String mSnomedCode;
152 
PregnancyStatus(String snomedCode)153         PregnancyStatus(String snomedCode) {
154             mSnomedCode = snomedCode;
155         }
156 
getSnomedCode()157         public String getSnomedCode() {
158             return mSnomedCode;
159         }
160     }
161 
162     /**
163      * Representation of the International Patient Summary recommended <a
164      * href="https://build.fhir.org/ig/HL7/fhir-ips/ValueSet-current-smoking-status-uv-ips.html">ValueSet</a>
165      * for current smoking status.
166      */
167     public enum CurrentSmokingStatus {
168         SMOKES_TOBACCO_DAILY("449868002"),
169         OCCASIONAL_TOBACCO_SMOKER("428041000124106"),
170         EX_SMOKER("8517006"),
171         NEVER_SMOKED_TOBACCO("266919005"),
172         SMOKER("77176002"),
173         TOBACCO_SMOKING_CONSUMPTION_UNKNOWN("266927001"),
174         HEAVY_CIGARETTE_SMOKER("230063004"),
175         LIGHT_CIGARETTE_SMOKER("230060001"),
176         ;
177         private final String mSnomedCode;
178 
CurrentSmokingStatus(String snomedCode)179         CurrentSmokingStatus(String snomedCode) {
180             mSnomedCode = snomedCode;
181         }
182 
getSnomedCode()183         public String getSnomedCode() {
184             return mSnomedCode;
185         }
186     }
187 
188     private static final String DEFAULT_JSON =
189             "{"
190                     + "  \"resourceType\": \"Observation\","
191                     + "  \"id\": \"f001\","
192                     + "  \"identifier\": ["
193                     + "    {"
194                     + "      \"use\": \"official\","
195                     + "      \"system\": \"http://www.bmc.nl/zorgportal/identifiers/observations\","
196                     + "      \"value\": \"6323\""
197                     + "    }"
198                     + "  ],"
199                     + "  \"status\": \"final\","
200                     + "  \"subject\": {"
201                     + "    \"reference\": \"Patient/f001\","
202                     + "    \"display\": \"A. Lincoln\""
203                     + "  },"
204                     + "  \"effectivePeriod\": {"
205                     + "    \"start\": \"2013-04-02T09:30:10+01:00\""
206                     + "  },"
207                     + "  \"issued\": \"2013-04-03T15:30:10+01:00\","
208                     + "  \"performer\": ["
209                     + "    {"
210                     + "      \"reference\": \"Practitioner/f005\","
211                     + "      \"display\": \"G. Washington\""
212                     + "    }"
213                     + "  ]"
214                     + "}";
215 
216     /**
217      * Creates a default valid FHIR Observation.
218      *
219      * <p>All that should be relied on is that the Observation is valid. To rely on anything else
220      * set it with the other methods.
221      */
ObservationBuilder()222     public ObservationBuilder() {
223         super(DEFAULT_JSON);
224         setBloodGlucose(6.3);
225     }
226 
227     /**
228      * Sets the category for this observation.
229      *
230      * @return this builder.
231      */
setCategory(ObservationCategory category)232     public ObservationBuilder setCategory(ObservationCategory category) {
233         return set("category", new JSONArray(List.of(category.toFhirCodeableConcept())));
234     }
235 
236     /** Sets this observation to represent a default blood glucose observation. */
setBloodGlucose()237     public ObservationBuilder setBloodGlucose() {
238         return setBloodGlucose(6.3);
239     }
240 
241     /**
242      * Sets this observation to represent a blood glucose observation.
243      *
244      * @param mmolPerLitre the measurement of blood glucose in mmol/liter.
245      */
setBloodGlucose(double mmolPerLitre)246     public ObservationBuilder setBloodGlucose(double mmolPerLitre) {
247         return setBloodGlucose(mmolPerLitre, 3.1, 6.2);
248     }
249 
250     /**
251      * Sets this observation to represent blood glucose observation, including whether it is high,
252      * low or normal.
253      *
254      * @param mmolPerLitre the measurement of blood glucose in mmol/liter.
255      * @param lowBoundary the upper limit for a low reading (exclusive) in mmol/liter
256      * @param highBoundary the lower limit for a high reading (exclusive) in mmol/liter
257      */
setBloodGlucose( double mmolPerLitre, double lowBoundary, double highBoundary)258     public ObservationBuilder setBloodGlucose(
259             double mmolPerLitre, double lowBoundary, double highBoundary) {
260         // See https://terminology.hl7.org/6.0.2/CodeSystem-v3-ObservationInterpretation.html
261         // for these codes.
262         String code;
263         String display;
264         if (mmolPerLitre > highBoundary) {
265             code = "H";
266             display = "High";
267         } else if (mmolPerLitre < lowBoundary) {
268             code = "L";
269             display = "Low";
270         } else {
271             code = "N";
272             display = "Normal";
273         }
274         try {
275             setCode(LOINC, "15074-8");
276             set("valueQuantity", QuantityUnits.MMOL_PER_L.makeFhirQuantity(mmolPerLitre));
277             JSONObject interpretation =
278                     makeCodeableConcept(
279                             Uri.parse(
280                                     "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation"),
281                             code,
282                             display);
283             set("interpretation", new JSONArray(List.of(interpretation)));
284             JSONObject range =
285                     new JSONObject(
286                             Map.of(
287                                     "low",
288                                     QuantityUnits.MMOL_PER_L.makeFhirQuantity(lowBoundary),
289                                     "high",
290                                     QuantityUnits.MMOL_PER_L.makeFhirQuantity(highBoundary)));
291             set("referenceRange", new JSONArray(List.of(range)));
292 
293         } catch (JSONException e) {
294             throw new IllegalArgumentException(e);
295         }
296         return this;
297     }
298 
299     /**
300      * Set pregnancy status as recommended by the <a
301      * href="https://build.fhir.org/ig/HL7/fhir-ips/StructureDefinition-Observation-pregnancy-status-uv-ips.html">Internation
302      * Patient Summary</a>.
303      *
304      * @return this builder
305      */
setPregnancyStatus(PregnancyStatus status)306     public ObservationBuilder setPregnancyStatus(PregnancyStatus status) {
307         setCode(LOINC, "82810-3");
308         setValueCodeableConcept(SNOMED_CT, status.getSnomedCode());
309         return this;
310     }
311 
312     /**
313      * Set tobacco use as recommended by the <a
314      * href="https://build.fhir.org/ig/HL7/fhir-ips/StructureDefinition-Observation-tobaccouse-uv-ips.html">Internation
315      * Patient Summary</a>.
316      *
317      * @return this builder
318      */
setTobaccoUse(CurrentSmokingStatus status)319     public ObservationBuilder setTobaccoUse(CurrentSmokingStatus status) {
320         setCategory(ObservationCategory.SOCIAL_HISTORY);
321         setCode(LOINC, "72166-2");
322         setValueCodeableConcept(SNOMED_CT, status.getSnomedCode());
323         return this;
324     }
325 
326     /**
327      * Set that a heart rate in beats per minute was observed. Uses the <a
328      * href="https://hl7.org/fhir/R5/observation-vitalsigns.html">IPS Vital Signs</a>
329      * recommendations.
330      *
331      * @return this builder
332      */
setHeartRate(int beatsPerMinute)333     public ObservationBuilder setHeartRate(int beatsPerMinute) {
334         setCategory(ObservationCategory.VITAL_SIGNS);
335         setCode(LOINC, "8867-4", "Heart rate");
336         setValueQuantity(beatsPerMinute, QuantityUnits.BEATS_PER_MINUTE);
337         return this;
338     }
339 
340     /**
341      * Sets the code for the observation.
342      *
343      * @param system the Uri for the coding system the code comes from
344      * @return this builder
345      */
setCode(@onNull Uri system, @NonNull String code)346     public ObservationBuilder setCode(@NonNull Uri system, @NonNull String code) {
347         return setCode(system, code, /* display= */ null);
348     }
349 
350     /**
351      * Sets the code for the observation.
352      *
353      * @param system the Uri for the coding system the code comes from
354      * @param display A human readable display value for the code
355      * @return this builder
356      */
setCode( @onNull Uri system, @NonNull String code, @Nullable String display)357     public ObservationBuilder setCode(
358             @NonNull Uri system, @NonNull String code, @Nullable String display) {
359         return set("code", makeCodeableConcept(system, code, display));
360     }
361 
362     /**
363      * Set the value for an observation where the value should be represented as a codeable concept
364      * with a single code.
365      *
366      * @param code the code for the value
367      * @param system the coding system for the value
368      * @return this builder
369      */
setValueCodeableConcept(@onNull Uri system, @NonNull String code)370     public ObservationBuilder setValueCodeableConcept(@NonNull Uri system, @NonNull String code) {
371         return removeAllValueMultiTypeFields()
372                 .set(
373                         "valueCodeableConcept",
374                         makeCodeableConcept(system, code, /* display= */ null));
375     }
376 
377     /**
378      * Set the value for an observation where the value should be represented as a quantity in some
379      * units.
380      *
381      * @return this builder
382      */
setValueQuantity(Number quantity, QuantityUnits units)383     public ObservationBuilder setValueQuantity(Number quantity, QuantityUnits units) {
384         try {
385             return removeAllValueMultiTypeFields()
386                     .set("valueQuantity", units.makeFhirQuantity(quantity));
387         } catch (JSONException e) {
388             throw new IllegalArgumentException(e);
389         }
390     }
391 
392     /**
393      * Removes all fields that are part of the effective[x] multi type field, such as
394      * effectiveDateTime and effectivePeriod.
395      *
396      * <p>This should be used before setting one of these fields, as only one is allowed to be set.
397      *
398      * @return this builder
399      */
removeAllEffectiveMultiTypeFields()400     public ObservationBuilder removeAllEffectiveMultiTypeFields() {
401         return removeField("effectiveDateTime")
402                 .removeField("effectivePeriod")
403                 .removeField("effectiveTiming")
404                 .removeField("effectiveInstant");
405     }
406 
407     /**
408      * Removes all fields that are part of the value[x] multi type field, such as valueQuantity and
409      * valueCodeableConcept.
410      *
411      * <p>This should be used before setting one of these fields, as only one is allowed to be set.
412      *
413      * @return this builder
414      */
removeAllValueMultiTypeFields()415     public ObservationBuilder removeAllValueMultiTypeFields() {
416         return removeField("valueQuantity")
417                 .removeField("valueCodeableConcept")
418                 .removeField("valueString")
419                 .removeField("valueBoolean")
420                 .removeField("valueInteger")
421                 .removeField("valueRange")
422                 .removeField("valueRatio")
423                 .removeField("valueSampledData")
424                 .removeField("valueTime")
425                 .removeField("valueDateTime")
426                 .removeField("valuePeriod");
427     }
428 
429     /**
430      * Returns a JSON Object representing a FHIR <a
431      * href="https://www.hl7.org/fhir/R4/datatypes.html#CodeableConcept">CodeableConcept</a>.
432      *
433      * @param system the coding system for the value.
434      * @param code the code for the value
435      * @param display if non-null a display string for the value
436      */
437     @NonNull
makeCodeableConcept( @onNull Uri system, @NonNull String code, @Nullable String display)438     public static JSONObject makeCodeableConcept(
439             @NonNull Uri system, @NonNull String code, @Nullable String display) {
440         LinkedHashMap<String, String> content = new LinkedHashMap<>();
441         content.put("system", system.toString());
442         content.put("code", code);
443         if (display != null) {
444             content.put("display", display);
445         }
446         return new JSONObject(Map.of("coding", new JSONArray(List.of(new JSONObject(content)))));
447     }
448 
449     @Override
returnThis()450     protected ObservationBuilder returnThis() {
451         return this;
452     }
453 }
454