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