Calculating inpatient occupancy using a calendar table

All data in this post is fake data, generated artificially.

Hospital beds are an expensive resource that isn’t easy to flex. Intelligence about occupancy is useful for operational management of the bed base. The traditional way to approach this is to create a “bed census”. That is, a data set which shows how many patients were in beds at midnight each day.

There are a variety of ways of going about this. It’s possible to cook something up from an episodes extract in Excel, and I’ve also seen solutions that use SQL loops. For R users, there’s the patientcounter package created by John Mackintosh.

But my preferred solution is a set-based SQL approach using a calendar table (i.e. a reference table with one row for each calendar date).

The fundamental task is to count up the number of days in which a patient is in a bed at midnight. That suggests that we should aim to create a table in which each row represents a “bed-day”. That is, a patient episode starting at 10am on 1st January and ending at 4pm on the 4th January should correspond to three rows in the new table: one for midnight on 1st January, one for midnight on 2nd January, and one for midnight on 3rd January. Then we can use GROUP BY to count up the number of patients in bed at midnight on each day.

Let’s start by looking at all the episodes in which the patient was in hospital for part of the first week in January.

SELECT TOP 100
    ipe.EpisodeID
        ,ipe.StartDate
        ,ipe.EndDate

FROM episode ipe

WHERE ipe.EndDate >= '2020 JAN 01'
AND ipe.StartDate < '2020 JAN 08'

ORDER BY ipe.StartDate
        ,ipe.EndDate
ABCDEFGHIJ0123456789
EpisodeID
<int>
StartDate
<S3: POSIXct>
EndDate
<S3: POSIXct>
182522019-12-17 20:00:372020-01-01 07:56:57
226612019-12-18 02:43:592020-01-01 21:30:17
169432019-12-18 23:53:332020-01-02 10:23:01
171782019-12-19 19:24:132020-01-02 02:43:56
228342019-12-20 03:37:552020-01-01 06:18:38
131072019-12-20 17:08:332020-01-03 17:55:48
177502019-12-20 17:21:022020-01-02 04:58:39
1452019-12-21 02:19:572020-01-14 12:23:45
246862019-12-21 05:56:382020-01-01 19:31:25
211912019-12-21 23:56:142020-01-04 14:31:56
113562019-12-22 09:40:312020-01-05 03:18:00
245672019-12-22 11:44:152020-01-02 11:18:16
100662019-12-22 17:53:272020-01-01 11:56:55
162112019-12-22 20:12:382020-01-03 02:36:02
157042019-12-23 09:54:192020-01-06 15:12:06
224932019-12-23 13:01:262020-01-12 14:21:00
110592019-12-23 13:45:472020-01-04 00:29:00
150432019-12-23 14:01:502020-01-10 07:44:12
248462019-12-23 18:14:332020-01-01 21:33:31
210152019-12-23 22:32:212020-01-04 09:09:13

We can then create our “bed-day” table by joining the episodes table to the calendar table. The join is written so that every row in the calendar table with a date between the start date and end date of the episode will match. Note that in the results, we now have one row for every midnight that the patient was an inpatient.

It’s also worth noting I’m exploiting the default behaviour of T-SQL here. Because the dates in the lkp_calendar table do not have a time, T-SQL treats the date as though it represents midnight on the day in question.

SELECT TOP 100 
    ipe.EpisodeID
        ,ipe.StartDate
        ,ipe.EndDate
        ,census.[Date] AS 'Census Date'

FROM episode ipe
LEFT JOIN lkp_calendar census 
    ON census.[Date] >= ipe.StartDate 
        AND census.[Date] < ipe.EndDate

WHERE ipe.EndDate >= '2020 JAN 01'
AND ipe.StartDate < '2020 JAN 08'

ORDER BY ipe.StartDate
        ,ipe.EndDate
        ,census.[Date]
ABCDEFGHIJ0123456789
EpisodeID
<int>
StartDate
<S3: POSIXct>
EndDate
<S3: POSIXct>
Census Date
<S3: POSIXct>
182522019-12-17 20:00:372020-01-01 07:56:572019-12-18
182522019-12-17 20:00:372020-01-01 07:56:572019-12-19
182522019-12-17 20:00:372020-01-01 07:56:572019-12-20
182522019-12-17 20:00:372020-01-01 07:56:572019-12-21
182522019-12-17 20:00:372020-01-01 07:56:572019-12-22
182522019-12-17 20:00:372020-01-01 07:56:572019-12-23
182522019-12-17 20:00:372020-01-01 07:56:572019-12-24
182522019-12-17 20:00:372020-01-01 07:56:572019-12-25
182522019-12-17 20:00:372020-01-01 07:56:572019-12-26
182522019-12-17 20:00:372020-01-01 07:56:572019-12-27
182522019-12-17 20:00:372020-01-01 07:56:572019-12-28
182522019-12-17 20:00:372020-01-01 07:56:572019-12-29
182522019-12-17 20:00:372020-01-01 07:56:572019-12-30
182522019-12-17 20:00:372020-01-01 07:56:572019-12-31
182522019-12-17 20:00:372020-01-01 07:56:572020-01-01
226612019-12-18 02:43:592020-01-01 21:30:172019-12-19
226612019-12-18 02:43:592020-01-01 21:30:172019-12-20
226612019-12-18 02:43:592020-01-01 21:30:172019-12-21
226612019-12-18 02:43:592020-01-01 21:30:172019-12-22
226612019-12-18 02:43:592020-01-01 21:30:172019-12-23

Finally, we can calculate the overall occupancy on each day by using a GROUP BY. I’ve swapped the WHERE clause to return just the census dates we’re interested in.

SELECT census.[Date]
      ,COUNT(*) AS 'Patients in Bed at Midnight'

FROM episode ipe
LEFT JOIN lkp_calendar census 
    ON census.[Date] >= ipe.StartDate 
        AND census.[Date] < ipe.EndDate

WHERE census.[Date] >= '2020 JAN 01'
AND census.[Date] < '2020 JAN 08'

GROUP BY census.[Date]

ORDER BY census.[Date]
ABCDEFGHIJ0123456789
Date
<S3: POSIXct>
Patients in Bed at Midnight
<int>
2020-01-01206
2020-01-02192
2020-01-03193
2020-01-04196
2020-01-05193
2020-01-06193
2020-01-07190

Occupancy by specialty

One advantage of this approach is that it’s very easy to analyse the figures by units such as specialty, ward or admission method. Just add the required aggregates to the SELECT and GROUP BY clauses.

Below, I’ve also limited the query to a small number of specialties to make it a bit easier to see what’s going on. As ever, this is completely fake data so the numbers may look a little odd!

SELECT census.[Date]
        ,spec.Meaning AS 'Specialty'
        ,COUNT(*) AS 'Patients in Bed at Midnight'

FROM episode ipe
LEFT JOIN lkp_calendar census 
    ON census.[Date] >= ipe.StartDate 
        AND census.[Date] < ipe.EndDate
LEFT JOIN lkp_mainspecialty spec
    ON spec.Code = ipe.MainSpecialtyCode

WHERE census.[Date] >= '2020 JAN 01'
AND census.[Date] < '2020 JAN 08'
AND spec.Code IN (100, 101, 110, 130)

GROUP BY census.[Date]
            ,spec.Meaning

ORDER BY spec.Meaning 
            ,census.[Date]
            
ABCDEFGHIJ0123456789
Date
<S3: POSIXct>
Specialty
<chr>
Patients in Bed at Midnight
<int>
2020-01-01General Surgery1
2020-01-02General Surgery4
2020-01-03General Surgery3
2020-01-04General Surgery3
2020-01-05General Surgery3
2020-01-06General Surgery2
2020-01-07General Surgery2
2020-01-01Ophthalmology2
2020-01-02Ophthalmology2
2020-01-03Ophthalmology3
2020-01-04Ophthalmology4
2020-01-05Ophthalmology2
2020-01-06Ophthalmology2
2020-01-07Ophthalmology2
2020-01-01Trauma and Orthopaedics6
2020-01-02Trauma and Orthopaedics5
2020-01-03Trauma and Orthopaedics5
2020-01-04Trauma and Orthopaedics5
2020-01-05Trauma and Orthopaedics3
2020-01-06Trauma and Orthopaedics2

Admitted or discharged each day

If you’re undertaking a piece of work about patient flow, you may well want the number of patients admitted or discharged each day too. Again, the above approach makes this straightforward.

SELECT census.[Date]
        ,COUNT(*) AS 'Patients in Bed at Midnight'
        ,SUM(CASE WHEN CAST(ipe.StartDate AS date) = census.[Date] - 1
            THEN 1
            ELSE 0
            END) AS 'Admissions'
        ,SUM(CASE WHEN CAST(ipe.EndDate AS date) = census.[Date] 
            THEN 1
            ELSE 0
            END) AS 'Discharges'

FROM episode ipe
LEFT JOIN lkp_calendar census 
    ON census.[Date] >= ipe.StartDate 
        AND census.[Date] < ipe.EndDate

WHERE census.[Date] >= '2020 JAN 01'
AND census.[Date] < '2020 JAN 08'

GROUP BY census.[Date]

ORDER BY census.[Date]
ABCDEFGHIJ0123456789
Date
<S3: POSIXct>
Patients in Bed at Midnight
<int>
Admissions
<int>
Discharges
<int>
2020-01-012064558
2020-01-021924462
2020-01-031936350
2020-01-041965356
2020-01-051935351
2020-01-061935149
2020-01-071904658

Note that the admissions column counts the admissions that took place leading up to the census date in question, while the discharges column counts discharges in the 24 hours following the census date. This may not be the behaviour you need!

Occupancy at times other than midnight

It’s also straightforward to look at occupancy at times other than midnight. This might come in handy for looking into delays to discharge. The extra step is to extend the calendar table to include one row for every timestamp for which you need a patient count - in the example below I’ve done this in a CTE.

In the query below, I’ve used a table value constructor, but if you have to do this often it’s probably better to make and use a clock table.

WITH calendarwithtimes AS 
    (
    SELECT DATEADD(hour, clock.hr, census.[Date]) AS 'Census Date and Time'

    FROM lkp_calendar census
    LEFT JOIN (VALUES (0), (6), (12), (18)) AS clock(hr)
      ON 1 = 1 
    )

SELECT census.[Census Date and Time]
        ,COUNT(*) AS 'Patients in Bed'

FROM calendarwithtimes census
LEFT JOIN episode ipe
    ON census.[Census Date and Time] >= ipe.StartDate 
        AND census.[Census Date and Time] < ipe.EndDate

WHERE census.[Census Date and Time] >= '2020 JAN 01'
AND census.[Census Date and Time] < '2020 JAN 08'

GROUP BY census.[Census Date and Time]

ORDER BY census.[Census Date and Time]
ABCDEFGHIJ0123456789
Census Date and Time
<S3: POSIXct>
Patients in Bed
<int>
2020-01-01 00:00:00206
2020-01-01 06:00:00215
2020-01-01 12:00:00207
2020-01-01 18:00:00197
2020-01-02 00:00:00192
2020-01-02 06:00:00199
2020-01-02 12:00:00196
2020-01-02 18:00:00197
2020-01-03 00:00:00193
2020-01-03 06:00:00198
2020-01-03 12:00:00193
2020-01-03 18:00:00187
2020-01-04 00:00:00196
2020-01-04 06:00:00201
2020-01-04 12:00:00201
2020-01-04 18:00:00196
2020-01-05 00:00:00193
2020-01-05 06:00:00197
2020-01-05 12:00:00196
2020-01-05 18:00:00199