Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions Internal/Prediction/bed_availability_prediction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@

# Bed Availability Prediction

> 7-day forward forecast of bed occupancy and vacancies per floor/ward, with bottleneck flagging

## Purpose

Forecasts bed occupancy for each floor/ward over the next 7 days by combining current occupancy with historical (90-day) day-of-week admission and discharge patterns. Applies tunable multipliers to account for known surges or slowdowns, and flags any ward/day projected to cross an occupancy threshold as a `BOTTLENECK RISK`.

## Parameters

| Variable | Default | Meaning | When to change it |
|----------|---------|---------|--------------------|
| `admission_multiplier` | `1.0` | Scales expected admissions vs. the 90-day average. `1.0` = normal, `1.2` = expect 20% more admissions, `0.8` = expect 20% fewer | Festival/flu season surge → `1.2`–`1.5`. Holiday period with fewer elective admits → `0.7`–`0.9` |
| `turnover_multiplier` | `1.0` | Scales expected discharges. `1.0` = normal discharge pace, `1.2` = faster discharges, `0.8` = slower | Discharge drive/faster turnover initiative → `1.1`–`1.3`. Long weekend when doctors discharge fewer patients → `0.7`–`0.9` |
| `bottleneck_threshold` | `0.90` | Occupancy % (as a fraction) at which a ward gets flagged as `"BOTTLENECK RISK"` | ICU/critical wards may warrant `0.80` (flag earlier). General wards may tolerate `0.95` |
Comment thread
sonzsara marked this conversation as resolved.

> **Note:** `bottleneck_threshold` is a **fraction**, so enter `0.90`, not `90`. If all three variables are set to `1`, the threshold is effectively `100%`, meaning nothing will flag until a ward is completely full — set it to `0.9` (or your desired fraction) instead.
Comment thread
sonzsara marked this conversation as resolved.

---

## Query

```sql
WITH capacity AS (
SELECT
COALESCE(gp.name, p.name) AS floor,
p.name AS ward,
COUNT(*) AS total_beds
FROM emr_facilitylocation fl
LEFT JOIN emr_facilitylocation p ON fl.parent_id = p.id
LEFT JOIN emr_facilitylocation gp ON p.parent_id = gp.id
WHERE fl.deleted = FALSE AND fl.status = 'active'
AND fl.form = 'bd' AND fl.root_location_id != 300
GROUP BY 1, 2
),
current_occ AS (
SELECT
COALESCE(gp.name, p.name) AS floor,
p.name AS ward,
COUNT(DISTINCT fl.id) AS occupied_beds
FROM emr_facilitylocationencounter fle
INNER JOIN emr_facilitylocation fl ON fle.location_id = fl.id
LEFT JOIN emr_facilitylocation p ON fl.parent_id = p.id
LEFT JOIN emr_facilitylocation gp ON p.parent_id = gp.id
WHERE fl.deleted = FALSE AND fl.status = 'active'
AND fl.form = 'bd' AND fle.deleted = FALSE
AND fl.root_location_id != 300
AND fle.start_datetime <= NOW()
AND (fle.end_datetime IS NULL OR fle.end_datetime > NOW())
GROUP BY 1, 2
),

dow_occurrences AS (
SELECT
EXTRACT(DOW FROM d) AS dow,
COUNT(*) AS occurrences
FROM generate_series(
date_trunc('day', NOW()) - INTERVAL '90 days',
date_trunc('day', NOW()) - INTERVAL '1 day',
INTERVAL '1 day'
) AS d
GROUP BY 1
),
patterns AS (

SELECT
combined.floor,
combined.ward,
combined.dow,
SUM(combined.admit_count) / docc.occurrences::float AS avg_admits,
SUM(combined.discharge_count) / docc.occurrences::float AS avg_discharges
FROM (

SELECT
COALESCE(gp_a.name, p_a.name) AS floor,
p_a.name AS ward,
EXTRACT(DOW FROM fle_a.start_datetime) AS dow,
COUNT(*) AS admit_count,
0 AS discharge_count
FROM emr_facilitylocationencounter fle_a
INNER JOIN emr_facilitylocation fl_a ON fle_a.location_id = fl_a.id
LEFT JOIN emr_facilitylocation p_a ON fl_a.parent_id = p_a.id
LEFT JOIN emr_facilitylocation gp_a ON p_a.parent_id = gp_a.id
WHERE fl_a.deleted = FALSE AND fl_a.status = 'active'
AND fl_a.form = 'bd' AND fle_a.deleted = FALSE
AND fl_a.root_location_id != 300
AND fle_a.start_datetime >= date_trunc('day', NOW()) - INTERVAL '90 days'
AND fle_a.start_datetime < date_trunc('day', NOW())
GROUP BY 1, 2, 3

UNION ALL


SELECT
COALESCE(gp_d.name, p_d.name) AS floor,
p_d.name AS ward,
EXTRACT(DOW FROM fle_d.end_datetime) AS dow,
0 AS admit_count,
COUNT(*) AS discharge_count
FROM emr_facilitylocationencounter fle_d
INNER JOIN emr_facilitylocation fl_d ON fle_d.location_id = fl_d.id
LEFT JOIN emr_facilitylocation p_d ON fl_d.parent_id = p_d.id
LEFT JOIN emr_facilitylocation gp_d ON p_d.parent_id = gp_d.id
WHERE fl_d.deleted = FALSE AND fl_d.status = 'active'
AND fl_d.form = 'bd' AND fle_d.deleted = FALSE
AND fl_d.root_location_id != 300
AND fle_d.end_datetime IS NOT NULL
AND fle_d.end_datetime >= date_trunc('day', NOW()) - INTERVAL '90 days'
AND fle_d.end_datetime < date_trunc('day', NOW())
GROUP BY 1, 2, 3
) combined
JOIN dow_occurrences docc ON docc.dow = combined.dow
GROUP BY 1, 2, 3, docc.occurrences
),
days AS (
SELECT generate_series(
date_trunc('day', NOW()) + INTERVAL '1 day',
date_trunc('day', NOW()) + INTERVAL '7 days',
INTERVAL '1 day'
) AS forecast_day
),
forecast AS (
SELECT
c.floor,
c.ward,
c.total_beds,
d.forecast_day,
COALESCE(co.occupied_beds, 0) AS occupied_beds,
SUM(
COALESCE(pt.avg_admits, 0) * {{admission_multiplier}}
- COALESCE(pt.avg_discharges, 0) * {{turnover_multiplier}}
) OVER (PARTITION BY c.floor, c.ward ORDER BY d.forecast_day) AS cum_net_flow
FROM capacity c
CROSS JOIN days d
LEFT JOIN current_occ co ON co.floor = c.floor AND co.ward = c.ward
LEFT JOIN patterns pt
ON pt.floor = c.floor AND pt.ward = c.ward
AND pt.dow = EXTRACT(DOW FROM d.forecast_day)
)
SELECT
floor,
ward,
forecast_day,
total_beds,
ROUND(LEAST(GREATEST(occupied_beds + cum_net_flow, 0), total_beds)::numeric) AS predicted_occupied,
total_beds - ROUND(LEAST(GREATEST(occupied_beds + cum_net_flow, 0), total_beds)::numeric) AS predicted_vacancies,
ROUND((100.0 * LEAST(GREATEST(occupied_beds + cum_net_flow, 0), total_beds) / total_beds)::numeric, 1) AS predicted_occupancy_pct,
CASE WHEN (occupied_beds + cum_net_flow) / total_beds::float >= {{bottleneck_threshold}}
THEN 'BOTTLENECK RISK' ELSE 'OK' END AS status
FROM forecast
ORDER BY floor, ward, forecast_day
```

## Notes

- **Tunable variables:** See the Parameters table above.
- `admission_multiplier` and `turnover_multiplier` scale the historical daily averages to reflect expected surges/slowdowns.
- `bottleneck_threshold` is compared against the *fraction* `(occupied_beds + cum_net_flow) / total_beds`, so it must be entered as a decimal fraction (e.g. `0.90` for 90%), not a whole number.
- Results are ordered by floor, then ward, then forecast day — giving a 7-day trend per ward.

*Last updated: 2026-07-24*