Age-Sensitive Ranges

Hey all, here’s my summary of the platform meeting today.

Firstly, we can break down this feature into two separate sub-features:

  1. Allowing multiple numerics for a concept, each defined by some set of factors.
  2. Flagging critical observational data against the numerics that apply to that patient.

I’ll cover a multi-step approach we can take to implement both, and we should be able to complete sub-feature 1 without breaking any existing functionality before moving on to 2.

First off, I refined my original designs based on our discussion on the call. I’ve also put in some example data to help visualize what this could look like:

To summarize the example setup in the above graph (using Systolic blood pressure), lets say we have the following patients:

  • Patient A is Female, aged 20
  • Patient B is Female, aged 50
  • Patient C is Male, aged 20

We have the following factor groups (that each map to their own numeric):

  • Group 0: Female, aged 10-30 → (critical range 175-75, normal range 135-95)
  • Group 1: Female (no age restrictions) → (critical range 185-70, normal range 150-75)

So, implementing the above data structure (and its relevant APIs) would give us feature 1, but we still need feature 2, aka a way to actually decide which of the numerics we want to have apply when making a patient observation.

Using the above data again, that should look like:

  • Patient A will map to factor group 0 and 1
  • Patient B will map to factor group 1
  • Patient C will map to no factor group and so will use the numeric defined in the old concept_numeric table

Currently in O3, the flagging of critical observational data is done using the concept_numeric table (thanks Grace for the demo!):

If an entered observation is in the critical range for a concept (as defined by the hi_critical and low_critical columns in concept_numeric), it’ll hilight the observation in red (In the above you can see that BP, Heart Rate and R. Rate are all at critical levels). Obviously this will need to change to account for the new concept_numeric_factored table but for the time being we can introduce feature 1 without breaking this existing functionality.

Now, onto actually implementing feature 2.

First, we need a way to define how to find a factor (such as age) in a patient’s data. I was hoping that all this data would be stored dynamically in some generic “PatientAttribute” table but it seems that’s not the case. (That table would have one row for each attribute of the patient (birthdate, sex, ethnicity…). It’d make looking up attributes and adding new ones easy because we’d just key into the same generic table, but alas, one can dream).

In reality, factors can come from multiple different sources. Ex: As Daniel and Ian pointed out, age and sex are stored in the “person” table (under the gender and birthdate fields), and ethnicity is somewhere else. For fields like these that are represented as specific columns in some table I’d recommend the following solution:

Notice the “validator” column I have in the numeric_factor table above. Here we can define the Java class name of a validator for that type of field (Ex: MinAgeFactorValidator). This validator will be coded to know the db table and column that we need to look up to find the patient’s value for that attribute/factor (so for sex, it’d know to go to “patient.gender”). It’d also have a method that returns true if a patient matches a given factor (Ex: True for a person aged 30 and a factor: min_age=10). So when checking if a patient matches a factor, we simply de-reference the validator by class name, then run its validate method.

OMRS actually already does something similar to this with the PatientIdentifierType table. See openmrs-core/api/src/main/java/org/openmrs/PatientIdentifierType.java at 11cbda0a208b5b287c76b265865005108b27d780 · openmrs/openmrs-core · GitHub and openmrs-core/api/src/main/java/org/openmrs/api/impl/PatientServiceImpl.java at 11cbda0a208b5b287c76b265865005108b27d780 · openmrs/openmrs-core · GitHub.

Obviously it’s not a perfect solution because it requires us to code up a validator for each new type of factor that we want to add but I think for now at least it’s the path of least resistance. Later we can look into abstracting this further to the point of not needing these custom validators.

Also important, I think there’s more to be discussed about what happens when a patient matches multiple numerics (in the above example, Patient A meets the conditions of factor group 0 AND 1). Some solutions for validation in that case that we discussed:

  1. Choose whatever factor group is the most restrictive (ie has the most number of factors) and break ties with some “priority” column
  2. Take the lowest and highest values of all the numerics the patient matches and use those (ie if one numeric has a critical range 20-50 and another has a range 10-40, you’d use a critical range of 10-50)
  3. Validate against all the numerics the user matches with and return the most critical condition (ie if a patient has a value 40, and one numeric has a normal range 30-45 and another has a critical range of 38-50, we’d return that the patient is critical)

I’m personally in favour of the 3rd approach since I feel like it’s the most clear. In the UI we could also potentially display all the numerics the patient matches and show which ones they have critical levels for.

As a side note: I’d also take the concept_numeric_factored table a step further and split the ranges into their own table, concept_numeric_range. That way we can avoid hardcoding the individual ranges as columns like we do in the concept_numeric table (hi_critical, low_critical…). This just makes it more flexible if we ever want to add another range in since all we’d need is more rows in the concept_numeric_range, and not new columns in concept_numeric_factored.

This is just a side thought though, not essential to the implementation.

Some other notes:

  • As Grace mentioned, currently ranges are always inclusive (meaning a range 30-40 includes values 30 and 40).
  • We can retain the existing functionality in the concept_numeric table for defining absolute min and max values for entering oservation data (Ex: throw an error if you try to enter a negative heartrate).
  • We’ll likely need to be able to define age factors on both a weekly and yearly scale so that we can track numerics for newborns so the solution might need to change slightly to account for this. We could introduce “min/max_age_weeks” factors with their own validators that will count the age in weeks instead of years. It’ll need some thought.
  • For future iterations it would be good to add support for "OR"ing the factors of a factor group together to allow more complex groups.

Thanks all! And apologies, it seems I’m incapable of being brief on this topic haha

3 Likes

Another important consideration that was brought up by the PIH guys Mark and Michael in this morning’s TAC call is that we need to track which numerics are applied at the moment of observation. Ian and Burke had touched on this as well previously in the thread.

Because the hilighting that we do on observations (if a patient’s data is in the critical range) is re-computed whenever we load an observation, if we don’t track the numeric(s) we used at the time the observation was logged, we risk that hilighting changing as patient attributes change.

For example: We have numeric A which maps to pregnancy status=true and numeric B which maps to pregnancy status=false. If a patient is pregnant at the time of observation, their data is validated against numeric A. But since we validate dynamically, if a clinician loads that observation some time later, after the patient is no longer pregnant, we’ll now validate against numeric B.

This clarifies for me the FHIR approach of reporting the reference range as a part of the observation. (Ian, I think you had already determined this so it looks like I’m late to the party figuring that one out.)

Depending on the solution we choose for our warning logic, when logging an observation we’ll want that observation to store a link back to one or more factor_group_ids so that we can de-reference the appropriate numerics when loading the observation.

Ian makes a good point here. If an observation is being imported from an external source, we’ll need to consider how to reference the correct numerics. I’m less familiar with this avenue of logging observations though and the expected structure of those observations.

Also, just to note about my proposed solution above, the only tables that are essential to the design are concept_numeric_factored and numeric_factor_group. The numeric_factor and numeric_factor_value tables could theoretically be stored as enums in the code, it’s just a more rigid solution doing it that way vs having them be data driven. Still, it could be doable as a portotype/v1 type solution if the full solution feels a little unwieldy to tackle all at once.

2 Likes

Speaking of logging - we should have a way of capturing / logging what the logic was at the time that information was displayed. So for example if there was an error or a different practice guideline (acceptable range) in 2024, but in 2025 the criteria is different, a malpractice/harms investigation team would be able to see “Ah, that vital sign was / was not flagged as high/low/critical at that time.” Make sense?

2 Likes

One way to approach that is to log the hard code into the record the reference ranges used at the time of the information being displayed/decision being made. That’s the safest. A lesser intensive way is to permanently “log” the reference ranges over time and just use that as your meta data for your logging around the decision made. Is there another better way?

1 Like

IIUC, an important update on this topic is that @dkayiwa has our Platform team member @isaiahmuli working on this very important issue of Age-Sensitive warning ranges. Is this correct?

our Platform team member @isaiahmuli working on this very important issue of Age-Sensitive warning ranges. Is this correct?

Yes, @dkayiwa and I came up with a simple architectural solution and I am now working on it’s implementation in OpenMRS core. I will also document it after testing the code.

2 Likes

Wonderful news @isaiahmuli! You mentioned yesterday that you and @dkayiwa have worked out the architecture for this, or at least a basic starting version.

Can you share a link to the Wiki (or Jira ticket, or other) doc where this plan is described? Or, can you describe here in more detail? (For example, Wikum’s updates have been super helpful for us to follow, such as this one). Not that there’s a strict template to follow, would just love a way for the community and for myself to follow your work on this very important project :smiley: :pray: :tada:

Thank you @grace for explaining. I have not documented the architectural solution yet because it was still in testing phase. However, below is a brief explanation of our proposed solution and a link to the ticket.

Ticket: [TRUNK-6245] - OpenMRS Issues

Proposed architectural solution :
The idea was to come up with a simple yet robust solution that can be dynamic enough to store ranges of different factors and a combination of two or more factors. e.g. Age & Gender.
The solution involves coming up with concept_reference_range table that contains ranges and ‘criteria’ columns. The criteria column will contain functions that will be used to evaluate if a patient fits a certain criteria and consequently the range.

e.g.
concept_numeric Table

  • concept_id: 1, name: “Blood Pressure”, criteria: null, hi_critical: 180, hi_normal: 120, hi_absolute: 140, low_critical: 60, low_normal: 80, low_absolute: 90
  • concept_id: 2, name: “Heart Rate”, criteria: “”, hi_critical: 200, hi_normal: 100, hi_absolute: 120, low_critical: 40, low_normal: 60, low_absolute: 70

concept_reference_range Table

  • concept_reference_range_id: 1, concept_id: 1, criteria: ”fn(getAge(1-3))”, hi_critical: 180, hi_normal: 120, hi_absolute: 140, low_critical: 60, low_normal: 80, low_absolute: 90
  • concept_reference_range_id: 2, concept_id: 1, criteria: ”fn(getAge(6 - 8)”, hi_critical: 160, hi_normal: 120, hi_absolute: 140, low_critical: 60, low_normal: 80, low_absolute: 90

In the above example, concept_reference_range_id = 1 would only apply to ages between 1 and 3.

Pros:

  • Simple model - simple implementation while keeping the existing model structure.
  • Flexible/dynamic - ability to set specific criteria for a certain range. For instance, criteria: "fn.getAge() > 18 && fn.getGender() = 'F')" would apply for both Gender and Age factors.

Cons:

  • The evaluations are done “in flight”, either after fetching concept data or while saving the data, which might increase response time.
  • We have to make sure that the criteria functions are in a consistent pattern.

Note: The concept_numeric table will remain as is, for backward compatibility.

3 Likes

Given age & gender are the most common use cases, perhaps we could simplify these. For example:

concept_reference_range

  • concept_reference_range_id: 1, concept_id: 1 (Systolic blood pressure), age_low:1, age_high:3, gender:null, hi_critical: 180, hi_normal: 120, hi_absolute: 140, low_critical: 60, low_normal: 80, low_absolute: 90

In other words, adding specific attributes with inclusive bounds for age and a gender filter:

age_low (double)
  • lower bound (inclusive) in years, allows decimals to support months for infants
  • null means no lower bound
age_hi (double)
  • upper bound (inclusive) in years
  • allows decimals to support months for infants
  • null means no upper bound
gender (enum/char)
  • “M” if constraint only applies to people who are genetically male
  • “F” if constraints applies to people who are genetically female
  • null if constraint applies to all genders

This may be less flexible, but could cover the common use cases (age & gender) in a simpler manner that could be applied easily within SQL queries. If we need fancier constraint filters, we could introduce something like a “custom” or generic “criteria” attribute to perform more sophisticated filtering; however, I agree about your concerns that these fancier criteria may have performance/implementation downsides.

2 Likes

Ditto to Burke’s point about ensuring we can support month-based ages - e.g. ranges are different for a new infant <=1 month old vs a baby 11 months old :slight_smile:

2 Likes

@dkayiwa I’d like to implement this feature now in the O3 RefApp (e.g. have pediatric Vital Sign ranges). What do I do next?

@grace first step would be switching O3 RefApp to openmrs core 2.7

1 Like

In Canada with lab result reports from Lifelabs the reference range has already been adjusted for the age and gender of the patient.

Ray

@dkayiwa is this feature available in O3 RefApp? I see that O3 dev server is using openmrs core 2.7.x I am just curious to know, i understand that there could be alot of topic to prioritize and this may not be in top list, so just curious.

This feature was added to the backend core framework. But we need to do some frontend work to take advantage of it.

1 Like

Thanks @dkayiwa , any reference design or documentation how it shall be used in front end.

This is what we so far have:

1 Like