Skip to content

Commit 642219b

Browse files
authored
Merge pull request #209 from cmu-delphi/staging
Staging
2 parents 92573e0 + 2efcc82 commit 642219b

File tree

66 files changed

+13701
-1498
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+13701
-1498
lines changed

src/alternative_interface/__init__.py

Whitespace-only changes.

src/alternative_interface/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.contrib import admin
2+
3+
# Register your models here.

src/alternative_interface/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AlternativeInterfaceConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "alternative_interface"

src/alternative_interface/migrations/__init__.py

Whitespace-only changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.db import models
2+
3+
# Create your models here.

src/alternative_interface/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import path
2+
from django.urls.resolvers import URLPattern
3+
from alternative_interface.views import alternative_interface_view
4+
5+
urlpatterns: list[URLPattern] = [
6+
path("alternative_interface", alternative_interface_view, name="alternative_interface"),
7+
]

src/alternative_interface/utils.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
from datetime import datetime, timedelta
2+
from typing import Iterable, Union
3+
4+
import requests
5+
from django.conf import settings
6+
from epiweeks import Week
7+
8+
from base.models import GeographyUnit
9+
from indicatorsets.utils import (
10+
generate_epivis_custom_title,
11+
generate_random_color,
12+
get_epiweek,
13+
group_by_property,
14+
)
15+
16+
17+
def epiweeks_in_date_range(start_date_str: str, end_date_str: str):
18+
start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
19+
end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
20+
if end_date < start_date:
21+
start_date, end_date = end_date, start_date
22+
23+
start_week = Week.fromdate(start_date)
24+
end_week = Week.fromdate(end_date)
25+
26+
weeks = []
27+
seen = set()
28+
d = start_week.startdate()
29+
while d <= end_week.enddate():
30+
w = Week.fromdate(d)
31+
key = (w.year, w.week)
32+
if key not in seen:
33+
weeks.append(w)
34+
seen.add(key)
35+
d += timedelta(days=7)
36+
return weeks
37+
38+
39+
def _epiweek_key(w: Week) -> int:
40+
# Matches API time_value format YYYYWW, e.g. 202032
41+
return w.year * 100 + w.week
42+
43+
44+
def _epiweek_label(w: Week) -> str:
45+
return f"{w.year}-W{w.week:02d}"
46+
47+
48+
def get_available_geos(indicators):
49+
geo_values = []
50+
grouped_indicators = group_by_property(indicators, "data_source")
51+
for data_source, indicators in grouped_indicators.items():
52+
indicators_str = ",".join(indicator["name"] for indicator in indicators)
53+
response = requests.get(
54+
f"{settings.EPIDATA_URL}covidcast/geo_indicator_coverage",
55+
params={"data_source": data_source, "signals": indicators_str},
56+
auth=("epidata", settings.EPIDATA_API_KEY),
57+
)
58+
if response.status_code == 200:
59+
data = response.json()
60+
if len(data["epidata"]):
61+
geo_values.extend(data["epidata"])
62+
unique_values = set(geo_values)
63+
geo_levels = set([el.split(":")[0] for el in unique_values])
64+
geo_unit_ids = set([geo_value.split(":")[1] for geo_value in unique_values])
65+
geographic_granularities = [
66+
{
67+
"id": f"{geo_unit.geo_level.name}:{geo_unit.geo_id}",
68+
"geoType": geo_unit.geo_level.name,
69+
"text": geo_unit.display_name,
70+
"geoTypeDisplayName": geo_unit.geo_level.display_name,
71+
}
72+
for geo_unit in GeographyUnit.objects.filter(geo_level__name__in=geo_levels)
73+
.filter(geo_id__in=geo_unit_ids)
74+
.prefetch_related("geo_level")
75+
.order_by("level")
76+
]
77+
grouped_geographic_granularities = group_by_property(
78+
geographic_granularities, "geoTypeDisplayName"
79+
)
80+
geographic_granularities = []
81+
for key, value in grouped_geographic_granularities.items():
82+
geographic_granularities.append(
83+
{
84+
"text": key,
85+
"children": value,
86+
}
87+
)
88+
return geographic_granularities
89+
90+
91+
def get_covidcast_data(indicator, start_date, end_date, geo, api_key):
92+
if indicator["_endpoint"] == "covidcast":
93+
time_values = f"{start_date}--{end_date}"
94+
if indicator["time_type"] == "week":
95+
start_day, end_day = get_epiweek(start_date, end_date)
96+
time_values = f"{start_day}-{end_day}"
97+
geo_type, geo_value = geo.split(":")
98+
params = {
99+
"time_type": indicator["time_type"],
100+
"time_values": time_values,
101+
"data_source": indicator["data_source"],
102+
"signal": indicator["name"],
103+
"geo_type": geo_type,
104+
"geo_values": geo_value.lower(),
105+
"api_key": api_key if api_key else settings.EPIDATA_API_KEY,
106+
}
107+
response = requests.get(f"{settings.EPIDATA_URL}covidcast", params=params)
108+
if response.status_code == 200:
109+
response_data = response.json()
110+
if len(response_data["epidata"]):
111+
return response_data["epidata"]
112+
return []
113+
114+
115+
def prepare_chart_series_multi(
116+
api_rows: list[dict],
117+
start_date: str,
118+
end_date: str,
119+
series_by: Union[str, Iterable[str]] = "signal",
120+
):
121+
"""
122+
api_rows: list of dicts with at least 'time_value' (YYYYWW) and 'value'
123+
series_by: a field name (e.g., 'signal' or 'geo_value') or an iterable of fields (e.g., ('signal','geo_value'))
124+
returns: { labels: [...], datasets: [{ label, data }, ...] }
125+
"""
126+
# 1) Build aligned epiweek axis
127+
weeks = epiweeks_in_date_range(start_date, end_date)
128+
labels = [_epiweek_label(w) for w in weeks]
129+
keys = [_epiweek_key(w) for w in weeks]
130+
131+
# 2) Group rows by series key
132+
if isinstance(series_by, (list, tuple)):
133+
134+
def series_key_of(row):
135+
return tuple(row.get(k) for k in series_by)
136+
137+
def series_label_of(key):
138+
return " - ".join(str(k) for k in key)
139+
140+
else:
141+
142+
def series_key_of(row):
143+
return row.get(series_by)
144+
145+
def series_label_of(key):
146+
return str(key)
147+
148+
series_to_values: dict[object, dict[int, float]] = {}
149+
for row in api_rows:
150+
tv = row.get("time_value")
151+
# If the API returned daily values (YYYYMMDD), convert to epiweek key (YYYYWW)
152+
if tv is not None and (row.get("time_type") == "day"):
153+
try:
154+
tv_str = str(tv)
155+
year = int(tv_str[0:4])
156+
month = int(tv_str[4:6])
157+
day = int(tv_str[6:8])
158+
d = datetime(year, month, day).date()
159+
w = Week.fromdate(d)
160+
tv = _epiweek_key(w)
161+
except Exception:
162+
# Skip malformed dates
163+
tv = None
164+
if tv is None:
165+
continue
166+
skey = series_key_of(row)
167+
if skey not in series_to_values:
168+
series_to_values[skey] = {}
169+
# last one wins if duplicates
170+
series_to_values[skey][tv] = row.get("value", None)
171+
172+
# 3) Align each series to the epiweek axis, filling with None
173+
datasets = []
174+
for skey, tv_map in series_to_values.items():
175+
data = [tv_map.get(k, None) for k in keys]
176+
datasets.append({"label": series_label_of(skey), "data": data})
177+
178+
return {"labels": labels, "datasets": datasets}
179+
180+
181+
def normalize_dataset(data):
182+
"""
183+
Normalize a dataset to 0-100% range based on its min/max.
184+
Preserves None values for missing data.
185+
"""
186+
# Filter out None values for min/max calculation
187+
numeric_values = [v for v in data if v is not None and not (isinstance(v, float) and (v != v or v in (float('inf'), float('-inf'))))]
188+
189+
if not numeric_values:
190+
return data # Return as-is if no valid numeric values
191+
192+
min_val = min(numeric_values)
193+
max_val = max(numeric_values)
194+
range_val = (max_val - min_val) or 1 # Avoid division by zero
195+
196+
# Normalize each value
197+
normalized = []
198+
for value in data:
199+
if value is None:
200+
normalized.append(None)
201+
elif isinstance(value, float) and (value != value or value in (float('inf'), float('-inf'))):
202+
normalized.append(None)
203+
else:
204+
normalized.append(((value - min_val) / range_val) * 100)
205+
206+
return normalized
207+
208+
209+
def get_chart_data(indicators, geography):
210+
chart_data = {"labels": [], "datasets": []}
211+
geo_type, geo_value = geography.split(":")
212+
geo_display_name = GeographyUnit.objects.get(
213+
geo_level__name=geo_type, geo_id=geo_value
214+
).display_name
215+
for indicator in indicators:
216+
title = generate_epivis_custom_title(indicator, geo_display_name)
217+
color = generate_random_color()
218+
data = get_covidcast_data(
219+
indicator, "2010-01-01", "2025-01-31", geography, settings.EPIDATA_API_KEY
220+
)
221+
if data:
222+
series = prepare_chart_series_multi(
223+
data,
224+
"2020-01-01",
225+
"2025-01-31",
226+
series_by="signal", # label per indicator (adjust to ("signal","geo_value") if needed)
227+
)
228+
# Apply readable label, color, and normalize data for each dataset
229+
for ds in series["datasets"]:
230+
ds["label"] = f"{title} - {ds['label']}"
231+
ds["borderColor"] = color
232+
ds["backgroundColor"] = f"{color}33"
233+
# Normalize data to 0-100% range
234+
if ds.get("data"):
235+
ds["data"] = normalize_dataset(ds["data"])
236+
# Initialize labels once; assume same date range for all
237+
if not chart_data["labels"]:
238+
chart_data["labels"] = series["labels"]
239+
chart_data["datasets"].extend(series["datasets"])
240+
return chart_data

src/alternative_interface/views.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from django.shortcuts import render
2+
from indicators.models import Indicator
3+
from base.models import Pathogen
4+
5+
from alternative_interface.utils import get_available_geos, get_chart_data
6+
7+
8+
HEADER_DESCRIPTION = "Discover, display and download real-time infectious disease indicators (time series) that track a variety of pathogens, diseases and syndromes in a variety of locations (primarily within the USA). Browse the list, or filter it first by locations and pathogens of interest, by surveillance categories, and more. Expand any row to expose and select from a set of related indicators, then hit 'Show Selected Indicators' at bottom to plot or export your selected indicators, or to generate code snippets to retrieve them from the Delphi Epidata API. Most indicators are served from the Delphi Epidata real-time repository, but some may be available only from third parties or may require prior approval."
9+
10+
11+
def alternative_interface_view(request):
12+
try:
13+
ctx = {}
14+
ctx["header_description"] = HEADER_DESCRIPTION
15+
16+
# Get filters from URL parameters
17+
pathogen_filter = request.GET.get("pathogen", "")
18+
geography_filter = request.GET.get("geography", "")
19+
ctx["selected_pathogen"] = pathogen_filter
20+
ctx["selected_geography"] = geography_filter
21+
22+
# Build queryset with optional filtering
23+
indicators_qs = Indicator.objects.filter(
24+
use_in_express_interface=True
25+
).prefetch_related("pathogens", "available_geographies", "indicator_set")
26+
27+
# Fetch pathogens for dropdown
28+
pathogens_qs = Pathogen.objects.filter(
29+
id__in=indicators_qs.values_list("pathogens", flat=True)
30+
).order_by("display_order_number")
31+
ctx["pathogens"] = list[Pathogen](pathogens_qs)
32+
33+
if pathogen_filter:
34+
indicators_qs = indicators_qs.filter(
35+
pathogens__id=pathogen_filter,
36+
)
37+
38+
# Convert to list of dictionaries
39+
ctx["indicators"] = [
40+
{
41+
"_endpoint": (
42+
indicator.indicator_set.epidata_endpoint
43+
if indicator.indicator_set
44+
else ""
45+
),
46+
"name": indicator.name,
47+
"data_source": indicator.source.name if indicator.source else "Unknown",
48+
"time_type": indicator.time_type,
49+
"indicator_set_short_name": (
50+
indicator.indicator_set.short_name
51+
if indicator.indicator_set
52+
else "Unknown"
53+
),
54+
"member_short_name": (
55+
indicator.member_short_name
56+
if indicator.member_short_name
57+
else "Unknown"
58+
),
59+
}
60+
for indicator in indicators_qs
61+
]
62+
63+
ctx["available_geos"] = get_available_geos(ctx["indicators"])
64+
65+
if geography_filter:
66+
ctx["chart_data"] = get_chart_data(ctx["indicators"], geography_filter)
67+
else:
68+
ctx["chart_data"] = []
69+
return render(
70+
request, "alternative_interface/alter_dashboard.html", context=ctx
71+
)
72+
except Exception as e:
73+
from django.http import HttpResponse
74+
75+
return HttpResponse(f"Error loading page: {str(e)}")

0 commit comments

Comments
 (0)