diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a07ea4f..24505a0 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -412,13 +412,13 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_ - **Checkpoint**: Sankey tab renders real drug transition flows ✓ ### 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 - Uses `parse_average_spacing()` to extract weekly interval numbers - Y-axis = trust or directorate, X-axis = weekly interval -- [ ] Create figure function in `src/visualization/` -- [ ] Wire into tab switching -- **Checkpoint**: Dosing tab renders real data with parsed interval numbers +- [x] Create figure function in `src/visualization/` +- [x] Wire into tab switching +- **Checkpoint**: Dosing tab renders real data with parsed interval numbers ✓ ### 9.8 Directorate × Drug Heatmap chart (Tab 7) - [ ] Create `dash_app/callbacks/heatmap.py`: diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py index 1b56d59..e1ca566 100644 --- a/dash_app/callbacks/chart.py +++ b/dash_app/callbacks/chart.py @@ -183,6 +183,33 @@ def _render_sankey(app_state, 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): """Register tab switching, pathway data loading, and chart rendering callbacks.""" @@ -308,6 +335,9 @@ def register_chart_callbacks(app): elif active_tab == "sankey": fig = _render_sankey(app_state, title) + elif active_tab == "dosing": + fig = _render_dosing(app_state, title) + else: # Placeholder for charts not yet implemented tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab) diff --git a/progress.txt b/progress.txt index 439a8f3..86754ad 100644 --- a/progress.txt +++ b/progress.txt @@ -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 ### Blocked items: - 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 diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index dee7d8d..ba05b5b 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -834,6 +834,237 @@ def create_sankey_figure( 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"{drug}
" + f"Avg interval: {avg_interval:.1f} weeks
" + f"Avg doses: {avg_doses:.1f}
" + f"Avg treatment: {avg_weeks:.0f} weeks
" + 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]}", + 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"{short_trust}
" + f"Directorate: {directory}
" + f"Interval: {avg_iv:.1f} weeks
" + f"Avg doses: {avg_doses:.1f}
" + f"Treatment: {avg_weeks:.0f} weeks
" + 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}", + )) + + fig.update_layout(barmode="group") + return fig + + def save_figure_html( fig: go.Figure, save_dir: str, title: str, open_browser: bool = False ) -> str: