Skip to content

Commit aceb486

Browse files
Recreate SRO key measure with Streamlit
Closes #2 Co-authored-by: Alice Wong <[email protected]>
1 parent e3dca13 commit aceb486

9 files changed

+985
-2
lines changed

app/measures.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import dataclasses
2+
import pathlib
3+
4+
import altair
5+
import pandas
6+
import yaml
7+
8+
9+
PERCENTILE = "Percentile"
10+
DECILE = "Decile"
11+
MEDIAN = "Median"
12+
13+
14+
@dataclasses.dataclass
15+
class Measure:
16+
name: str
17+
explanation: str
18+
caveats: str
19+
classification: str
20+
codelist_url: str
21+
unique_patients: int
22+
total_events: int
23+
top_5_codes_table: pandas.DataFrame
24+
deciles_table: pandas.DataFrame
25+
26+
def __repr__(self):
27+
return f"Measure(name='{self.name}')"
28+
29+
def change_in_median(self, from_year, to_year, month):
30+
# Pandas wants these to be strings
31+
from_year = str(from_year)
32+
to_year = str(to_year)
33+
34+
dt = self.deciles_table # convenient alias
35+
is_month = dt["date"].dt.month == month
36+
is_median = dt["label"] == MEDIAN
37+
# set index to date to allow convenient selection by year
38+
value = dt.loc[is_month & is_median].set_index("date").loc[:, "value"]
39+
40+
# .values is a numpy array
41+
from_val = value[from_year].values[0]
42+
to_val = value[to_year].values[0]
43+
pct_change = (to_val - from_val) / from_val
44+
45+
return from_val, to_val, pct_change
46+
47+
@property
48+
def deciles_chart(self):
49+
# selections
50+
legend_selection = altair.selection_point(bind="legend", fields=["label"])
51+
52+
# encodings
53+
stroke_dash = altair.StrokeDash(
54+
"label",
55+
title=None,
56+
scale=altair.Scale(
57+
domain=[PERCENTILE, DECILE, MEDIAN],
58+
range=[[1, 1], [5, 5], [0, 0]],
59+
),
60+
legend=altair.Legend(orient="bottom"),
61+
)
62+
stroke_width = (
63+
altair.when(altair.datum.type == MEDIAN)
64+
.then(altair.value(1))
65+
.otherwise(altair.value(0.5))
66+
)
67+
opacity = (
68+
altair.when(legend_selection)
69+
.then(altair.value(1))
70+
.otherwise(altair.value(0.2))
71+
)
72+
73+
# chart
74+
chart = (
75+
altair.Chart(self.deciles_table, title="Rate per 1,000 registered patients")
76+
.mark_line()
77+
.encode(
78+
altair.X("date", title=None),
79+
altair.Y("value", title=None),
80+
detail="percentile",
81+
strokeDash=stroke_dash,
82+
strokeWidth=stroke_width,
83+
opacity=opacity,
84+
)
85+
.add_params(legend_selection)
86+
)
87+
return chart
88+
89+
90+
class OSJobsRepository:
91+
def __init__(self):
92+
path = pathlib.Path(__file__).parent.joinpath("measures.yaml")
93+
self._records = {r["name"]: r for r in yaml.load(path.read_text(), yaml.Loader)}
94+
self._measures = {} # the repository
95+
96+
def get(self, name):
97+
"""Get the measure with the given name from the repository."""
98+
if name not in self._measures:
99+
self._measures[name] = self._construct(name)
100+
return self._measures[name]
101+
102+
def _construct(self, name):
103+
"""Construct the measure with the given name from information stored on the
104+
local file system and on OS Jobs."""
105+
record = self._records[name]
106+
107+
# The following helpers don't need access to instance attributes, so we define
108+
# them as functions rather than as methods. Doing so makes them easier to mock.
109+
counts = _get_counts(record["counts_table_url"])
110+
top_5_codes_table = _get_top_5_codes_table(record["top_5_codes_table_url"])
111+
deciles_table = _get_deciles_table(record["deciles_table_url"])
112+
113+
return Measure(
114+
name,
115+
record["explanation"],
116+
record["caveats"],
117+
record["classification"],
118+
record["codelist_url"],
119+
counts["unique_patients"],
120+
counts["total_events"],
121+
top_5_codes_table,
122+
deciles_table,
123+
)
124+
125+
def list(self):
126+
"""List the names of all the measures in the repository."""
127+
return sorted(self._records.keys())
128+
129+
130+
def _get_counts(counts_table_url):
131+
return pandas.read_csv(counts_table_url, index_col=0).to_dict().get("count")
132+
133+
134+
def _get_top_5_codes_table(top_5_codes_table_url):
135+
top_5_codes_table = pandas.read_csv(
136+
top_5_codes_table_url, index_col=0, dtype={"Code": str}
137+
)
138+
top_5_codes_table.index = pandas.RangeIndex(
139+
1, len(top_5_codes_table) + 1, name="Rank"
140+
)
141+
return top_5_codes_table
142+
143+
144+
def _get_deciles_table(deciles_table_url):
145+
deciles_table = pandas.read_csv(deciles_table_url, parse_dates=["date"])
146+
deciles_table.loc[:, "label"] = PERCENTILE
147+
deciles_table.loc[deciles_table["percentile"] % 10 == 0, "label"] = DECILE
148+
deciles_table.loc[deciles_table["percentile"] == 50, "label"] = MEDIAN
149+
return deciles_table

app/measures.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
- name: Liver Function Testing - Alanine Transferaminase (ALT)
2+
explanation: >
3+
An ALT blood test is one of a group of liver function tests (LFTs) which are used to detect problems with the function of the liver.
4+
It is often used to monitor patients on medications which may affect the liver or which rely on the liver to break them down within the body.
5+
They are also tested for patients with known or suspected liver dysfunction.
6+
caveats: >
7+
**In a small number of places, an ALT test may NOT be included within a liver function test.**
8+
We use codes which represent results reported to GPs so tests requested but not yet reported are not included.
9+
Only tests results returned to GPs are included,
10+
which will usually exclude tests requested while a person is in hospital and other settings like a private clinic.
11+
classification: recovery
12+
codelist_url: https://www.opencodelists.org/codelist/opensafely/alanine-aminotransferase-alt-tests/2298df3e/
13+
counts_table_url: https://jobs.opensafely.org/service-restoration-observatory/sro-key-measures-dashboard/published/01GGZ127420DXX35BM0MMQNW8N/
14+
top_5_codes_table_url: https://jobs.opensafely.org/service-restoration-observatory/sro-key-measures-dashboard/published/01GGWFEGKSB1ANPP4X5V2FM3FR/
15+
deciles_table_url: https://jobs.opensafely.org/service-restoration-observatory/sro-key-measures-dashboard/published/01GGZ12739P6B7Z00QAJBTBKK3/
16+
17+
- name: Glycated Haemoglobin A1c Level (HbA1c)
18+
explanation: >
19+
HbA1c is a long term indicator of diabetes control.
20+
Only test results returned to GPs are included,
21+
which will usually exclude tests requested while a person is in hospital and other settings like a private clinic.
22+
caveats: >
23+
We use codes which represent results reported to GPs so tests requested but not yet reported are not included.
24+
Only test results returned to GPs are included,
25+
which will usually exclude tests requested while a person is in hospital and other settings like a private clinic.
26+
classification: recovery
27+
codelist_url: https://www.opencodelists.org/codelist/opensafely/glycated-haemoglobin-hba1c-tests/3e5b1269/
28+
counts_table_url: https://jobs.opensafely.org/service-restoration-observatory/sro-key-measures-dashboard/published/01GGZ12749JZ938746AV8XCPZ3/
29+
top_5_codes_table_url: https://jobs.opensafely.org/service-restoration-observatory/sro-key-measures-dashboard/published/01GGWFEGMVQ62NGNM403MK32Z7/
30+
deciles_table_url: https://jobs.opensafely.org/service-restoration-observatory/sro-key-measures-dashboard/published/01GGZ1273K1QJM5EQ7238X7P3S/

app/sro_key_measures.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import streamlit
2+
3+
from app import measures
4+
5+
6+
def main():
7+
repository = measures.OSJobsRepository()
8+
9+
selected_measure_name = streamlit.selectbox("Select a measure:", repository.list())
10+
11+
measure = repository.get(selected_measure_name)
12+
13+
streamlit.markdown(f"# {measure.name}")
14+
15+
streamlit.markdown(
16+
"The codes used for this measure"
17+
f"are available in [this codelist]({measure.codelist_url})."
18+
)
19+
20+
with streamlit.expander("What is it and why does it matter?"):
21+
streamlit.markdown(measure.explanation)
22+
23+
with streamlit.expander("Caveats"):
24+
streamlit.markdown(measure.caveats)
25+
26+
streamlit.altair_chart(measure.deciles_chart, use_container_width=True)
27+
28+
streamlit.markdown(f"**Most common codes ([codelist]({measure.codelist_url}))**")
29+
30+
streamlit.dataframe(measure.top_5_codes_table)
31+
32+
streamlit.markdown(
33+
"Total patients: "
34+
f"**{measure.unique_patients:,}** "
35+
f"({measure.total_events:,} events)"
36+
)
37+
38+
for from_year, to_year in [(2019, 2020), (2019, 2021)]:
39+
from_val, to_val, pct_change = measure.change_in_median(from_year, to_year, 4)
40+
streamlit.markdown(
41+
f"Change in median from April {from_year} ({from_val:.2f}) "
42+
f"to April {to_year} ({to_val:.2f}): "
43+
f"**{pct_change:.2%}**"
44+
)
45+
46+
streamlit.markdown(f"Overall classification: **{measure.classification}**")
47+
48+
49+
if __name__ == "__main__":
50+
main()

justfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ BIN_DIR := VENV_DIR / "bin"
44
PIP := BIN_DIR / "python -m pip"
55
PIP_COMPILE := BIN_DIR / "pip-compile"
66
RUFF := BIN_DIR / "ruff"
7+
STREAMLIT := BIN_DIR / "streamlit"
78

89
# List available recipes and their arguments
910
default:
@@ -61,9 +62,9 @@ prodenv: requirements-prod (_install 'prod')
6162
# Install dev requirements into the virtual environment
6263
devenv: requirements-dev prodenv (_install 'dev') && install-pre-commit
6364

64-
# Run a command in the virtual environment
65+
# Run a Streamlit app
6566
run *args: devenv
66-
echo "Not implemented"
67+
PYTHONPATH=. {{ STREAMLIT }} run {{ args }}
6768

6869
# Run tests
6970
test *args: devenv

requirements.dev.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ coverage
22
pre-commit
33
pytest
44
ruff
5+
watchdog

requirements.dev.txt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,35 @@ virtualenv==20.28.0 \
191191
--hash=sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0 \
192192
--hash=sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa
193193
# via pre-commit
194+
watchdog==6.0.0 \
195+
--hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \
196+
--hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \
197+
--hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \
198+
--hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \
199+
--hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \
200+
--hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \
201+
--hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \
202+
--hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \
203+
--hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \
204+
--hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \
205+
--hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \
206+
--hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \
207+
--hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \
208+
--hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \
209+
--hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \
210+
--hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \
211+
--hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \
212+
--hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \
213+
--hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \
214+
--hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \
215+
--hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \
216+
--hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \
217+
--hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \
218+
--hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \
219+
--hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \
220+
--hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \
221+
--hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \
222+
--hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \
223+
--hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \
224+
--hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2
225+
# via -r requirements.dev.in

requirements.prod.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pyyaml
2+
streamlit

0 commit comments

Comments
 (0)