Precautionary measures

Author

Christian McDonald, Media Innovation Group

Overview

We have eight days of hand-written weather logs from 82 prisons that we have translated into data. The date range was 2023-07-24 to 2023-07-31.

In this analysis our aim is to compare those local weather logs to TDCJ heat protocol implementations. The protocols are based on the EXCESSIVE AND EXTREME TEMPERATURE CONDITIONS IN THE TDCJ (AD-10.64 (rev. 10)).

Definitions

These come from AD-10.64:

  • “Excessive Heat” occurs from a combination of significantly higher than normal temperatures and high humidity.
  • “Excessive Heat Warning” is issued by the National Weather Service within 12 hours of the onset of the following criteria: temperature of at least 105ºF for more than three hours per day for two consecutive days, or heat index of 113ºF or greater for any period of time.
  • “Heat Wave” is a prolonged period (three or more days) of excessively hot and unusually humid weather that meets the following criteria: temperature of at least 105ºF or heat index of 113ºF.

III.A.2

When the National Weather Service issues an excessive heat warning or notice of an impending heat wave, the TDCJ Office of Emergency Management shall send the applicable division directors an email notification. When excessive heat conditions last for more than three consecutive days, the division directors and warden(s) of units in the affected area(s) shall immediately implement additional precautionary measures, as outlined in Section IV.I of this directive.

NOTE: The TDCJ protocol activation records we have relate to these “precautionary measures” mentioned here.

What is unclear

The definition of excessive heat above is a challenge as it is not clearly defined with data points to measure against, yet seems to be referred to in the III.A.2 section of the directive.

Excessive heat warning is clearly defined, but the III.A.2 section does not use the term “warning”, just the ambiguous “excessive heat conditions” terminology.

Does this mean that an “excessive heat warning” must be in effect for more than 3 days before implementing “precautionary measure” protocols? Or that the conditions that contribute to that heat warning have to be in place?

Clarifications

In communications with the TDCJ Director of Communications, they stated that “ICS is initiated when temperatures are 105+ degrees or heat index is 113+ for three or more consecutive days.” This interpretation is less strict than the “excessive heat warning” definition in the AD-10.64 directive.

Our understanding is the “ICS” (Incident Command System) is the precautionary measures outlined in III.A.2 of the directive.

Other known issues

Because of the nature of the original records, there are missing data that affect our analysis.

  • There are 8 records where we don’t have a temperature value. We dropped those records.
  • There are 3088 records (out of 15,744) where we don’t have a heat index value. That limits their value, but we still have temperatures, so we keep them.
    • There are 7 units that have one or more days where we can’t find a max heat index value. Typically because there are no heat index values recorded for a given date. 8 days for Dalhart, 7 for Formby, 3 for Wheeler. The others only have one day missing.
    • In the original written logs, some units (Dalhart, for example) used category designations instead of heat index values, which means they will not translate into numbers for data. Category 3 is the “Danger” area according to the NOAA’s National Weather Service Heat and Humidity Index included in the TDCJ directive, and that range includes heat indexes of 113 degrees and higher. Category 4 is “Extreme Danger”. This means in some cases we could have heat conditions based on heat index, but they are not included.

What we’ll find

Based on our communications with TDCJ, we’ll use the following definition as “meeting protocol” to go into precautionary measures: When temperatures are 105+ degrees or heat index is 113+ for three or more consecutive days.

We’ll find …

  1. Which units met the protocol definition outlined above.
  2. Which units had ICS “precautionary measures” implemented by TDCJ.
  3. Setting aside the consecutive days criteria, we’ll find any day it reached 105 or a heat index of 113.
  4. We’ll find how many times units reached a heat index of 90 or greater, as the directive uses that measure for some heat mitigation measures, though TDCJ does not track their use.
  5. Just for comparison, we’ll calculate the more strict “excessive heat warning” criteria and see when units reached it.

Setup

Our libraries.

Expand this to see code
library(tidyverse)
library(janitor)
library(rlang)

Streaks function

This function creates a new column that counts how many consecutive days another column has been true, within each group (by unit). It’s used several times throughout the analysis, anytime we want to count a streak of a flag value. The code was written with help from ChatGPT. Full explanation in the resources folder.

Expand this to see code
add_streaks <- function(df, new_col, flag_col) {
  # helps new col work if quoted
  new_col <- rlang::ensym(new_col)

  df |>
  arrange(unit, date) |> 
  group_by(unit) |> 
  # Counts consecutive flags by unit
  mutate(
    !!new_col := {
      count <- 0L
      map_int({{ flag_col }}, ~ {
        if (.x) {
          count <<- count + 1L
        } else {
          count <<- 0L
        }
        count
      })
    }
  ) |> 
  ungroup()
}

Import

We need the logs and the activations.

Expand this to see code
logs_all <- read_rds("data-processed/01-outdoor-cleaned.rds") 
activations <- read_rds("data-processed/01-activation-cleaned.rds")

Data checks

We use temperature (tmp) and heat index (hi_wc) values as a basis of everything. Missing those values affect things down the road, so let’s catalog the problems and mitigate them.

Missing both

Here we find records that have neither value:

Expand this to see code
logs_all |> filter(is.na(temp), is.na(hi_wc))

We’ll drop these records below because they can’t help us. I’ve checked and these are also the only records that are missing temp.

Expand this to see code
logs_clipped <- logs_all |> 
  drop_na(temp) # drops 8 records that don't have a temperature recorded

Missing heat index

However, there are some records with temps that are missing heat index/wind chill hi_wc values.

Expand this to see code
logs_missing_hi <- logs_clipped |> filter(is.na(hi_wc))

logs_missing_hi |> nrow()
[1] 3080

Missing the heat index value is not necessarily a problem. Temperatures and humidity could be out of range for a valid calculation.

We keep these because we can still use the temp values.

Risk category

That said, of the records without heat index values, there are some that do have the hi_wc_n value, which is the “Risk Category” in the NOAA Heat and Humidity Index.

Category Rating
1 Caution
2 Extreme Caution
3 Danger
4 Extreme Danger

Here I filter to find the units that have the category listed. Since these are hourly readings, I use distinct to find the dates when a unit recorded a specific category. We then filter those units that recorded cat 3 or higher.

Expand this to see code
logs_missing_hi |>
  filter(!is.na(hi_wc_n)) |> 
  distinct(unit, date, hi_wc_n) |>
  filter(hi_wc_n >= 3)

This means there are 15 “unit days” where it is possible the heat index has reached 113, but we don’t know for sure. Here we show which units have a missing heat index and category 3 risk category.

In the end, we are keeping records with no heat index value because they still have temperature, and we are ignoring the risk category because we don’t know the exact heat index value.

Preparing the protocol flags

Here we summarize the hourly logs to see if certain conditions exist in a given day.

  • We find the maximum heat index value.
  • We find the maximum temperatures for a day.
  • We find how many hours within a day the temperature was above 105. (This is for strict Excessive Heat Warning calculations.)

We peek at a sample to check results.

Expand this to see code
logs_values <- logs_clipped |> 
  group_by(unit, date) |> 
  # summarize measurements
  summarise(
    u_hi_max = max(hi_wc, na.rm = T),
    u_tmp_max = max(temp, na.rm = T),
    u_hrs_105 = sum(temp >= 105),
    .groups = "drop"
  )
Warning: There were 22 warnings in `summarise()`.
The first warning was:
ℹ In argument: `u_hi_max = max(hi_wc, na.rm = T)`.
ℹ In group 81: `unit = "Dalhart"` `date = 2023-07-24`.
Caused by warning in `max()`:
! no non-missing arguments to max; returning -Inf
ℹ Run `dplyr::last_dplyr_warnings()` to see the 21 remaining warnings.
Expand this to see code
logs_values |> slice_sample(n = 20)

Warnings and -Inf values

We do get some warnings here “no non-missing arguments to max; returning -Inf” when we calculate maximum heat index values. These are cases where the hi_wc was always NA within a unit day, so there was no max value to find. In 22 cases it gives us an -Inf which gets treated as NA going forward and we can’t consider heat index values for those unit days.

Add hi, tmp flags

Here we add some flags based on the excessive heat warning definitions:

  • If heat index reached 113
  • If the temperature reached 105

We peek at a sample to check the results.

Expand this to see code
logs_flags <- logs_values |> 
  mutate(
    u_hi_flag = if_else(u_hi_max >= 113, T, F),
    u_tmp_flag = if_else(u_tmp_max >= 105, T, F),
    )

# view a sample
logs_flags |> slice_sample(n = 20)

Set protocol flags

Here we set more flags when certain definitions have been met.

  • Flag TRUE if either the temperature reached 105 or the heat index reached 113: u_hi_tmp_proto.
  • Create a consecutive day count for when u_hi_tmp_proto is true: u_hi_tmp_proto_cnt.
  • Flag TRUE if u_hi_tmp_proto_cnt is more than three days: u_proto_flag.

We peek at a sample of rows to check logic.

Expand this to see code
logs_protos <- logs_flags |> 
  mutate(
    u_hi_tmp_proto = if_else(u_tmp_flag == T | u_hi_flag == T, T, F),
  ) |> 
  add_streaks("u_hi_tmp_proto_cnt", u_hi_tmp_proto) |> 
  mutate(
    u_proto_flag = if_else(u_hi_tmp_proto_cnt > 3, T, F)
  )

logs_protos |> slice_sample(n = 20)

At this point we have all the conditions counted and flags created.

Join with activations

Here we take our log files and join them with the activation dates so we can see where/when they match, if at all.

We glimpse the new table to review the column names.

Expand this to see code
logs_activations <- logs_protos |>
  left_join(activations, join_by(unit, date)) |> 
  mutate(protocol_active = if_else(is.na(protocol_active), F, protocol_active))

logs_activations |> glimpse()
Rows: 656
Columns: 11
$ unit               <chr> "Allred", "Allred", "Allred", "Allred", "Allred", "…
$ date               <date> 2023-07-24, 2023-07-25, 2023-07-26, 2023-07-27, 20…
$ u_hi_max           <dbl> 105, 107, 105, 105, 110, 105, 104, 108, 109, 107, 1…
$ u_tmp_max          <dbl> 106, 108, 103, 105, 104, 105, 105, 107, 103, 100, 9…
$ u_hrs_105          <int> 3, 4, 0, 1, 0, 1, 2, 5, 0, 0, 0, 0, 0, 0, 1, 2, 0, …
$ u_hi_flag          <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FA…
$ u_tmp_flag         <lgl> TRUE, TRUE, FALSE, TRUE, FALSE, TRUE, TRUE, TRUE, F…
$ u_hi_tmp_proto     <lgl> TRUE, TRUE, FALSE, TRUE, FALSE, TRUE, TRUE, TRUE, F…
$ u_hi_tmp_proto_cnt <int> 1, 2, 0, 1, 0, 1, 2, 3, 0, 0, 0, 0, 0, 0, 1, 2, 0, …
$ u_proto_flag       <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FA…
$ protocol_active    <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FA…

Checks on activations

We have a multi-step bit here to make sure that all the activations have been added to our logs data.

Here we look at the original data to see which rows match our log time frame.

Expand this to see code
activations_active <- activations |>
  filter(date >= "2023-07-24" & date <= "2023-07-31") |> 
  arrange(date, unit)

activations_active

There are 24 activations in our time period. Now let’s check our joined data …

Expand this to see code
logs_activations_active <- logs_activations |> 
  filter(!is.na(protocol_active)) |> 
  arrange(date, unit)

logs_activations_active

There are also 24 days here. Let’s anti-join them to see if there are any non-matches.

This should result in 0 rows if we’ve done things right.

Expand this to see code
activations_active |> 
  anti_join(logs_activations_active, join_by(unit, date))

This has been overkill, but I’m certain that we have captured all of the activations in our time period and those have been properly added to our logs.

Answer the questions

1. Units meeting criteria

  1. Which units met the protocol definition outlined above.
Expand this to see code
criteria_met <- logs_activations |> 
  filter(u_proto_flag == T) |> 
  select(unit, date, u_hi_max, u_tmp_max, protocol_active) |> 
  arrange(unit, date)

criteria_met

Which units were involved?

Expand this to see code
criteria_met |> count(unit)

Were ICS protocols activated?

Expand this to see code
criteria_met |> filter(protocol_active == T)

Takeaway 1

Seven units met the ICS criteria for a total of 17 “unit days” based on the definition given to us by the TDCJ communications department. None of those had active ICS protocols in place.

Note that this count could be low, as it’s possible units met protocol before the first day of our sample.

2. Days in ICS

Here we look at the days that units within our time frame where TDCJ activated its Incident Command System, i.e., precautionary measures based on heat.

We include our u_proto_flag created based on the handwritten weather logs.

Expand this to see code
logs_ics <- logs_activations |> 
  filter(protocol_active == T) |> 
  select(unit, date, u_hi_max, u_tmp_max, u_proto_flag)

logs_ics

However, 12 of these activation days are within the first two days of our period, meaning they could’ve met the criteria based on data not in our review.

Expand this to see code
logs_ics |> filter(date < "2023-07-26")

One more look … Which of these days did not meet the criteria for a the given day.

Expand this to see code
logs_ics |> 
  filter(u_hi_max < 113 & u_tmp_max < 105)

Half of these units did not reach a heat index of 113 or temperature of 105 on the day of the active protocol. Being under ICS is likely good for inmates, but it perhaps shows inconsistency in implementation.

Takeaway 2

According to the activation data, there were 24 days in our time period where ICS heat mitigation protocols were put in place. On those activation days, none of these units met the criteria we’ve been given by TDCJ communications based on the weather logs kept by units. That said, half of the activations are within the first two days of our study, meaning they could have met the criteria in the days before our time frame.

3. Any day meeting condition

These precautionary protocols are mandated only after heat conditions exists for three consecutive days. But any day these conditions exist can be dangerous. Let’s see how many times this condition was true for these units in our time period.

Expand this to see code
logs_activations |> 
  filter(u_hi_tmp_proto == T) |> 
  count(unit) |> 
  adorn_totals("row") |> 
  tibble()

How many of these were in active ICS protocol?

Expand this to see code
logs_activations |> 
  filter(u_hi_tmp_proto == T) |> 
  count(protocol_active)

Takeaway 3

Out of 656 “unit days” possible (82 units, 8 days), excessive heat conditions were reached 127 times. Half of the units reached the criteria at least once. In only 12 of those instances was a unit in active precautionary measures.

4. Days of 90 heat index

There is a section in the directive (III.A.5) where heat precautions must be taken when in “apparent air temperatures above 90 degrees”, like frequent water breaks. TDCJ does not record when such conditions exist, but we wanted to get an idea.

Expand this to see code
logs_activations |> 
  filter(u_hi_max >= 90) |> 
  count(unit)

Takeaway 4

These 90 degree heat index conditions existed nearly every day for all 82 units within our time period. Only five units didn’t meet that criteria every day, and for four of those days it didn’t meet for only a single day.

5. Excessive Heat Warnings

Within the directive, TDCJ outlines a definition of “Excessive Heat Warning” as “temperature of at least 105ºF for more than three hours per day for two consecutive days, or heat index of 113ºF or greater for any period of time.” This is more strict than the definition we use above. Let’s just see how many of our units would meet this criteria based on the handwritten weather logs data.

Prep the data

We have to calculate quite a few things to make this definition. Comments in code.

Expand this to see code
logs_ehw_protos <- logs_values |> 
  # 105ºF for more than three hours per day
  mutate(
    u_tmp_3hrs = if_else(u_hrs_105 >= 3, T, F)
  ) |> 
  # add count to find consecutive days 105 met
  add_streaks("u_tmp_flag_cnt", u_tmp_3hrs) |> 
  mutate(
    # flag if 105 was 2 or more days
    u_tmp_2days = if_else(u_tmp_flag_cnt >= 2, T, F),
    # flag if either tmp conditions or heat index conditions are met
    u_ehw_flag = if_else(u_hi_max >= 113 | u_tmp_2days == T, T, F)
  ) |> 
  # Add count to find consecutive days either condition met
  add_streaks("u_ehw_flag_cnt", u_ehw_flag) |> 
  # add activations
  left_join(activations, join_by(unit, date)) |> 
  mutate(protocol_active = if_else(is.na(protocol_active), F, protocol_active))

logs_ehw_protos

EHW streaks

In III.A.2 the directive says “When excessive heat conditions last for more than three consecutive days, the division directors and warden(s) of units in the affected area(s) shall immediately implement additional precautionary measures …”

Emphasis is mine. That portion could be read as four or more days, which is different than the “three consecutive days” value we’ve been working with in our basic definition above.

Here we show which units met the EHW criteria for three or more days.

OF NOTE: Since we only have eight days of readings, we potentially miss streaks early in our time period. Conditions could have been met on July 22-23, but we don’t know that.

Expand this to see code
logs_ehw_protos |> 
  filter(u_ehw_flag_cnt >= 3) |> 
  select(unit, date, u_hi_max, u_tmp_max, u_ehw_flag_cnt, protocol_active)

The emphasis is mine. This could be read as these conditions being in place for more than three days, so four or greater. So here check that.

Expand this to see code
logs_ehw_protos |> 
  filter(u_ehw_flag_cnt >= 4) |> 
  select(unit, date, u_hi_max, u_tmp_max, u_ehw_flag_cnt, protocol_active)

Takeaway 5

There were 12 “unit days” where strict Excessive Heat Warning conditions were in effect for three or more days during our eight-day period. Official “Incident Command System” protocols were not in active on any of those days.