feat: add Dosing Interval Comparison chart (Task 9.7)

This commit is contained in:
Andrew Charlwood
2026-02-06 19:58:28 +00:00
parent b1136ad7bf
commit 02fe4b4e28
4 changed files with 322 additions and 4 deletions
+4 -4
View File
@@ -412,13 +412,13 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_
- **Checkpoint**: Sankey tab renders real drug transition flows ✓ - **Checkpoint**: Sankey tab renders real drug transition flows ✓
### 9.7 Dosing Interval Comparison chart (Tab 6) ### 9.7 Dosing Interval Comparison chart (Tab 6)
- [ ] Create `dash_app/callbacks/dosing.py`: - [x] Create `dash_app/callbacks/dosing.py`:
- Build horizontal grouped bar chart from `get_dosing_intervals()` data - Build horizontal grouped bar chart from `get_dosing_intervals()` data
- Uses `parse_average_spacing()` to extract weekly interval numbers - Uses `parse_average_spacing()` to extract weekly interval numbers
- Y-axis = trust or directorate, X-axis = weekly interval - Y-axis = trust or directorate, X-axis = weekly interval
- [ ] Create figure function in `src/visualization/` - [x] Create figure function in `src/visualization/`
- [ ] Wire into tab switching - [x] Wire into tab switching
- **Checkpoint**: Dosing tab renders real data with parsed interval numbers - **Checkpoint**: Dosing tab renders real data with parsed interval numbers
### 9.8 Directorate × Drug Heatmap chart (Tab 7) ### 9.8 Directorate × Drug Heatmap chart (Tab 7)
- [ ] Create `dash_app/callbacks/heatmap.py`: - [ ] Create `dash_app/callbacks/heatmap.py`:
+30
View File
@@ -183,6 +183,33 @@ def _render_sankey(app_state, title):
return create_sankey_figure(data, title) return create_sankey_figure(data, title)
def _render_dosing(app_state, title):
"""Build the dosing interval figure from current filter state."""
from dash_app.data.queries import get_dosing_intervals
from visualization.plotly_generator import create_dosing_figure
filter_id = (app_state or {}).get("date_filter_id", "all_6mo")
chart_type = (app_state or {}).get("chart_type", "directory")
selected_drugs = (app_state or {}).get("selected_drugs") or []
selected_trusts = (app_state or {}).get("selected_trusts") or []
drug = selected_drugs[0] if len(selected_drugs) == 1 else None
trust = selected_trusts[0] if len(selected_trusts) == 1 else None
try:
data = get_dosing_intervals(filter_id, chart_type, drug, trust)
except Exception:
log.exception("Failed to load dosing interval data")
return _empty_figure("Failed to load dosing interval data.")
if not data:
return _empty_figure("No dosing interval data available.\nTry adjusting your filters.")
# Single drug selected → show per-trust comparison; otherwise overview
group_by = "trust" if drug else "drug"
return create_dosing_figure(data, title, group_by)
def register_chart_callbacks(app): def register_chart_callbacks(app):
"""Register tab switching, pathway data loading, and chart rendering callbacks.""" """Register tab switching, pathway data loading, and chart rendering callbacks."""
@@ -308,6 +335,9 @@ def register_chart_callbacks(app):
elif active_tab == "sankey": elif active_tab == "sankey":
fig = _render_sankey(app_state, title) fig = _render_sankey(app_state, title)
elif active_tab == "dosing":
fig = _render_dosing(app_state, title)
else: else:
# Placeholder for charts not yet implemented # Placeholder for charts not yet implemented
tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab) tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab)
+57
View File
@@ -1623,3 +1623,60 @@ Console error: `WARN: Multiple implied roots, cannot build icicle hierarchy of t
- Note: Sankey has ordinal suffixes (e.g., "ADALIMUMAB (1st)") to prevent self-loops - Note: Sankey has ordinal suffixes (e.g., "ADALIMUMAB (1st)") to prevent self-loops
### Blocked items: ### Blocked items:
- None - None
## Iteration 29 — 2026-02-06
### Task: Phase 9 — Task 9.7 (Dosing Interval Comparison chart — Tab 6)
### Why this task:
- Tasks 9.19.6 complete. Task 9.7 is next in sequence.
- Progress.txt from iteration 28 explicitly recommended this task.
- Uses existing `get_dosing_intervals()` query and `parse_average_spacing()` parsing.
### Status: COMPLETE
### What was done:
- **Created `create_dosing_figure(data, title, group_by)` in `src/visualization/plotly_generator.py`** (~180 lines):
- Two modes via `group_by` parameter:
- **"drug" (overview)**: Weighted average weekly interval per drug, one horizontal bar per drug sorted by patient count. Bars coloured by interval (NHS blue gradient — darker = more frequent dosing). Patient count annotations on right side.
- **"trust" (per-drug comparison)**: Grouped horizontal bars per trust, one trace per directory. Enables cross-trust dosing comparison for a specific drug.
- Hover shows: drug/trust, interval, avg doses, avg treatment weeks, patient count
- Dynamic height, NHS design aesthetic (Source Sans 3, transparent, clean grid)
- Helper functions: `_dosing_by_drug()` and `_dosing_by_trust()` for clean separation
- **Added `_render_dosing(app_state, title)` helper in `dash_app/callbacks/chart.py`**:
- Extracts filter params (date_filter_id, chart_type, single drug/trust)
- Auto-selects `group_by`: "trust" when single drug selected, "drug" for overview
- Calls `get_dosing_intervals()` wrapper then `create_dosing_figure()`
- Handles empty data and exceptions gracefully
- **Wired into `update_chart` dispatch**: `active_tab == "dosing"` → `_render_dosing()`
### Validation results:
- Tier 1 (Code): `from dash_app.app import app` — OK, 11 callbacks registered
- Tier 1 (App starts): `python run_dash.py` → "Dash is running on http://127.0.0.1:8050/" — no errors
- Tier 3 (Functional):
- Directory chart overview: 124 rows → 36 drugs, intervals 4.352.4 weeks — renders correctly
- Indication chart: 219 rows → 37 drugs — renders correctly
- ADALIMUMAB single drug: 14 rows → 4 directory traces, per-trust comparison — renders correctly
- NNUH trust filter: single trust reduces data — filters correctly
- Date filter (2yr_12mo): 89 rows → 29 drugs — filters correctly
- Empty data: returns empty figure — handled correctly
- Icicle still works: 293 nodes, 11,118 patients — no regression
- Market share, cost effectiveness, cost waterfall, Sankey — no regressions
### Files changed:
- `src/visualization/plotly_generator.py` — Added: `create_dosing_figure()`, `_dosing_by_drug()`, `_dosing_by_trust()` (~180 lines)
- `dash_app/callbacks/chart.py` — Added: `_render_dosing()` helper + dispatch branch
- `IMPLEMENTATION_PLAN.md` — Task 9.7 marked [x]
### Committed: [pending]
### Patterns discovered:
- Dosing chart benefits from two distinct modes: overview (all drugs, weighted averages) and comparison (single drug, per-trust). The `group_by` parameter makes this clean without separate figure functions.
- Trust names are very long (e.g., "NORFOLK AND NORWICH UNIVERSITY HOSPITALS NHS FOUNDATION TRUST") — stripping " NHS FOUNDATION TRUST" and " HOSPITALS" suffixes greatly improves y-axis readability.
- Weighted average by patient count is the right aggregation for dosing intervals — it prevents small patient groups from skewing the displayed interval.
- Bar colour gradient by interval value provides immediate visual cue: darker blue = more frequent dosing, lighter = less frequent.
### Next iteration should:
- Start Task 9.8 — Directorate × Drug Heatmap chart (Tab 7)
- Sub-steps:
1. Create figure function in `src/visualization/plotly_generator.py` — `create_heatmap_figure(data, title, metric)`
2. Build Plotly heatmap from `get_drug_directory_matrix()` data
3. Rows = directorates (sorted by total patients), Columns = drugs (sorted by frequency)
4. Cell colour = patient count (default), with possible toggle for cost or cost_pp_pa
5. Wire into `update_chart` via `_render_heatmap()` helper
6. Responds to trust filter, date filter, chart type toggle
- Read `get_drug_directory_matrix()` in pathway_queries.py for exact data shape
- Data returns: `{directories: [...], drugs: [...], matrix: {dir: {drug: {patients, cost, cost_pp_pa}}}}`
### Blocked items:
- None
+231
View File
@@ -834,6 +834,237 @@ def create_sankey_figure(
return fig return fig
def create_dosing_figure(
data: list[dict],
title: str = "",
group_by: str = "drug",
) -> go.Figure:
"""Create dosing interval comparison chart.
Shows weekly dosing intervals as horizontal bars, grouped either by drug
(overview mode) or by trust (single-drug comparison mode).
Args:
data: List of dicts from get_dosing_intervals() with keys:
drug, trust_name, directory, weekly_interval, dose_count,
total_weeks, patients.
title: Chart title suffix (filter description).
group_by: "drug" for drug-level overview (default),
"trust" for per-trust comparison of a single drug.
Returns:
Plotly Figure with horizontal grouped bar chart.
"""
if not data:
return go.Figure()
nhs_colours = [
"#005EB8", "#003087", "#41B6E6", "#0066CC", "#1E88E5",
"#4FC3F7", "#009639", "#ED8B00", "#768692", "#AE2573",
"#8A1538", "#330072", "#DA291C", "#00A499", "#425563",
]
if group_by == "trust":
# Single-drug mode: compare trusts, group bars by directory
fig = _dosing_by_trust(data, nhs_colours)
chart_title = f"Dosing Intervals by Trust"
else:
# Overview mode: weighted average per drug
fig = _dosing_by_drug(data, nhs_colours)
chart_title = "Dosing Interval Overview"
if title:
chart_title = f"{chart_title}{title}"
n_rows = len(fig.data[0].y) if fig.data else 10
fig.update_layout(
title=dict(
text=chart_title,
font=dict(
family="Source Sans 3, system-ui, sans-serif",
size=18,
color="#003087",
),
x=0.5,
xanchor="center",
),
xaxis=dict(
title="Weekly Interval (weeks between doses)",
titlefont=dict(size=13, color="#425563"),
gridcolor="rgba(66,85,99,0.1)",
zeroline=True,
zerolinecolor="rgba(66,85,99,0.2)",
),
yaxis=dict(
automargin=True,
tickfont=dict(size=11),
),
font=dict(
family="Source Sans 3, system-ui, sans-serif",
size=12,
),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=60, l=20, r=40, b=60),
height=max(450, n_rows * 40 + 150),
bargap=0.15,
bargroupgap=0.05,
showlegend=True,
legend=dict(
orientation="h",
yanchor="top",
y=-0.12,
xanchor="center",
x=0.5,
font=dict(size=11),
),
)
return fig
def _dosing_by_drug(data: list[dict], colours: list[str]) -> go.Figure:
"""Build dosing overview: one row per drug, bars per trust, showing weekly_interval."""
# Aggregate: weighted average interval per drug, summing patients
drug_agg = {}
for d in data:
drug = d["drug"]
pts = d["patients"] or 0
if drug not in drug_agg:
drug_agg[drug] = {"weighted_sum": 0.0, "total_patients": 0,
"dose_count_ws": 0.0, "total_weeks_ws": 0.0}
drug_agg[drug]["weighted_sum"] += d["weekly_interval"] * pts
drug_agg[drug]["total_patients"] += pts
drug_agg[drug]["dose_count_ws"] += d["dose_count"] * pts
drug_agg[drug]["total_weeks_ws"] += d["total_weeks"] * pts
# Build sorted list (by total patients desc)
drugs_sorted = sorted(
drug_agg.items(),
key=lambda x: x[1]["total_patients"],
)
drug_names = [d[0] for d in drugs_sorted]
intervals = []
patients_list = []
hover_texts = []
for drug, agg in drugs_sorted:
tp = agg["total_patients"]
avg_interval = agg["weighted_sum"] / tp if tp > 0 else 0
avg_doses = agg["dose_count_ws"] / tp if tp > 0 else 0
avg_weeks = agg["total_weeks_ws"] / tp if tp > 0 else 0
intervals.append(round(avg_interval, 1))
patients_list.append(tp)
hover_texts.append(
f"<b>{drug}</b><br>"
f"Avg interval: {avg_interval:.1f} weeks<br>"
f"Avg doses: {avg_doses:.1f}<br>"
f"Avg treatment: {avg_weeks:.0f} weeks<br>"
f"Patients: {tp:,}"
)
# Colour bars by interval: lower = more frequent dosing = NHS blue, higher = lighter
max_interval = max(intervals) if intervals else 1
bar_colours = []
for iv in intervals:
ratio = iv / max_interval if max_interval > 0 else 0
# Interpolate NHS blue (#005EB8) to light blue (#41B6E6)
r = int(0x00 + (0x41 - 0x00) * ratio)
g = int(0x5E + (0xB6 - 0x5E) * ratio)
b = int(0xB8 + (0xE6 - 0xB8) * ratio)
bar_colours.append(f"rgb({r},{g},{b})")
fig = go.Figure()
fig.add_trace(go.Bar(
y=drug_names,
x=intervals,
orientation="h",
marker=dict(color=bar_colours, line=dict(color="#FFFFFF", width=0.5)),
text=[f"{iv}w" for iv in intervals],
textposition="outside",
textfont=dict(size=10, color="#425563"),
customdata=list(zip(hover_texts, patients_list)),
hovertemplate="%{customdata[0]}<extra></extra>",
name="Weighted Avg Interval",
showlegend=False,
))
# Add patient count annotations on the right
for i, (drug, pts) in enumerate(zip(drug_names, patients_list)):
fig.add_annotation(
x=max(intervals) * 1.15 if intervals else 10,
y=drug,
text=f"n={pts:,}",
showarrow=False,
font=dict(size=9, color="#768692"),
xanchor="left",
)
return fig
def _dosing_by_trust(data: list[dict], colours: list[str]) -> go.Figure:
"""Build per-trust comparison: one row per trust, bars per directory, showing weekly_interval."""
from collections import defaultdict
# Group by trust × directory
trust_dir = defaultdict(list)
for d in data:
trust_dir[(d["trust_name"], d["directory"])].append(d)
# Get unique trusts and directories
trusts = sorted(set(d["trust_name"] for d in data))
directories = sorted(set(d["directory"] for d in data))
fig = go.Figure()
for i, directory in enumerate(directories):
y_labels = []
x_vals = []
hover_list = []
for trust in trusts:
entries = trust_dir.get((trust, directory))
if not entries:
continue
# Average if multiple entries per trust+directory (shouldn't happen at level 3)
avg_iv = sum(e["weekly_interval"] * (e["patients"] or 0) for e in entries)
total_pts = sum(e["patients"] or 0 for e in entries)
if total_pts == 0:
continue
avg_iv /= total_pts
avg_doses = sum(e["dose_count"] * (e["patients"] or 0) for e in entries) / total_pts
avg_weeks = sum(e["total_weeks"] * (e["patients"] or 0) for e in entries) / total_pts
# Shorten trust name for readability
short_trust = trust.replace(" NHS FOUNDATION TRUST", "").replace(" HOSPITALS", "")
y_labels.append(short_trust)
x_vals.append(round(avg_iv, 1))
hover_list.append(
f"<b>{short_trust}</b><br>"
f"Directorate: {directory}<br>"
f"Interval: {avg_iv:.1f} weeks<br>"
f"Avg doses: {avg_doses:.1f}<br>"
f"Treatment: {avg_weeks:.0f} weeks<br>"
f"Patients: {total_pts:,}"
)
if y_labels:
fig.add_trace(go.Bar(
y=y_labels,
x=x_vals,
orientation="h",
name=directory,
marker=dict(color=colours[i % len(colours)]),
customdata=hover_list,
hovertemplate="%{customdata}<extra></extra>",
))
fig.update_layout(barmode="group")
return fig
def save_figure_html( def save_figure_html(
fig: go.Figure, save_dir: str, title: str, open_browser: bool = False fig: go.Figure, save_dir: str, title: str, open_browser: bool = False
) -> str: ) -> str: