diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 4ece051..dda8a8a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -381,16 +381,16 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_ - **Checkpoint**: Market Share tab renders real data, responds to filters, icicle still works ### 9.4 Pathway Cost Effectiveness chart (Tab 3) -- [ ] Create `dash_app/callbacks/pathway_costs.py`: +- [x] Create `dash_app/callbacks/pathway_costs.py`: - Build horizontal lollipop chart from `get_pathway_costs()` data - Y-axis = pathway label (e.g., "Adalimumab → Secukinumab → Rituximab"), X-axis = £ per patient per annum - Dot size = patient count, colour gradient: green (cheap) → amber → red (expensive) - Uses `parse_pathway_drugs()` to extract pathway labels -- [ ] Add retention rate annotations using `calculate_retention_rate()` +- [x] Add retention rate annotations using `calculate_retention_rate()` - Show as secondary annotation: "Drug B retains 72% of patients" -- [ ] Create figure function in `src/visualization/` -- [ ] Wire into tab switching -- **Checkpoint**: Cost Effectiveness tab renders with lollipop dots and retention annotations +- [x] Create figure function in `src/visualization/` +- [x] Wire into tab switching +- **Checkpoint**: Cost Effectiveness tab renders with lollipop dots and retention annotations ✓ ### 9.5 Cost Waterfall chart (Tab 4) - [ ] Create `dash_app/callbacks/cost_waterfall.py`: diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py index 7446528..dd8f754 100644 --- a/dash_app/callbacks/chart.py +++ b/dash_app/callbacks/chart.py @@ -108,6 +108,33 @@ def _render_market_share(app_state, title): return create_market_share_figure(data, title) +def _render_cost_effectiveness(app_state, chart_data, title): + """Build the cost effectiveness lollipop figure from current filter state.""" + from dash_app.data.queries import get_pathway_costs + from data_processing.parsing import calculate_retention_rate + from visualization.plotly_generator import create_cost_effectiveness_figure + + filter_id = (app_state or {}).get("date_filter_id", "all_6mo") + chart_type = (app_state or {}).get("chart_type", "directory") + + selected_dirs = (app_state or {}).get("selected_directorates") or [] + selected_trusts = (app_state or {}).get("selected_trusts") or [] + directory = selected_dirs[0] if len(selected_dirs) == 1 else None + trust = selected_trusts[0] if len(selected_trusts) == 1 else None + + try: + data = get_pathway_costs(filter_id, chart_type, directory, trust) + except Exception: + log.exception("Failed to load pathway cost data") + return _empty_figure("Failed to load pathway cost data.") + + if not data: + return _empty_figure("No pathway cost data available.\nTry adjusting your filters.") + + retention = calculate_retention_rate(data) + return create_cost_effectiveness_figure(data, retention, title) + + def register_chart_callbacks(app): """Register tab switching, pathway data loading, and chart rendering callbacks.""" @@ -224,6 +251,9 @@ def register_chart_callbacks(app): elif active_tab == "market-share": fig = _render_market_share(app_state, title) + elif active_tab == "cost-effectiveness": + fig = _render_cost_effectiveness(app_state, chart_data, title) + else: # Placeholder for charts not yet implemented tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab) diff --git a/src/data_processing/parsing.py b/src/data_processing/parsing.py index 0ff94a3..bdcc32d 100644 --- a/src/data_processing/parsing.py +++ b/src/data_processing/parsing.py @@ -64,6 +64,11 @@ def parse_pathway_drugs(ids, level): return segments[3:] +def _get_patients(node): + """Get patient count from a node dict (supports both 'value' and 'patients' keys).""" + return node.get("value") or node.get("patients") or 0 + + def calculate_retention_rate(nodes): """Calculate pathway retention rates from node data. @@ -71,8 +76,8 @@ def calculate_retention_rate(nodes): to an N+1 drug pathway. This identifies effective treatment sequences. Args: - nodes: List of dicts with 'ids', 'level', 'value' keys. - Should contain level 3+ nodes from a single directorate. + nodes: List of dicts with 'ids', 'level', and 'value' or 'patients' keys. + Should contain level 4+ nodes (pathway level). Returns: Dict mapping pathway ids to retention info: @@ -92,14 +97,14 @@ def calculate_retention_rate(nodes): continue node_ids = node.get("ids", "") - total_patients = node.get("value", 0) + total_patients = _get_patients(node) if not total_patients: continue # Find child pathways (nodes whose ids start with this node's ids + " - ") child_prefix = node_ids + " - " child_patients = sum( - n.get("value", 0) + _get_patients(n) for n in nodes if n.get("ids", "").startswith(child_prefix) and n.get("level", 0) == level + 1 ) diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index 315773c..bbee0ce 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -381,6 +381,184 @@ def create_market_share_figure(data: list[dict], title: str = "") -> go.Figure: return fig +def create_cost_effectiveness_figure( + data: list[dict], + retention: dict, + title: str = "", +) -> go.Figure: + """ + Create horizontal lollipop chart showing pathway cost per patient per annum. + + Args: + data: List of dicts from get_pathway_costs() with keys: + ids, pathway_label, cost_pp_pa, patients, cost, avg_days, + directory, trust_name, drug_sequence, level. + Sorted by cost_pp_pa desc. + retention: Dict from calculate_retention_rate() mapping ids to retention + info: {retained_patients, total_patients, retention_rate, drug_sequence}. + title: Chart title suffix (filter description). + + Returns: + Plotly Figure with horizontal lollipop dots and retention annotations. + """ + if not data: + return go.Figure() + + # Filter to pathways with positive cost + filtered = [d for d in data if d["cost_pp_pa"] > 0] + if not filtered: + return go.Figure() + + # Cap to top 40 pathways by cost to keep chart readable + filtered = filtered[:40] + + # Reverse for horizontal chart (highest cost at top) + filtered = list(reversed(filtered)) + + pathway_labels = [d["pathway_label"] for d in filtered] + costs = [d["cost_pp_pa"] for d in filtered] + patients = [d["patients"] for d in filtered] + + # Colour gradient: green (cheap) → amber → red (expensive) + max_cost = max(costs) if costs else 1 + min_cost = min(costs) if costs else 0 + cost_range = max_cost - min_cost if max_cost != min_cost else 1 + + colours = [] + for c in costs: + ratio = (c - min_cost) / cost_range + if ratio < 0.33: + colours.append("#009639") # NHS green + elif ratio < 0.66: + colours.append("#ED8B00") # NHS warm yellow + else: + colours.append("#DA291C") # NHS red + + # Dot size scaled by patient count (min 8, max 30) + max_pts = max(patients) if patients else 1 + min_pts = min(patients) if patients else 1 + pts_range = max_pts - min_pts if max_pts != min_pts else 1 + sizes = [8 + (p - min_pts) / pts_range * 22 for p in patients] + + # Build hover text with retention info + hover_texts = [] + for d in filtered: + retention_info = retention.get(d["ids"], {}) + retention_rate = retention_info.get("retention_rate") + drugs_in_seq = len(d["drug_sequence"]) + + hover = ( + f"{d['pathway_label']}
" + f"Cost p.p.p.a.: £{d['cost_pp_pa']:,.0f}
" + f"Patients: {d['patients']:,}
" + f"Total cost: £{d['cost']:,.0f}
" + f"Avg duration: {d['avg_days']:,.0f} days
" + f"Directorate: {d['directory']}
" + f"Treatment lines: {drugs_in_seq}" + ) + if retention_rate is not None: + hover += f"
Retention: {retention_rate:.0f}% (no further switch)" + hover_texts.append(hover) + + # Lollipop sticks (horizontal lines from 0 to cost) + stick_traces = [] + for i, (label, cost) in enumerate(zip(pathway_labels, costs)): + stick_traces.append( + go.Scatter( + x=[0, cost], + y=[label, label], + mode="lines", + line=dict(color="#CBD5E1", width=1.5), + showlegend=False, + hoverinfo="skip", + ) + ) + + # Lollipop dots + dot_trace = go.Scatter( + x=costs, + y=pathway_labels, + mode="markers", + marker=dict( + size=sizes, + color=colours, + line=dict(color="#FFFFFF", width=1), + ), + hovertemplate="%{customdata}", + customdata=hover_texts, + showlegend=False, + ) + + display_title = ( + f"Pathway Cost Effectiveness — {title}" if title + else "Pathway Cost Effectiveness (£ per patient per annum)" + ) + + fig = go.Figure(data=stick_traces + [dot_trace]) + + # Add retention annotations for pathways with notable retention + annotation_count = 0 + for d in filtered: + ret = retention.get(d["ids"], {}) + rate = ret.get("retention_rate") + if rate is not None and rate < 90 and d["patients"] >= 10 and annotation_count < 8: + fig.add_annotation( + x=d["cost_pp_pa"], + y=d["pathway_label"], + text=f"{rate:.0f}% retain", + showarrow=False, + xanchor="left", + xshift=10, + font=dict(size=10, color="#768692", family="Source Sans 3"), + ) + annotation_count += 1 + + fig.update_layout( + title=dict( + text=display_title, + font=dict( + family="Source Sans 3, system-ui, sans-serif", + size=18, + color="#1E293B", + ), + x=0.5, + xanchor="center", + ), + xaxis=dict( + title="£ per patient per annum", + tickprefix="£", + tickformat=",", + gridcolor="#E2E8F0", + zeroline=True, + zerolinecolor="#CBD5E1", + ), + yaxis=dict( + title="", + automargin=True, + tickfont=dict(size=11), + ), + margin=dict(t=50, l=8, r=24, b=40), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + autosize=True, + hoverlabel=dict( + bgcolor="#FFFFFF", + bordercolor="#CBD5E1", + font=dict( + family="Source Sans 3, system-ui, sans-serif", + size=13, + color="#1E293B", + ), + ), + font=dict( + family="Source Sans 3, system-ui, sans-serif", + ), + height=max(450, len(filtered) * 28 + 150), + ) + + return fig + + def save_figure_html( fig: go.Figure, save_dir: str, title: str, open_browser: bool = False ) -> str: