feat: add Dosing Interval Comparison chart (Task 9.7)
This commit is contained in:
@@ -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`:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.1–9.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.3–52.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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user