Expand this to see code
library(tidyverse)
library(janitor)
library(rlang)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)).
These come from AD-10.64:
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.
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?
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.
Because of the nature of the original records, there are missing data that affect our analysis.
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 …
Our libraries.
library(tidyverse)
library(janitor)
library(rlang)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.
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()
}We need the logs and the activations.
logs_all <- read_rds("data-processed/01-outdoor-cleaned.rds")
activations <- read_rds("data-processed/01-activation-cleaned.rds")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.
Here we find records that have neither value:
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.
logs_clipped <- logs_all |>
drop_na(temp) # drops 8 records that don't have a temperature recordedHowever, there are some records with temps that are missing heat index/wind chill hi_wc values.
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.
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.
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.
Here we summarize the hourly logs to see if certain conditions exist in a given day.
We peek at a sample to check results.
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.
logs_values |> slice_sample(n = 20)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.
Here we add some flags based on the excessive heat warning definitions:
We peek at a sample to check the results.
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)Here we set more flags when certain definitions have been met.
TRUE if either the temperature reached 105 or the heat index reached 113: u_hi_tmp_proto.u_hi_tmp_proto is true: u_hi_tmp_proto_cnt.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.
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.
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.
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…
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.
activations_active <- activations |>
filter(date >= "2023-07-24" & date <= "2023-07-31") |>
arrange(date, unit)
activations_activeThere are 24 activations in our time period. Now let’s check our joined data …
logs_activations_active <- logs_activations |>
filter(!is.na(protocol_active)) |>
arrange(date, unit)
logs_activations_activeThere 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.
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.
criteria_met <- logs_activations |>
filter(u_proto_flag == T) |>
select(unit, date, u_hi_max, u_tmp_max, protocol_active) |>
arrange(unit, date)
criteria_metWhich units were involved?
criteria_met |> count(unit)Were ICS protocols activated?
criteria_met |> filter(protocol_active == T)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.
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.
logs_ics <- logs_activations |>
filter(protocol_active == T) |>
select(unit, date, u_hi_max, u_tmp_max, u_proto_flag)
logs_icsHowever, 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.
logs_ics |> filter(date < "2023-07-26")One more look … Which of these days did not meet the criteria for a the given day.
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.
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.
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.
logs_activations |>
filter(u_hi_tmp_proto == T) |>
count(unit) |>
adorn_totals("row") |>
tibble()How many of these were in active ICS protocol?
logs_activations |>
filter(u_hi_tmp_proto == T) |>
count(protocol_active)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.
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.
logs_activations |>
filter(u_hi_max >= 90) |>
count(unit)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.
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.
We have to calculate quite a few things to make this definition. Comments in 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_protosIn 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.
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.
logs_ehw_protos |>
filter(u_ehw_flag_cnt >= 4) |>
select(unit, date, u_hi_max, u_tmp_max, u_ehw_flag_cnt, protocol_active)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.