In this tutorial we will connect to the Berkeley Demo JupyterHealth Exchange, find a patient in a study, fetch their blood pressure readings, and plot them over time.
By the end you will have a chart of one patient’s blood pressure, and a count of how many of their readings were normal, elevated, or in the hypertension range.
You don’t need to install anything: everything runs on the Berkeley Demo JupyterHealth Hub.
Before you start¶
You need an account on the Berkeley Demo JupyterHealth Exchange and to be logged into the Berkeley Demo JupyterHealth Hub. If you don’t have an account yet, follow Sign up for the Exchange.
Click the launch button above. The Hub copies this notebook into your home directory and opens it in JupyterLab.
In JupyterLab, run each code cell in order by clicking into it and pressing Shift + Enter.
After each step, compare what you see with the text under You should see.
Step 1: Connect to the JupyterHealth Exchange¶
The JupyterHealth Exchange stores the health data. We talk to it through a Python library
called jupyterhealth_client. The Hub has already logged you in, so we only need to create
a client.
Run the cell below.
from jupyterhealth_client import Code, JupyterHealthClient
client = JupyterHealthClient()
me = client.get_user()
print(f"Connected as {me['firstName']} {me['lastName']} ({me['email']})")You should see one line starting with Connected as, followed by your name and email in parentheses.
Every request we make from now on goes through client.
Step 2: Find a study¶
Data in the Exchange is organized into studies. You can only see the studies your organization runs.
Run the cell below to list them.
for study in client.list_studies():
print(f"[{study['id']}] {study['name']}")You should see a list of studies. The number in square brackets is the study’s ID.
We will use the study with ID 30001. If you don’t see it in your list, change the number
below to the ID of one of your studies.
Run the cell.
study_id = 30001Step 3: Find a patient¶
Now let’s list the patients in that study.
for patient in client.list_patients(study_id=study_id):
print(f"[{patient['id']}] {patient['nameGiven']} {patient['nameFamily']}")You should see a list of patients, each with their ID in square brackets.
We will use the patient with ID 40028. If they’re not in your list, copy any ID from the
list into the cell below instead.
Run the cell.
patient_id = 40028Step 4: Fetch the blood pressure readings¶
Next we ask the Exchange for this patient’s blood pressure readings in this study. The client returns them as a pandas DataFrame: a table with one row per reading.
Run the cell below.
df = client.list_observations_df(
patient_id=patient_id,
study_id=study_id,
code=Code.BLOOD_PRESSURE,
)
print(f"Found {len(df)} readings")
df.head()You should see Found ... readings, then the first five rows of a wide table. This is
the raw data, exactly as the Exchange stores it. We only need the time and the two
measurements.
If you see Found 0 readings, go back to Step 3 and pick a different patient.
What are all these columns?
Each row is one reading, stored in the Open mHealth format. pandas hides some of the middle columns behind ... to save space. The columns fall into a few groups:
About the record:
id,status,resource_type,codeand themeta_...columns say what kind of data this is and when it was stored in the Exchange.When it was measured:
effective_time_frame_date_timeis in UTC, andeffective_time_frame_date_time_localis in the patient’s local time.The measurement:
systolic_blood_pressure_valueanddiastolic_blood_pressure_value, with their units (mmHg) in the matching..._unitcolumns.Extra details, such as the
external_datasheets_...columns.NaNmeans a field is empty for that reading.
Let’s keep just those three columns, and give them short names.
bp = df[
[
"effective_time_frame_date_time",
"systolic_blood_pressure_value",
"diastolic_blood_pressure_value",
]
].rename(
columns={
"effective_time_frame_date_time": "time",
"systolic_blood_pressure_value": "systolic",
"diastolic_blood_pressure_value": "diastolic",
}
)
bp = bp.astype({"systolic": int, "diastolic": int}).sort_values("time")
bp.head()You should see a table with three columns: time, systolic and diastolic.
Step 5: Plot blood pressure over time¶
Now for the chart. Run the cell below.
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 4))
# Shade the gap between the two numbers, then draw each as a thin line with dots
ax.fill_between(bp["time"], bp["diastolic"], bp["systolic"], color="#5b8fc7", alpha=0.12)
for column, color in [("systolic", "#1f4e79"), ("diastolic", "#5b8fc7")]:
ax.plot(bp["time"], bp[column], color=color, linewidth=0.8, alpha=0.6)
ax.scatter(bp["time"], bp[column], color=color, s=20, label=column, zorder=2)
ax.set_ylabel("Blood pressure (mmHg)")
ax.set_title(f"Blood pressure for patient {patient_id}", loc="left")
ax.legend(loc="upper left", bbox_to_anchor=(1, 1), frameon=False)
# Styling (optional): shorter date labels and no top or right border
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()))
ax.spines[["top", "right"]].set_visible(False)You should see a chart with two lines, systolic on top and diastolic below, with the gap between them shaded.
Notice how much the readings move around from one day to the next. To make sense of them, it helps to group them, which we’ll do next.
Step 6: Group readings into categories¶
We’ll label each reading using the American Heart Association categories:
normal: systolic below 120 and diastolic below 80
elevated: systolic 120–129 and diastolic below 80
hypertension: systolic 130 or higher, or diastolic 80 or higher
Note: The AHA splits hypertension into stage 1 (130–139 / 80–89) and stage 2 (140 / 90 or higher). Many other guidelines, including the European ones, only call it hypertension from 140 / 90. We use three categories to keep things simple. A single reading in the hypertension range doesn’t mean someone has hypertension: a diagnosis is based on the average of many readings.
Run the cell below to label each reading and count how often each category occurs.
def categorize(row):
if row.systolic < 120 and row.diastolic < 80:
return "normal"
elif row.systolic < 130 and row.diastolic < 80:
return "elevated"
else:
return "hypertension"
bp["category"] = bp.apply(categorize, axis=1)
bp["category"].value_counts(normalize=True).mul(100).round(1)You should see each category with the percentage of readings that fall into it. The percentages add up to 100.
Step 7: Highlight readings out of range¶
Finally, let’s draw the chart from Step 5 again, this time coloring each reading by its category.
colors = {"normal": "#2a9d8f", "elevated": "#e9a23b", "hypertension": "#d1495b"}
fig, ax = plt.subplots(figsize=(10, 4))
# The chart from Step 5, in grey
ax.fill_between(bp["time"], bp["diastolic"], bp["systolic"], color="lightgrey", alpha=0.3)
ax.plot(bp["time"], bp["systolic"], color="lightgrey")
ax.plot(bp["time"], bp["diastolic"], color="lightgrey")
# Each reading's two dots, colored by its category
for category, color in colors.items():
readings = bp[bp["category"] == category]
ax.scatter(readings["time"], readings["systolic"], color=color, s=20, label=category, zorder=2)
ax.scatter(readings["time"], readings["diastolic"], color=color, s=20, zorder=2)
ax.set_ylabel("Blood pressure (mmHg)")
ax.set_title(f"Blood pressure for patient {patient_id}", loc="left")
ax.legend(title="Category", loc="upper left", bbox_to_anchor=(1, 1), frameon=False)
# Styling (optional): shorter date labels and no top or right border
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()))
ax.spines[["top", "right"]].set_visible(False)You should see the same chart as in Step 5, with dots in green (normal), amber (elevated) and red (hypertension). Both dots of a reading share its color, so a red systolic dot can sit next to a diastolic dot that is in range, or the other way round.
What you’ve done¶
In this tutorial you:
connected to the JupyterHealth Exchange with
JupyterHealthClientlisted your studies and the patients in a study
fetched a patient’s blood pressure readings as a table
plotted them over time
grouped them into clinical categories and highlighted them on the chart
Try going back to Step 3, picking a different patient, and running the cells again.
Next steps¶
The researcher view notebook explores the same data in more depth, including checking readings against a treatment goal.
The blood pressure dashboard turns this kind of analysis into an interactive dashboard with dropdowns for picking the study and patient.
The
jupyterhealth-clientdocumentation lists everything the client can do.