From d539a484b3fa7907e1c53ca3d7188d9f2dc515e7 Mon Sep 17 00:00:00 2001 From: edithatogo <15080672+edithatogo@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:53:42 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Optimize=20load=5Fpublic=5Fsources?= =?UTF-8?q?=20with=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💡 What: Add `@functools.cache` decorator to `load_public_sources()`. 🎯 Why: The function redundantly parsed YAML data on every call when verifying readiness. 📊 Measured Improvement: Calling the function 50 times dropped execution time from ~9-10ms down to ~0.0001ms per iteration, essentially eliminating the YAML parsing overhead on subsequent calls. --- dash_app/__init__.py | 1 - dash_app/app.py | 205 +- gtpcnz/__init__.py | 1 - models/primarycare_model/abm.py | 47 +- models/primarycare_model/app.py | 852 ++++++-- .../calibration_target_readiness.py | 10 +- .../calibration_validation_gates.py | 29 +- .../posterior_predictive_checks.py | 8 +- .../public_aggregate_calibration.py | 24 +- .../calibration/public_holdout_validation.py | 14 +- .../public_policy_shock_plausibility.py | 24 +- .../public_temporal_holdout_validation.py | 9 +- models/primarycare_model/calibration_v150.py | 145 +- .../contracts/calibration_targets.py | 12 +- models/primarycare_model/contracts/oia.py | 6 +- .../contracts/public_parameters.py | 4 +- models/primarycare_model/dashboard_service.py | 427 +++- .../data/public_processed_schema.py | 4 +- .../data/public_source_fetch.py | 16 +- .../data/public_source_readiness_matrix.py | 24 +- .../data/public_source_snapshot.py | 6 +- .../data/public_source_transforms.py | 21 +- .../public_temporal_period_acquisition.py | 7 +- .../data/public_validation_sources.py | 8 +- models/primarycare_model/data_layer.py | 266 +-- .../primarycare_model/demonstrative_games.py | 347 ++- models/primarycare_model/diffusion.py | 40 +- .../empirical_calibration.py | 87 +- .../primarycare_model/engines/abm_adapter.py | 10 +- .../engines/diffusion_adapter.py | 14 +- .../engines/jax_mc_adapter.py | 65 +- .../primarycare_model/engines/mpc_adapter.py | 89 +- .../engines/nash_opt_adapter.py | 55 +- .../primarycare_model/engines/sd_adapter.py | 47 +- .../engines/sensitivity_adapter.py | 57 +- .../evidence/public_evidence_monitor.py | 40 +- .../full_parameterised_model_v170.py | 1868 ++++++++++++++--- models/primarycare_model/game.py | 10 +- models/primarycare_model/gnn_pathways.py | 5 +- models/primarycare_model/hybrid_model.py | 12 +- models/primarycare_model/ipc.py | 106 +- models/primarycare_model/jax_mc.py | 8 +- models/primarycare_model/mcda.py | 714 ++++++- models/primarycare_model/nash_opt.py | 98 +- .../pages/2_kairos_abm_playback.py | 67 +- .../pages/3_bass_diffusion.py | 61 +- .../pages/4_nash_convergence.py | 70 +- .../pages/5_monte_carlo_histogram.py | 81 +- models/primarycare_model/privacy.py | 1 + models/primarycare_model/runtime_lab.py | 829 +++++--- models/primarycare_model/scenario_service.py | 60 +- models/primarycare_model/schemas.py | 19 +- models/primarycare_model/shap_explainer.py | 52 +- models/primarycare_model/ui/accessibility.py | 13 +- models/primarycare_model/ui/cockpit.py | 13 +- .../uncertainty/structural_ensemble.py | 14 +- .../validation/arrow_schemas.py | 380 ++-- .../validation/registry_loader.py | 16 +- .../validation/runtime_checks.py | 7 +- models/tests/test_app.py | 5 +- models/tests/test_concern_boundaries.py | 7 +- .../tests/test_conductor_parallel_tracks.py | 4 +- models/tests/test_contract_registries.py | 230 +- models/tests/test_dashboard_service.py | 11 +- models/tests/test_engine_adapters.py | 426 +++- models/tests/test_game_formulas.py | 34 +- .../test_pho_services_agreement_transform.py | 16 +- models/tests/test_property_based.py | 79 +- models/tests/test_property_invariants.py | 6 +- .../test_public_policy_shock_plausibility.py | 11 +- .../tests/test_public_site_visual_contract.py | 5 +- models/tests/test_public_source_fetch.py | 4 +- .../test_public_source_retrieval_plan.py | 9 +- models/tests/test_public_source_transforms.py | 10 +- .../test_public_validation_source_evidence.py | 16 +- models/tests/test_release_engineering.py | 8 +- models/tests/test_streamlit_dashboard_app.py | 10 +- models/tests/test_transformed_schemas.py | 11 +- repo_scorecards.py | 101 +- scripts/bootstrap_prefix_pixi.py | 4 +- scripts/check_concern_boundaries.py | 52 +- scripts/check_dash_browser_smoke.py | 10 +- scripts/check_data_freshness.py | 5 + scripts/check_no_patient_data.py | 114 +- scripts/check_parameter_traceability.py | 14 +- scripts/check_pixi_package_manager.py | 6 +- scripts/check_public_only_boundary.py | 11 +- scripts/check_public_source_snapshot.py | 8 +- scripts/check_remote_streamlit_smoke.py | 2 +- scripts/check_repo_health.py | 14 +- .../check_substack_publication_readiness.py | 21 +- scripts/check_substack_schedule_contract.py | 1 + scripts/dev_check.py | 22 +- scripts/generate_release_manifest.py | 8 +- scripts/generate_release_model_card.py | 3 +- scripts/polish_substack_simulation_plots.py | 46 +- scripts/run_public_aggregate_calibration.py | 5 +- ...re_substack_simulation_plot_readability.py | 4 +- scripts/sync_public_mirror.py | 15 +- 99 files changed, 6754 insertions(+), 2139 deletions(-) diff --git a/dash_app/__init__.py b/dash_app/__init__.py index 786d7e2..36a9cda 100644 --- a/dash_app/__init__.py +++ b/dash_app/__init__.py @@ -1,2 +1 @@ """Dash application package for the GTPCNZ Hugging Face Space.""" - diff --git a/dash_app/app.py b/dash_app/app.py index 1be415a..58f5992 100644 --- a/dash_app/app.py +++ b/dash_app/app.py @@ -122,7 +122,10 @@ def _metric_card(metric: MetricCard) -> html.Div: def _nav() -> html.Nav: return html.Nav( - [dcc.Link(label, href=href, className="nav-link") for label, href in (*STREAMLIT_PUBLIC_TABS, *DASH_EXTRA_PUBLIC_ROUTES)], + [ + dcc.Link(label, href=href, className="nav-link") + for label, href in (*STREAMLIT_PUBLIC_TABS, *DASH_EXTRA_PUBLIC_ROUTES) + ], className="nav", ) @@ -195,7 +198,11 @@ def start_page() -> html.Main: [ html.A("GitHub Pages front door", href=links["github_pages"], className="surface-link"), html.A("Hugging Face Space", href=links["huggingface_space"], className="surface-link"), - html.A("Streamlit compatibility", href=links["streamlit_compatibility"], className="surface-link"), + html.A( + "Streamlit compatibility", + href=links["streamlit_compatibility"], + className="surface-link", + ), html.A("Substack series", href=links["substack_series"], className="surface-link"), ], className="surface-grid", @@ -245,7 +252,10 @@ def post_guide_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Reading map table"), _data_table(post_reading_map_table(), "post-guide-table", page_size=18)], className="section-band"), + html.Section( + [html.H2("Reading map table"), _data_table(post_reading_map_table(), "post-guide-table", page_size=18)], + className="section-band", + ), ] ) @@ -265,10 +275,28 @@ def current_state_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Current New Zealand reform pathway"), _data_table(current_reform_table(), "current-reform-table", page_size=8)], className="section-band"), - html.Section([html.H2("Public project status"), _data_table(public_status_table(), "public-status-table", page_size=8)], className="section-band"), + html.Section( + [ + html.H2("Current New Zealand reform pathway"), + _data_table(current_reform_table(), "current-reform-table", page_size=8), + ], + className="section-band", + ), + html.Section( + [ + html.H2("Public project status"), + _data_table(public_status_table(), "public-status-table", page_size=8), + ], + className="section-band", + ), _bundle_section(readiness, "readiness-chart"), - html.Section([html.H2("Figure and table inventory"), _data_table(figure_inventory_table(), "figure-inventory-table", page_size=12)], className="section-band"), + html.Section( + [ + html.H2("Figure and table inventory"), + _data_table(figure_inventory_table(), "figure-inventory-table", page_size=12), + ], + className="section-band", + ), ] ) @@ -307,7 +335,9 @@ def compare_page(search: str | None = None) -> html.Main: ], className="two-column", ), - html.Section([html.H2("Share this view"), _shareable_link("/reference-scenarios", search)], className="section-band"), + html.Section( + [html.H2("Share this view"), _shareable_link("/reference-scenarios", search)], className="section-band" + ), html.Section( [ dcc.Graph(id="compare-bar-chart", config={"displayModeBar": False}), @@ -320,7 +350,13 @@ def compare_page(search: str | None = None) -> html.Main: ), _bundle_section(scenario_heatmap_bundle(), "reference-heatmap", page_size=10), _bundle_section(scenario_profile_bundle("F4"), "reference-profile", page_size=10), - html.Section([html.H2("Score interpretation guide"), _data_table(score_guide_table(), "score-guide-table", page_size=8)], className="section-band"), + html.Section( + [ + html.H2("Score interpretation guide"), + _data_table(score_guide_table(), "score-guide-table", page_size=8), + ], + className="section-band", + ), _bundle_section(budget_impact_bundle(), "budget-impact", page_size=12), ] ) @@ -340,7 +376,10 @@ def microeconomics_page() -> html.Main: ], className="section-band", ), - *[_bundle_section(bundle, f"micro-{index}", page_size=12) for index, bundle in enumerate(microeconomics_bundles(), start=1)], + *[ + _bundle_section(bundle, f"micro-{index}", page_size=12) + for index, bundle in enumerate(microeconomics_bundles(), start=1) + ], ] ) @@ -359,7 +398,10 @@ def game_theory_page() -> html.Main: ], className="section-band", ), - *[_bundle_section(bundle, f"game-{index}", page_size=12) for index, bundle in enumerate(game_theory_bundles(), start=1)], + *[ + _bundle_section(bundle, f"game-{index}", page_size=12) + for index, bundle in enumerate(game_theory_bundles(), start=1) + ], ] ) @@ -371,7 +413,9 @@ def _educational_sliders(defaults: dict[str, int] | None = None) -> list[html.Di sliders.append( html.Div( [ - html.Label(definition.public_label, htmlFor=f"edu-{definition.field_name}", className="control-label"), + html.Label( + definition.public_label, htmlFor=f"edu-{definition.field_name}", className="control-label" + ), dcc.Slider( id=f"edu-{definition.field_name}", min=definition.lower_bound, @@ -433,15 +477,23 @@ def simulation_page(search: str | None = None) -> html.Main: dcc.Slider(id="simulation-draws", min=10, max=500, step=10, value=state.draws), html.Label("Months", htmlFor="simulation-months", className="control-label"), dcc.Slider(id="simulation-months", min=12, max=60, step=6, value=state.months), - html.Label("Synthetic population", htmlFor="simulation-population", className="control-label"), - dcc.Slider(id="simulation-population", min=50, max=500, step=10, value=state.population_size), + html.Label( + "Synthetic population", htmlFor="simulation-population", className="control-label" + ), + dcc.Slider( + id="simulation-population", min=50, max=500, step=10, value=state.population_size + ), html.Label("Seed", htmlFor="simulation-seed", className="control-label"), dcc.Input(id="simulation-seed", type="number", min=1, max=999999, step=1, value=state.seed), html.Div(_educational_sliders(educational_values), className="educational-grid"), html.Div( [ - html.Button("Run simulation", id="simulation-run-button", className="command-button primary"), - html.Button("Download CSV", id="simulation-download-button", className="command-button"), + html.Button( + "Run simulation", id="simulation-run-button", className="command-button primary" + ), + html.Button( + "Download CSV", id="simulation-download-button", className="command-button" + ), ], className="command-row", ), @@ -452,7 +504,10 @@ def simulation_page(search: str | None = None) -> html.Main: ], className="two-column", ), - html.Section([html.H2("Share this live model state"), _shareable_link("/live-model", search)], className="section-band"), + html.Section( + [html.H2("Share this live model state"), _shareable_link("/live-model", search)], + className="section-band", + ), html.Section( [ dcc.Store(id="simulation-summary-store"), @@ -463,7 +518,10 @@ def simulation_page(search: str | None = None) -> html.Main: ], className="chart-stack", ), - *[_bundle_section(bundle, f"live-diagnostic-{index}", page_size=12) for index, bundle in enumerate(live_model_diagnostic_bundles("F4"), start=1)], + *[ + _bundle_section(bundle, f"live-diagnostic-{index}", page_size=12) + for index, bundle in enumerate(live_model_diagnostic_bundles("F4"), start=1) + ], ] ) @@ -488,7 +546,10 @@ def evidence_page() -> html.Main: ], className="section-band", ), - *[_bundle_section(bundle, f"methodology-{index}", page_size=12) for index, bundle in enumerate(methods, start=1)], + *[ + _bundle_section(bundle, f"methodology-{index}", page_size=12) + for index, bundle in enumerate(methods, start=1) + ], html.Section( [ html.H2("Evidence and references"), @@ -511,7 +572,9 @@ def evidence_page() -> html.Main: html.Li("GitHub remains the source of truth."), html.Li("GitHub Pages remains the public narrative front door."), html.Li("Hugging Face Spaces hosts the zero-cost interactive Dash lab."), - html.Li("Streamlit remains a compatibility surface until the Dash lab passes release gates."), + html.Li( + "Streamlit remains a compatibility surface until the Dash lab passes release gates." + ), ], className="method-list", ), @@ -541,7 +604,13 @@ def explainer_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Educational parameter dictionary"), _data_table(educational_parameter_dictionary(), "educational-dictionary-table", page_size=8)], className="section-band"), + html.Section( + [ + html.H2("Educational parameter dictionary"), + _data_table(educational_parameter_dictionary(), "educational-dictionary-table", page_size=8), + ], + className="section-band", + ), _bundle_section(default_bundle, "explainer-educational", page_size=8), ] ) @@ -561,8 +630,14 @@ def evidence_oia_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Evidence table"), _data_table(evidence_table(), "evidence-oia-evidence-table", page_size=10)], className="section-band"), - html.Section([html.H2("OIA tracker"), _data_table(oia_tracker_table(), "oia-tracker-table", page_size=10)], className="section-band"), + html.Section( + [html.H2("Evidence table"), _data_table(evidence_table(), "evidence-oia-evidence-table", page_size=10)], + className="section-band", + ), + html.Section( + [html.H2("OIA tracker"), _data_table(oia_tracker_table(), "oia-tracker-table", page_size=10)], + className="section-band", + ), ] ) @@ -581,8 +656,20 @@ def calibration_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Calibration benchmark mapping"), _data_table(calibration_benchmark_table(), "calibration-benchmark-table", page_size=10)], className="section-band"), - html.Section([html.H2("Calibration readiness"), _data_table(calibration_readiness(), "calibration-readiness-table", page_size=10)], className="section-band"), + html.Section( + [ + html.H2("Calibration benchmark mapping"), + _data_table(calibration_benchmark_table(), "calibration-benchmark-table", page_size=10), + ], + className="section-band", + ), + html.Section( + [ + html.H2("Calibration readiness"), + _data_table(calibration_readiness(), "calibration-readiness-table", page_size=10), + ], + className="section-band", + ), ] ) @@ -599,8 +686,17 @@ def public_cockpit_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Required cockpit sections"), _data_table(sections, "cockpit-sections-table", page_size=12)], className="section-band"), - html.Section([html.H2("Required visuals, provenance and caveats"), _data_table(charts, "cockpit-charts-table", page_size=14)], className="section-band"), + html.Section( + [html.H2("Required cockpit sections"), _data_table(sections, "cockpit-sections-table", page_size=12)], + className="section-band", + ), + html.Section( + [ + html.H2("Required visuals, provenance and caveats"), + _data_table(charts, "cockpit-charts-table", page_size=14), + ], + className="section-band", + ), ] ) @@ -612,11 +708,17 @@ def glossary_page() -> html.Main: ("Fee-for-service", "Payment for a specific eligible service."), ("Uncapped", "No fixed global ceiling on eligible activity."), ("Controlled", "Item rules, clinical governance, documentation, audit and accountability still apply."), - ("Place-based accountability", "Responsibility for a whole local population, including hard-to-reach people."), + ( + "Place-based accountability", + "Responsibility for a whole local population, including hard-to-reach people.", + ), ("Benchmark", "A transparent model structure that still needs real calibration data."), ("Reference scenario", "A model-generated scenario already stored in the project outputs."), ("Educational explainer", "A simplified interactive teaching tool, not the model forecast."), - ("Model-generated index", "A 0-100 unitless score from benchmark logic, not an observed New Zealand outcome."), + ( + "Model-generated index", + "A 0-100 unitless score from benchmark logic, not an observed New Zealand outcome.", + ), ], columns=["Term", "Plain-English definition"], ) @@ -633,7 +735,9 @@ def glossary_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Terms"), _data_table(glossary_rows, "glossary-table", page_size=12)], className="section-band"), + html.Section( + [html.H2("Terms"), _data_table(glossary_rows, "glossary-table", page_size=12)], className="section-band" + ), ] ) @@ -662,7 +766,11 @@ def guided_page() -> html.Main: ), _bundle_section(comparison_bundle(("F0", "F4", "F8")), "guided-scenario-comparison", page_size=8), _bundle_section(microeconomics_bundles()[0], "guided-marginal-supply", page_size=8), - _bundle_section(simulation_bundle("uncertainty", scenario_id="F4", draws=50, seed=20260526), "guided-uncertainty", page_size=8), + _bundle_section( + simulation_bundle("uncertainty", scenario_id="F4", draws=50, seed=20260526), + "guided-uncertainty", + page_size=8, + ), html.Section( [ html.H2("5. Evidence gaps"), @@ -697,7 +805,11 @@ def scenario_builder_page(search: str | None = None) -> html.Main: html.Section( [ html.H2("Current scenario inputs"), - _data_table(pd.DataFrame(sorted(settings.items()), columns=["Lever", "Value"]), "scenario-builder-inputs", page_size=8), + _data_table( + pd.DataFrame(sorted(settings.items()), columns=["Lever", "Value"]), + "scenario-builder-inputs", + page_size=8, + ), ], className="section-band", ), @@ -727,7 +839,13 @@ def model_surface_page() -> html.Main: ], className="section-band", ), - html.Section([html.H2("Surface coverage status"), _data_table(model_surface_status_table(), "model-surface-status-table", page_size=16)], className="section-band"), + html.Section( + [ + html.H2("Surface coverage status"), + _data_table(model_surface_status_table(), "model-surface-status-table", page_size=16), + ], + className="section-band", + ), _bundle_section(model_gap_bundle(), "model-gap-map", page_size=12), ] ) @@ -749,9 +867,21 @@ def calibration_diagnostics_page() -> html.Main: className="section-band", ), _bundle_section(calibration_error_bundle(), "calibration-error-diagnostic", page_size=10), - html.Section([html.H2("Validation gates"), _data_table(gates, "calibration-validation-gates-table", page_size=10)], className="section-band"), - html.Section([html.H2("Posterior predictive checks"), _data_table(ppc, "calibration-ppc-table", page_size=10)], className="section-band"), - html.Section([html.H2("Registered target checks"), _data_table(checks, "calibration-target-checks-table", page_size=10)], className="section-band"), + html.Section( + [html.H2("Validation gates"), _data_table(gates, "calibration-validation-gates-table", page_size=10)], + className="section-band", + ), + html.Section( + [html.H2("Posterior predictive checks"), _data_table(ppc, "calibration-ppc-table", page_size=10)], + className="section-band", + ), + html.Section( + [ + html.H2("Registered target checks"), + _data_table(checks, "calibration-target-checks-table", page_size=10), + ], + className="section-band", + ), ] ) @@ -770,7 +900,10 @@ def advanced_visuals_page() -> html.Main: ], className="section-band", ), - *[_bundle_section(bundle, f"advanced-visual-{index}", page_size=10) for index, bundle in enumerate(advanced_visual_bundles(), start=1)], + *[ + _bundle_section(bundle, f"advanced-visual-{index}", page_size=10) + for index, bundle in enumerate(advanced_visual_bundles(), start=1) + ], ] ) diff --git a/gtpcnz/__init__.py b/gtpcnz/__init__.py index cea2b4f..1b9bddc 100644 --- a/gtpcnz/__init__.py +++ b/gtpcnz/__init__.py @@ -1,2 +1 @@ """Installable package marker for Streamlit Cloud dependency resolution.""" - diff --git a/models/primarycare_model/abm.py b/models/primarycare_model/abm.py index d873e27..39c1d53 100644 --- a/models/primarycare_model/abm.py +++ b/models/primarycare_model/abm.py @@ -280,9 +280,13 @@ def build_providers(params: ABMParameters) -> list[ProviderAgent]: capacity=capacity_base[provider_type], benefit_eligible=True, scope=PROVIDER_SCOPES[provider_type], - place_accountability=clamp(params.place_based_accountability_strength + provider_bias[provider_type]), + place_accountability=clamp( + params.place_based_accountability_strength + provider_bias[provider_type] + ), audit_strength=clamp(params.audit_intensity + 0.05 * params.item_rules_strength), - direct_claiming=clamp(params.direct_claiming_strength + 0.04 * params.scheduled_medical_benefit_strength), + direct_claiming=clamp( + params.direct_claiming_strength + 0.04 * params.scheduled_medical_benefit_strength + ), ) ) provider_id += 1 @@ -324,17 +328,30 @@ def _access_probability(self, patient: PatientAgent) -> float: + 0.08 * self.params.equity_program_strength ) trust = 0.14 * patient.trust - access = 0.25 + supply_support + trust - 0.42 * price_penalty - 0.18 * rural_penalty - 0.10 * self.params.global_cap_constraint - 0.08 * need_pressure + access = ( + 0.25 + + supply_support + + trust + - 0.42 * price_penalty + - 0.18 * rural_penalty + - 0.10 * self.params.global_cap_constraint + - 0.08 * need_pressure + ) return clamp(access) def _contact_weights(self, patient: PatientAgent) -> dict[str, float]: return { "routine": CONTACT_TYPE_WEIGHTS["routine"] + 0.05 * (1 - patient.multimorbidity), - "urgent": CONTACT_TYPE_WEIGHTS["urgent"] + 0.10 * patient.multimorbidity + 0.06 * self.params.urgent_care_effectiveness, + "urgent": CONTACT_TYPE_WEIGHTS["urgent"] + + 0.10 * patient.multimorbidity + + 0.06 * self.params.urgent_care_effectiveness, "chronic": CONTACT_TYPE_WEIGHTS["chronic"] + 0.10 * patient.multimorbidity, "medicines_review": CONTACT_TYPE_WEIGHTS["medicines_review"] + 0.06 * self.params.scope_substitution_rate, - "care_coordination": CONTACT_TYPE_WEIGHTS["care_coordination"] + 0.05 * self.params.place_based_accountability_strength, - "telehealth": CONTACT_TYPE_WEIGHTS["telehealth"] + 0.08 * self.params.telehealth_acceptability + (0.04 if patient.rural else 0.0), + "care_coordination": CONTACT_TYPE_WEIGHTS["care_coordination"] + + 0.05 * self.params.place_based_accountability_strength, + "telehealth": CONTACT_TYPE_WEIGHTS["telehealth"] + + 0.08 * self.params.telehealth_acceptability + + (0.04 if patient.rural else 0.0), } def _available_providers(self, contact_type: str, capacities: dict[int, int]) -> list[ProviderAgent]: @@ -366,7 +383,9 @@ def _choose_provider( base += 0.06 elif provider.provider_type == "paramedic": base += 0.05 - base += 0.10 * provider.place_accountability + 0.08 * provider.audit_strength + 0.06 * provider.direct_claiming + base += ( + 0.10 * provider.place_accountability + 0.08 * provider.audit_strength + 0.06 * provider.direct_claiming + ) if contact_type in {"urgent", "care_coordination"} and provider.provider_type in {"gp", "paramedic"}: base += 0.12 if contact_type == "medicines_review" and provider.provider_type == "pharmacist": @@ -385,7 +404,12 @@ def _provider_capacity_schedule(self, month: int) -> dict[int, int]: - 0.10 * self.params.market_entry_response - 0.08 * self.params.rural_loading_response ) - capacity_scale = clamp(1.0 + 0.18 * self.params.capitation_base_strength + 0.14 * self.params.scheduled_medical_benefit_strength - 0.16 * stress) + capacity_scale = clamp( + 1.0 + + 0.18 * self.params.capitation_base_strength + + 0.14 * self.params.scheduled_medical_benefit_strength + - 0.16 * stress + ) scheduled = {} for provider in self.providers: base = self._provider_base_capacity[provider.provider_id] @@ -511,7 +535,10 @@ def run(self) -> ABMResult: if contact_type == "urgent": ambulance_events += 0.18 - if contact_type in {"urgent", "care_coordination"} and provider.provider_type in {"gp", "paramedic"}: + if contact_type in {"urgent", "care_coordination"} and provider.provider_type in { + "gp", + "paramedic", + }: ambulance_events += 0.07 access_rate = resolved / total_demand if total_demand else 0.0 @@ -547,7 +574,7 @@ def run(self) -> ABMResult: + 0.012 * ambulance_events + 0.020 * admissions - 0.030 * access_rate - - 0.020 * self.params.ambulance_deflection_rate + - 0.020 * self.params.ambulance_deflection_rate, ) fiscal_risk_index = clamp( 0.18 diff --git a/models/primarycare_model/app.py b/models/primarycare_model/app.py index 53cae5d..057a111 100644 --- a/models/primarycare_model/app.py +++ b/models/primarycare_model/app.py @@ -265,10 +265,7 @@ def _render_calculation_expander( if result_validation: st.markdown(f"**Result validation**: {result_validation}") - st.markdown( - "*This is a demonstrative calculation. It uses model-generated indices, " - "not calibrated forecasts.*" - ) + st.markdown("*This is a demonstrative calculation. It uses model-generated indices, not calibrated forecasts.*") def _render_seed_control(key_suffix: str = "") -> int: @@ -287,9 +284,6 @@ def _render_seed_control(key_suffix: str = "") -> int: # ── End Phase 5 helpers ───────────────────────────────────────────────── - - - def render_reader_guide() -> None: st.markdown( """ @@ -349,7 +343,7 @@ def render_big_words_expander() -> None: - **Model-generated index** means the number comes from the benchmark logic, not from observed New Zealand outcomes. - **Educational explainer** means the slider result is a teaching aid, not a calibrated forecast. """ - ) + ) def render_reference_scenario_explainer() -> None: @@ -466,7 +460,7 @@ def _render_substack_badges(post_ids: tuple[str, ...]) -> None: badge_parts.append( f'' - f'Post {escape(post_id)}: {escape(title)}' + f"Post {escape(post_id)}: {escape(title)}" ) if badge_parts: st.markdown("".join(badge_parts), unsafe_allow_html=True) @@ -494,8 +488,12 @@ def _render_download_exports(evidence_df: pd.DataFrame) -> None: title = str(row.get("Title", "")) publisher = str(row.get("Publisher", "")) url = str(row.get("URL", "")) - ris_lines.extend(["TY - GEN", f"ID - {ref_id}", f"TI - {title}", f"PB - {publisher}", f"UR - {url}", "ER - ", ""]) - bib_lines.append(f"@misc{{{ref_id or 'reference'},\n title = {{{title.replace('{', '').replace('}', '')}}},\n publisher = {{{publisher.replace('{', '').replace('}', '')}}},\n url = {{{url.replace('{', '').replace('}', '')}}}\n}}\n") + ris_lines.extend( + ["TY - GEN", f"ID - {ref_id}", f"TI - {title}", f"PB - {publisher}", f"UR - {url}", "ER - ", ""] + ) + bib_lines.append( + f"@misc{{{ref_id or 'reference'},\n title = {{{title.replace('{', '').replace('}', '')}}},\n publisher = {{{publisher.replace('{', '').replace('}', '')}}},\n url = {{{url.replace('{', '').replace('}', '')}}}\n}}\n" + ) xml_lines.append( f' {escape(title)}{escape(url)}' ) @@ -559,9 +557,11 @@ def render_methodology_and_evidence() -> None: ref_filter = st.text_input("Filter references", "", key="methodology_reference_filter") filtered = evidence_df if ref_filter: - mask = evidence_df.astype(str).apply( - lambda col: col.str.contains(ref_filter, case=False, regex=False) - ).any(axis=1) + mask = ( + evidence_df.astype(str) + .apply(lambda col: col.str.contains(ref_filter, case=False, regex=False)) + .any(axis=1) + ) filtered = evidence_df[mask] st.dataframe( filtered, @@ -1024,24 +1024,30 @@ def render_microeconomics_access_mix_lab() -> None: st.markdown("### Microeconomics lab 4: co-payment / access barrier") with st.expander("How this works - inputs, assumptions, calculation, output"): st.markdown("#### Inputs") - st.markdown("- **Co-payment (0-100):** out-of-pocket cost burden." + st.markdown( + "- **Co-payment (0-100):** out-of-pocket cost burden." "\\n- **Local in-person capacity (0-100):** face-to-face availability." "\\n- **Digital access reach (0-100):** telehealth and online access." "\\n- **Equity protection (0-100):** safeguards against access barriers." - "\\n- **Travel friction (0-100):** distance and transport barriers.") + "\\n- **Travel friction (0-100):** distance and transport barriers." + ) st.markdown("#### Assumptions") - st.markdown("1. Barriers reduce effective access via nonlinear (diminishing_return) functions." + st.markdown( + "1. Barriers reduce effective access via nonlinear (diminishing_return) functions." "\\n2. Digital can partly substitute for in-person care for suitable needs." "\\n3. Equity protection offsets the effect of co-payment and travel barriers." - "\\n4. Deferred share is the residual - need not met through any access route.") + "\\n4. Deferred share is the residual - need not met through any access route." + ) st.markdown("#### Calculation") st.latex(r"local_share = f(capacity, copay, travel)") st.latex(r"digital_share = f(access, equity)") st.latex(r"deferred = 100 - local_share - digital_share - equity_offset") st.markdown("#### Output") - st.markdown("- **Stacked bar chart:** share of need met through local, digital, or deferred." + st.markdown( + "- **Stacked bar chart:** share of need met through local, digital, or deferred." "\\n- **Access coverage metric:** composite score." - "\\n- **Interpretation:** higher deferred share = access failure.") + "\\n- **Interpretation:** higher deferred share = access failure." + ) st.markdown( """ **What this shows:** how co-payment pressure, local in-person capacity, @@ -1127,7 +1133,9 @@ def render_microeconomics_access_mix_lab() -> None: equity_boost = diminishing_return(equity_frac, 2.5) * 0.35 digital_share_value = digital_access * complex_penalty * (0.50 + equity_boost) # Deferred share is the residual after nonlinear interaction - deferred_share = max(0.0, 100 - local_share - digital_share_value - equity_protection * 0.15 + barrier_pressure * 0.18) + deferred_share = max( + 0.0, 100 - local_share - digital_share_value - equity_protection * 0.15 + barrier_pressure * 0.18 + ) total = local_share + digital_share_value + deferred_share if total <= 0: total = 1.0 @@ -1206,14 +1214,15 @@ def render_microeconomics_lab() -> None: if st.button("Run combined analysis", key="combined_micro_btn"): with st.spinner("Computing combined microeconomics..."): base = get_runtime_scenario("F4") - modified = replace(base, copayment_burden=float(co_payment_all), - equity_protection=float(equity_all)) + modified = replace(base, copayment_burden=float(co_payment_all), equity_protection=float(equity_all)) idx = calculate_indices(modified) cols = st.columns(3) cols[0].metric("Viability", f"{idx['hybrid_viability_score']:.1f}") cols[1].metric("Access", f"{idx['access_score']:.1f}") cols[2].metric("Equity", f"{idx['equity_legitimacy_score']:.1f}") - st.caption("Combined effect of co-payment and equity on benchmark indices. Higher access + equity = better.") + st.caption( + "Combined effect of co-payment and equity on benchmark indices. Higher access + equity = better." + ) # Cluster outcome cl_df = run_outcome_clustering(n_clusters=3) render_outcome_cluster_visual( @@ -1229,21 +1238,27 @@ def render_claims_audit_game_lab() -> None: st.markdown("### Game theory lab 1: formulas do not solve games") with st.expander("How this works - inputs, assumptions, calculation, output"): st.markdown("#### Inputs") - st.markdown("- **Marginal gain (0-100):** incentive to inflate claims." + st.markdown( + "- **Marginal gain (0-100):** incentive to inflate claims." "\\n- **Audit cost/penalty (0-100):** deterrence from audit." "\\n- **Claim rule clarity (0-100):** rule transparency." - "\\n- **Place accountability (0-100):** population responsibility.") + "\\n- **Place accountability (0-100):** population responsibility." + ) st.markdown("#### Assumptions") - st.markdown("1. Honest and gaming payoffs are sigmoid functions of input signals." + st.markdown( + "1. Honest and gaming payoffs are sigmoid functions of input signals." "\\n2. Detection risk rises with audit via strategic_response." - "\\n3. The flip threshold shows where honest overtakes gaming.") + "\\n3. The flip threshold shows where honest overtakes gaming." + ) st.markdown("#### Calculation") st.latex(r"honest = f(quality, place, audit)") st.latex(r"gaming = f(gain, detection, audit)") st.markdown("#### Output") - st.markdown("- **Two payoff lines:** honest vs gaming as audit changes." + st.markdown( + "- **Two payoff lines:** honest vs gaming as audit changes." "\\n- **Flip threshold:** audit level where honest wins." - "\\n- **Interpretation:** formulas alone do not solve gaming.") + "\\n- **Interpretation:** formulas alone do not solve gaming." + ) st.markdown( """ **What this shows:** an illustrative strategic-behaviour game in which the payoff @@ -1303,15 +1318,23 @@ def render_claims_audit_game_lab() -> None: honest_bonus = strategic_response(0.42 * quality + 0.34 * place + 0.24 * audit, 0.48, 7.0) detection_risk = strategic_response(0.55 * audit + 0.25 * penalty + 0.20 * place, 0.46, 7.0) gaming_attraction = strategic_response(0.62 * gain + 0.22 * (1 - quality) + 0.16 * (1 - place), 0.42, 7.0) - honest_payoff = 48 + 34 * honest_bonus + 14 * diminishing_return(gain) - 8 * diminishing_return(audit, 2.0) # nonlinear audit penalty - gaming_payoff = 48 + 42 * gaming_attraction - 36 * detection_risk - 8 * diminishing_return(audit, 1.8) # nonlinear audit penalty + honest_payoff = ( + 48 + 34 * honest_bonus + 14 * diminishing_return(gain) - 8 * diminishing_return(audit, 2.0) + ) # nonlinear audit penalty + gaming_payoff = ( + 48 + 42 * gaming_attraction - 36 * detection_risk - 8 * diminishing_return(audit, 1.8) + ) # nonlinear audit penalty honest.append(round(honest_payoff, 1)) gaming.append(round(gaming_payoff, 1)) selected_audit = audit_cost - audit_cost % 5 selected_index = audit_levels.index(selected_audit) fig = go.Figure() - fig.add_trace(go.Scatter(x=audit_levels, y=honest, mode="lines", name="Honest claiming", line=dict(color="#2f6f67", width=3))) - fig.add_trace(go.Scatter(x=audit_levels, y=gaming, mode="lines", name="Claim inflation", line=dict(color="#c47a2c", width=3))) + fig.add_trace( + go.Scatter(x=audit_levels, y=honest, mode="lines", name="Honest claiming", line=dict(color="#2f6f67", width=3)) + ) + fig.add_trace( + go.Scatter(x=audit_levels, y=gaming, mode="lines", name="Claim inflation", line=dict(color="#c47a2c", width=3)) + ) fig.add_trace( go.Scatter( x=[selected_audit], @@ -1330,11 +1353,16 @@ def render_claims_audit_game_lab() -> None: ) with chart: st.plotly_chart(fig, width="stretch") - threshold = next((audit_levels[i] for i, value in enumerate(zip(honest, gaming, strict=False)) if value[0] >= value[1]), None) + threshold = next( + (audit_levels[i] for i, value in enumerate(zip(honest, gaming, strict=False)) if value[0] >= value[1]), None + ) metric_cols = st.columns(3) metric_cols[0].metric("Honest payoff now", f"{honest[selected_index]:.1f}") metric_cols[1].metric("Gaming payoff now", f"{gaming[selected_index]:.1f}") - metric_cols[2].metric("Flip threshold", "Above current" if threshold is not None and threshold <= selected_audit else "Not reached") + metric_cols[2].metric( + "Flip threshold", + "Above current" if threshold is not None and threshold <= selected_audit else "Not reached", + ) st.caption( "This is an illustrative game-theory simulation. It illustrates incentive direction and threshold logic, not observed compliance rates." ) @@ -1344,21 +1372,27 @@ def render_coordination_game_lab() -> None: st.markdown("### Game theory lab 2: payoff and best-response") with st.expander("How this works - inputs, assumptions, calculation, output"): st.markdown("#### Inputs") - st.markdown("- **Cooperation gain (0-100):** benefit of coordination." + st.markdown( + "- **Cooperation gain (0-100):** benefit of coordination." "\\n- **Cherry-pick gain (0-100):** benefit of selective activity." "\\n- **Equity protection (0-100):** cost of leaving patients behind." "\\n- **Scope flexibility (0-100):** workforce breadth." - "\\n- **Place accountability (0-100):** population responsibility.") + "\\n- **Place accountability (0-100):** population responsibility." + ) st.markdown("#### Assumptions") - st.markdown("1. Cooperate and cherry-pick payoffs are sigmoid functions." + st.markdown( + "1. Cooperate and cherry-pick payoffs are sigmoid functions." "\\n2. Stronger place accountability shifts advantage to cooperation." - "\\n3. All values are illustrative.") + "\\n3. All values are illustrative." + ) st.markdown("#### Calculation") st.latex(r"cooperate = f(cooperation, equity, scope, place)") st.latex(r"cherry\_pick = f(cherry, equity, scope, place)") st.markdown("#### Output") - st.markdown("- **Two payoff lines:** cooperate vs cherry-pick as place rises." - "\\n- **Coordination threshold:** place level where cooperation wins.") + st.markdown( + "- **Two payoff lines:** cooperate vs cherry-pick as place rises." + "\\n- **Coordination threshold:** place level where cooperation wins." + ) st.markdown( """ **What this shows:** a coordination game in which the value of @@ -1420,8 +1454,18 @@ def render_coordination_game_lab() -> None: cherry_pick = [] for place_level in place_levels: place = place_level / 100 - coop_signal = 0.38 * cooperation_gain / 100 + 0.25 * equity_protection / 100 + 0.20 * scope_flexibility / 100 + 0.24 * place - cherry_signal = 0.56 * cherry_pick_gain / 100 + 0.12 * (1 - equity_protection / 100) + 0.10 * (1 - scope_flexibility / 100) - 0.34 * place + coop_signal = ( + 0.38 * cooperation_gain / 100 + + 0.25 * equity_protection / 100 + + 0.20 * scope_flexibility / 100 + + 0.24 * place + ) + cherry_signal = ( + 0.56 * cherry_pick_gain / 100 + + 0.12 * (1 - equity_protection / 100) + + 0.10 * (1 - scope_flexibility / 100) + - 0.34 * place + ) coop_payoff = 46 + 48 * strategic_response(coop_signal, 0.48, 7.0) cherry_payoff = 46 + 48 * strategic_response(cherry_signal, 0.32, 7.0) cooperate.append(round(coop_payoff, 1)) @@ -1429,8 +1473,12 @@ def render_coordination_game_lab() -> None: selected_place = place_accountability - place_accountability % 5 selected_index = place_levels.index(selected_place) if selected_place in place_levels else 0 fig = go.Figure() - fig.add_trace(go.Scatter(x=place_levels, y=cooperate, mode="lines", name="Cooperate", line=dict(color="#2f6f67", width=3))) - fig.add_trace(go.Scatter(x=place_levels, y=cherry_pick, mode="lines", name="Cherry-pick", line=dict(color="#c47a2c", width=3))) + fig.add_trace( + go.Scatter(x=place_levels, y=cooperate, mode="lines", name="Cooperate", line=dict(color="#2f6f67", width=3)) + ) + fig.add_trace( + go.Scatter(x=place_levels, y=cherry_pick, mode="lines", name="Cherry-pick", line=dict(color="#c47a2c", width=3)) + ) fig.add_trace( go.Scatter( x=[selected_place], @@ -1449,7 +1497,14 @@ def render_coordination_game_lab() -> None: ) with chart: st.plotly_chart(fig, width="stretch") - threshold = next((place_levels[i] for i, value in enumerate(zip(cooperate, cherry_pick, strict=False)) if value[0] >= value[1]), None) + threshold = next( + ( + place_levels[i] + for i, value in enumerate(zip(cooperate, cherry_pick, strict=False)) + if value[0] >= value[1] + ), + None, + ) metric_cols = st.columns(3) metric_cols[0].metric("Cooperate payoff now", f"{cooperate[selected_index]:.1f}") metric_cols[1].metric("Cherry-pick payoff now", f"{cherry_pick[selected_index]:.1f}") @@ -1463,20 +1518,26 @@ def render_gaming_risk_frontier_lab() -> None: st.markdown("### Game theory lab 3: controls and gaming-risk frontier") with st.expander("How this works - inputs, assumptions, calculation, output"): st.markdown("#### Inputs") - st.markdown("- **Access gain (0-100):** policy emphasis on improving access." + st.markdown( + "- **Access gain (0-100):** policy emphasis on improving access." "\\n- **Control strength (0-100):** rules and monitoring intensity." "\\n- **Monitoring cost (0-100):** admin cost of controls." - "\\n- **Place accountability (0-100):** population responsibility.") + "\\n- **Place accountability (0-100):** population responsibility." + ) st.markdown("#### Assumptions") - st.markdown("1. Gaming risk rises with access pressure, falls with controls." + st.markdown( + "1. Gaming risk rises with access pressure, falls with controls." "\\n2. Access gain is a trade-off with gaming risk." - "\\n3. The frontier shows the policy trade-off space.") + "\\n3. The frontier shows the policy trade-off space." + ) st.markdown("#### Calculation") st.latex(r"gaming\_risk = f(access, control, place, monitoring)") st.latex(r"access\_gain = f(access, control, place, monitoring)") st.markdown("#### Output") - st.markdown("- **Gaming risk and access gain lines** as controls change." - "\\n- **Interpretation:** the frontier shows the trade-off.") + st.markdown( + "- **Gaming risk and access gain lines** as controls change." + "\\n- **Interpretation:** the frontier shows the trade-off." + ) st.markdown( """ **What this shows:** an illustrative frontier that shows how access gains can be @@ -1533,7 +1594,12 @@ def render_gaming_risk_frontier_lab() -> None: monitoring = monitoring_cost / 100 place = place_accountability / 100 risk_signal = 0.54 * access_pressure - 0.42 * control - 0.16 * place + 0.14 * monitoring - access_signal = 0.48 * access_pressure + 0.18 * diminishing_return(control) + 0.16 * place - 0.10 * diminishing_return(monitoring, 2.0) # nonlinear monitoring cost + access_signal = ( + 0.48 * access_pressure + + 0.18 * diminishing_return(control) + + 0.16 * place + - 0.10 * diminishing_return(monitoring, 2.0) + ) # nonlinear monitoring cost risk_value = 100 * strategic_response(risk_signal, 0.10, 7.0) access_value = 100 * strategic_response(access_signal, 0.35, 6.5) gaming_risk.append(round(clamp(risk_value), 1)) @@ -1615,14 +1681,15 @@ def render_game_theory_lab() -> None: if st.button("Run combined game analysis", key="combined_game_btn"): with st.spinner("Computing combined game theory..."): base = get_runtime_scenario("F4") - modified = replace(base, governance=float(audit_combined), - place_accountability=float(place_combined)) + modified = replace(base, governance=float(audit_combined), place_accountability=float(place_combined)) idx = calculate_indices(modified) cols = st.columns(3) cols[0].metric("Gaming risk", f"{idx['gaming_risk_score']:.1f}") cols[1].metric("Governance", f"{idx['governance_resilience_score']:.1f}") cols[2].metric("Viability", f"{idx['hybrid_viability_score']:.1f}") - st.caption("Combined effect of audit and place accountability. Higher governance + lower gaming risk = better.") + st.caption( + "Combined effect of audit and place accountability. Higher governance + lower gaming risk = better." + ) cl_df = run_outcome_clustering(n_clusters=3) render_outcome_cluster_visual( cl_df, @@ -1693,10 +1760,18 @@ def build_public_status_table() -> pd.DataFrame: return pd.DataFrame( [ ("Model status", "Public-data anchored benchmark", "Ready for explanation; not ready for forecasting."), - ("Dashboard status", "Educational explainer", "Shows reference indices and educational mechanisms separately."), + ( + "Dashboard status", + "Educational explainer", + "Shows reference indices and educational mechanisms separately.", + ), ("Evidence status", "Evidence readiness", "OIA/data requests still need submission or update."), ("Calibration status", "Readiness mapped", "Real linked data and validation tests still required."), - ("Claim status", "Bounded", "No precise fiscal, hospital-demand, workforce or implementation-impact claims."), + ( + "Claim status", + "Bounded", + "No precise fiscal, hospital-demand, workforce or implementation-impact claims.", + ), ("Deployment status", "Public GitHub Pages and Streamlit URLs", "Public surfaces are live and tested."), ], columns=["Area", "Current state", "What this means"], @@ -1706,42 +1781,217 @@ def build_public_status_table() -> pd.DataFrame: def build_figure_inventory_table() -> pd.DataFrame: return pd.DataFrame( [ - ("Static table", "Current reform pathway", "Current state tab", "Explains the real comparator in plain English."), - ("Static table", "Public project status", "Current state tab", "Shows what is mature and what is still early."), - ("Static diagram", "Public explainer architecture", "Current state tab", "Shows how reform, the benchmark, the educational explainer, evidence and calibration fit together."), - ("Static table", "Post reading map", "Post guide tab", "Maps posts to report sections, dashboard modules, public cards, visuals and caveats."), - ("Dynamic bar chart", "Reference scenario viability", "Reference scenarios tab", "Compares model-generated viability indices."), - ("Dynamic scatter plot", "Supply generation versus hospital pressure", "Reference scenarios tab", "Shows the trade-off between access/supply and hospital-pressure index."), - ("Dynamic heatmap", "Scenario score matrix", "Reference scenarios tab", "Shows multiple indices across scenarios at once."), - ("Dynamic radar chart", "Selected scenario profile", "Reference scenarios tab", "Shows one selected scenario across several dimensions."), - ("Dynamic bar chart", "Educational explainer output", "Educational explainer tab", "Shows simplified teaching outputs from educational slider settings."), - ("Dynamic bar chart", "Project readiness", "Current state tab", "Shows maturity of explanation, evidence, validation and calibration work."), - ("Dynamic line chart", "Marginal supply response", "Microeconomics lab tab", "Shows supply curve response as the payment signal changes."), - ("Dynamic bar chart", "Capitation budget constraint", "Microeconomics lab tab", "Shows illustrative budget versus cost pressure."), - ("Dynamic bar chart", "Scheduled activity payment", "Microeconomics lab tab", "Shows gross and net payment with controls."), - ("Dynamic stacked bar chart", "Co-payment/access barrier mix", "Microeconomics lab tab", "Shows service mix across need and access-barrier bands."), - ("Dynamic line chart", "Claims audit game", "Game theory lab tab", "Shows honest versus gaming payoffs as audit strength changes."), - ("Dynamic line chart", "Coordination game", "Game theory lab tab", "Shows cooperate versus cherry-pick incentives as place accountability changes."), - ("Dynamic line chart", "Gaming-risk frontier", "Game theory lab tab", "Shows gaming risk versus access gain as controls change."), - ("Dynamic tornado chart", "Tornado sensitivity", "Live model lab tab", "Shows OAT sensitivity of hybrid viability to each parameter lever."), - ("Dynamic waterfall chart", "Hybrid viability decomposition", "Live model lab tab", "Shows weighted component contributions to hybrid viability."), - ("Dynamic bar chart", "Ensemble Monte Carlo", "Live model lab tab", "Shows seeded stochastic uncertainty across all reference scenarios."), - ("Dynamic grouped bar chart", "Cohort-stratified comparison", "Live model lab tab", "Compares index scores under low vs high subgroup parameter settings."), - ("Dynamic bar chart", "Variance decomposition", "Live model lab tab", "Separates structural, subgroup, and stochastic variance contributions."), - ("Dynamic heatmap", "Scenario × subgroup heatmap", "Live model lab tab", "Shows hybrid viability across equity × complexity levels."), - ("Dynamic line chart", "Policy shock sequences", "Live model lab tab", "Models abrupt policy changes via stock-flow dynamics."), - ("Dynamic line chart", "Uncertainty ribbon (stock-flow)", "Live model lab tab", "Shows seeded stochastic spread around hospital pressure path."), - ("Dynamic violin chart", "Subgroup-stratified violin", "Live model lab tab", "Distribution of viability across equity subgroups."), - ("Dynamic bar chart", "Stress-test scenarios", "Live model lab tab", "Extreme-but-plausible input scenarios vs baseline."), - ("Dynamic heatmap", "Interaction scan", "Live model lab tab", "Detects equity × complexity interaction effects."), + ( + "Static table", + "Current reform pathway", + "Current state tab", + "Explains the real comparator in plain English.", + ), + ( + "Static table", + "Public project status", + "Current state tab", + "Shows what is mature and what is still early.", + ), + ( + "Static diagram", + "Public explainer architecture", + "Current state tab", + "Shows how reform, the benchmark, the educational explainer, evidence and calibration fit together.", + ), + ( + "Static table", + "Post reading map", + "Post guide tab", + "Maps posts to report sections, dashboard modules, public cards, visuals and caveats.", + ), + ( + "Dynamic bar chart", + "Reference scenario viability", + "Reference scenarios tab", + "Compares model-generated viability indices.", + ), + ( + "Dynamic scatter plot", + "Supply generation versus hospital pressure", + "Reference scenarios tab", + "Shows the trade-off between access/supply and hospital-pressure index.", + ), + ( + "Dynamic heatmap", + "Scenario score matrix", + "Reference scenarios tab", + "Shows multiple indices across scenarios at once.", + ), + ( + "Dynamic radar chart", + "Selected scenario profile", + "Reference scenarios tab", + "Shows one selected scenario across several dimensions.", + ), + ( + "Dynamic bar chart", + "Educational explainer output", + "Educational explainer tab", + "Shows simplified teaching outputs from educational slider settings.", + ), + ( + "Dynamic bar chart", + "Project readiness", + "Current state tab", + "Shows maturity of explanation, evidence, validation and calibration work.", + ), + ( + "Dynamic line chart", + "Marginal supply response", + "Microeconomics lab tab", + "Shows supply curve response as the payment signal changes.", + ), + ( + "Dynamic bar chart", + "Capitation budget constraint", + "Microeconomics lab tab", + "Shows illustrative budget versus cost pressure.", + ), + ( + "Dynamic bar chart", + "Scheduled activity payment", + "Microeconomics lab tab", + "Shows gross and net payment with controls.", + ), + ( + "Dynamic stacked bar chart", + "Co-payment/access barrier mix", + "Microeconomics lab tab", + "Shows service mix across need and access-barrier bands.", + ), + ( + "Dynamic line chart", + "Claims audit game", + "Game theory lab tab", + "Shows honest versus gaming payoffs as audit strength changes.", + ), + ( + "Dynamic line chart", + "Coordination game", + "Game theory lab tab", + "Shows cooperate versus cherry-pick incentives as place accountability changes.", + ), + ( + "Dynamic line chart", + "Gaming-risk frontier", + "Game theory lab tab", + "Shows gaming risk versus access gain as controls change.", + ), + ( + "Dynamic tornado chart", + "Tornado sensitivity", + "Live model lab tab", + "Shows OAT sensitivity of hybrid viability to each parameter lever.", + ), + ( + "Dynamic waterfall chart", + "Hybrid viability decomposition", + "Live model lab tab", + "Shows weighted component contributions to hybrid viability.", + ), + ( + "Dynamic bar chart", + "Ensemble Monte Carlo", + "Live model lab tab", + "Shows seeded stochastic uncertainty across all reference scenarios.", + ), + ( + "Dynamic grouped bar chart", + "Cohort-stratified comparison", + "Live model lab tab", + "Compares index scores under low vs high subgroup parameter settings.", + ), + ( + "Dynamic bar chart", + "Variance decomposition", + "Live model lab tab", + "Separates structural, subgroup, and stochastic variance contributions.", + ), + ( + "Dynamic heatmap", + "Scenario × subgroup heatmap", + "Live model lab tab", + "Shows hybrid viability across equity × complexity levels.", + ), + ( + "Dynamic line chart", + "Policy shock sequences", + "Live model lab tab", + "Models abrupt policy changes via stock-flow dynamics.", + ), + ( + "Dynamic line chart", + "Uncertainty ribbon (stock-flow)", + "Live model lab tab", + "Shows seeded stochastic spread around hospital pressure path.", + ), + ( + "Dynamic violin chart", + "Subgroup-stratified violin", + "Live model lab tab", + "Distribution of viability across equity subgroups.", + ), + ( + "Dynamic bar chart", + "Stress-test scenarios", + "Live model lab tab", + "Extreme-but-plausible input scenarios vs baseline.", + ), + ( + "Dynamic heatmap", + "Interaction scan", + "Live model lab tab", + "Detects equity × complexity interaction effects.", + ), ("Dynamic heatmap", "Regime sweep (2D)", "Live model lab tab", "Maps viability across 2D parameter space."), - ("Dynamic grouped bar chart", "Agent-based subgroup replay", "Live model lab tab", "Agent-level access patterns under different copayment settings."), - ("Dynamic scatter chart", "Phase portrait / vector field", "Live model lab tab", "Gradient direction of hybrid viability in 2D parameter space."), - ("Dynamic 3D surface", "3D payoff surface", "Live model lab tab", "3D surface of hybrid viability across two parameters."), - ("Static table", "Model gap map", "Live model lab tab", "Maps current, comprehensive, SOTA and bleeding-edge model assets and gaps."), - ("Animated scatter plot", "Animated parameter sweep", "Methodology and evidence tab", "Animates activity signal and governance over bounded sweep frames."), - ("Animated bar chart", "Scenario morph", "Live model lab tab", "Animates the transition from F0/current reform to the selected reference scenario."), - ("Animated scatter plot", "Animated regime sweep", "Live model lab tab", "Animates a 2D parameter sweep for the selected live scenario."), + ( + "Dynamic grouped bar chart", + "Agent-based subgroup replay", + "Live model lab tab", + "Agent-level access patterns under different copayment settings.", + ), + ( + "Dynamic scatter chart", + "Phase portrait / vector field", + "Live model lab tab", + "Gradient direction of hybrid viability in 2D parameter space.", + ), + ( + "Dynamic 3D surface", + "3D payoff surface", + "Live model lab tab", + "3D surface of hybrid viability across two parameters.", + ), + ( + "Static table", + "Model gap map", + "Live model lab tab", + "Maps current, comprehensive, SOTA and bleeding-edge model assets and gaps.", + ), + ( + "Animated scatter plot", + "Animated parameter sweep", + "Methodology and evidence tab", + "Animates activity signal and governance over bounded sweep frames.", + ), + ( + "Animated bar chart", + "Scenario morph", + "Live model lab tab", + "Animates the transition from F0/current reform to the selected reference scenario.", + ), + ( + "Animated scatter plot", + "Animated regime sweep", + "Live model lab tab", + "Animates a 2D parameter sweep for the selected live scenario.", + ), ], columns=["Type", "Figure or table", "Location", "Purpose"], ) @@ -1813,9 +2063,7 @@ def render_readiness_chart(status_df: pd.DataFrame) -> None: ) fig.update_layout(height=360, margin=dict(l=10, r=10, t=45, b=10)) st.plotly_chart(fig, width="stretch") - st.caption( - "This readiness chart is a project-status visual, not an empirical performance result." - ) + st.caption("This readiness chart is a project-status visual, not an empirical performance result.") def render_figure_inventory() -> None: @@ -1878,7 +2126,9 @@ def cached_stock_flow(scenario_id: str, months: int) -> pd.DataFrame: @st.cache_data(show_spinner=False) -def cached_agent_lens(scenario_id: str, population_size: int, months: int, seed: int) -> tuple[pd.DataFrame, pd.DataFrame]: +def cached_agent_lens( + scenario_id: str, population_size: int, months: int, seed: int +) -> tuple[pd.DataFrame, pd.DataFrame]: return run_agent_lens(scenario_id=scenario_id, population_size=population_size, months=months, seed=seed) @@ -1972,8 +2222,7 @@ def render_scenario_profile_radar(df: pd.DataFrame) -> None: return scenario_options = { - f"{row.scenario_id} - {row.scenario_name}": row - for row in df.sort_values("scenario_id").itertuples(index=False) + f"{row.scenario_id} - {row.scenario_name}": row for row in df.sort_values("scenario_id").itertuples(index=False) } selected_label = st.selectbox("Choose a reference scenario profile", list(scenario_options)) selected = scenario_options[selected_label] @@ -2044,9 +2293,7 @@ def render_educational_chart(scores: dict[str, float]) -> None: def render_educational_parameter_dictionary() -> None: st.markdown("### Educational parameter dictionary") st.dataframe(build_educational_parameter_dictionary(), hide_index=True, width="stretch") - st.caption( - "These are policy-strength levers, not estimated parameters. They make the causal logic visible." - ) + st.caption("These are policy-strength levers, not estimated parameters. They make the causal logic visible.") def render_model_status() -> None: @@ -2178,7 +2425,11 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: st.markdown("### Calculation trace") scenario_options = list(live_df["scenario_id"]) - selected_scenario = st.selectbox("Scenario for calculation trace", scenario_options, index=scenario_options.index("F4") if "F4" in scenario_options else 0) + selected_scenario = st.selectbox( + "Scenario for calculation trace", + scenario_options, + index=scenario_options.index("F4") if "F4" in scenario_options else 0, + ) trace = calculation_trace(selected_scenario) st.dataframe(trace, hide_index=True, width="stretch") trace_fig = px.bar( @@ -2221,7 +2472,9 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: seed = col_b.number_input("Seed", min_value=1, max_value=999999, value=260526, step=1) sd = col_c.slider("Perturbation width (± fraction)", 0.01, 0.20, 0.08, 0.01) draw_frame, uncertainty_summary = cached_stochastic(selected_scenario, draws, int(seed), float(sd)) - st.caption("Calculation source: cached stochastic demo; demonstrative uncertainty only; not an empirical probability.") + st.caption( + "Calculation source: cached stochastic demo; demonstrative uncertainty only; not an empirical probability." + ) st.dataframe(uncertainty_summary, hide_index=True, width="stretch") uncertainty_fig = px.violin( draw_frame, @@ -2244,15 +2497,30 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: _render_result_manifest_badge("seeded_stochastic", selected_scenario) replay_cols = st.columns(3) replay_draws = replay_cols[0].slider( - "Replay draws (count)", 10, MAX_MONTE_CARLO_DRAWS, 50, 10, key="replay_draws", + "Replay draws (count)", + 10, + MAX_MONTE_CARLO_DRAWS, + 50, + 10, + key="replay_draws", help="Number of draws for each replay arm.", ) replay_fixed_seed = replay_cols[1].number_input( - "Fixed seed", min_value=1, max_value=999999, value=260526, step=1, key="replay_fixed_seed", + "Fixed seed", + min_value=1, + max_value=999999, + value=260526, + step=1, + key="replay_fixed_seed", help="Fixed seed for reproducible replay.", ) replay_sd = replay_cols[2].slider( - "Perturbation width (± fraction)", 0.01, 0.20, 0.08, 0.01, key="replay_sd", + "Perturbation width (± fraction)", + 0.01, + 0.20, + 0.08, + 0.01, + key="replay_sd", help="Width of the Gaussian perturbation applied to scenario parameters.", ) replay_results = run_stochastic_replay( @@ -2321,7 +2589,11 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: "Levers ranked by total absolute impact (most influential at top)." ) tornado_step = st.slider( - "Perturbation step (±)", 1, 50, 10, 1, + "Perturbation step (±)", + 1, + 50, + 10, + 1, key="tornado_step", help="How much each lever is shifted up/down from its baseline.", ) @@ -2331,20 +2603,24 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: st.dataframe(tornado_df, hide_index=True, width="stretch") tornado_fig = go.Figure() levers_sorted = tornado_df["lever"].tolist() - tornado_fig.add_trace(go.Bar( - y=levers_sorted, - x=tornado_df["low_delta_viability"], - orientation="h", - name="Low perturbation", - marker_color="#c47a2c", - )) - tornado_fig.add_trace(go.Bar( - y=levers_sorted, - x=tornado_df["high_delta_viability"], - orientation="h", - name="High perturbation", - marker_color="#2f6f67", - )) + tornado_fig.add_trace( + go.Bar( + y=levers_sorted, + x=tornado_df["low_delta_viability"], + orientation="h", + name="Low perturbation", + marker_color="#c47a2c", + ) + ) + tornado_fig.add_trace( + go.Bar( + y=levers_sorted, + x=tornado_df["high_delta_viability"], + orientation="h", + name="High perturbation", + marker_color="#2f6f67", + ) + ) tornado_fig.update_layout( title=f"Tornado: hybrid viability sensitivity ({selected_scenario})", xaxis_title="Delta vs baseline index score", @@ -2362,34 +2638,32 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: # ── Wave 1: Waterfall / decomposition chart ───────────────────────── st.markdown("### Waterfall: hybrid viability decomposition") _render_result_manifest_badge("live_deterministic", selected_scenario) - st.markdown( - "**What this shows:** how seven component indices combine to produce " - "the hybrid viability score." - ) + st.markdown("**What this shows:** how seven component indices combine to produce the hybrid viability score.") wf_df = build_waterfall_data(selected_scenario) components = wf_df[~wf_df["is_total"]]["component"].tolist() contributions = wf_df[~wf_df["is_total"]]["contribution"].tolist() total_val = wf_df[wf_df["is_total"]]["contribution"].values[0] - waterfall_fig = go.Figure(go.Waterfall( - name="Contribution", orientation="v", - measure=["relative"] * len(components) + ["total"], - x=[*components, "Hybrid viability"], - y=[*contributions, total_val], - decreasing={"marker": {"color": "#c47a2c"}}, - increasing={"marker": {"color": "#2f6f67"}}, - totals={"marker": {"color": "#4f7eb6"}}, - )) + waterfall_fig = go.Figure( + go.Waterfall( + name="Contribution", + orientation="v", + measure=["relative"] * len(components) + ["total"], + x=[*components, "Hybrid viability"], + y=[*contributions, total_val], + decreasing={"marker": {"color": "#c47a2c"}}, + increasing={"marker": {"color": "#2f6f67"}}, + totals={"marker": {"color": "#4f7eb6"}}, + ) + ) waterfall_fig.update_layout( title=f"Hybrid viability decomposition ({selected_scenario})", - height=440, margin=dict(l=10, r=10, t=45, b=10), + height=440, + margin=dict(l=10, r=10, t=45, b=10), ) st.plotly_chart(waterfall_fig, width="stretch") with st.expander("Waterfall decomposition data", expanded=False): st.dataframe(wf_df, hide_index=True, width="stretch") - st.caption( - "The waterfall shows the additive weighted structure of the " - "hybrid viability formula." - ) + st.caption("The waterfall shows the additive weighted structure of the hybrid viability formula.") st.markdown("### Agent lens") col_d, col_e, col_f = st.columns(3) @@ -2423,17 +2697,29 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: "random seed for reproducibility." ) ens_draws = st.slider( - "Ensemble draws per scenario", 10, MAX_MONTE_CARLO_DRAWS, 50, 10, + "Ensemble draws per scenario", + 10, + MAX_MONTE_CARLO_DRAWS, + 50, + 10, key="ens_draws", help="Number of Monte Carlo draws per scenario.", ) ens_seed = st.number_input( - "Ensemble seed", min_value=1, max_value=999999, value=260526, step=1, + "Ensemble seed", + min_value=1, + max_value=999999, + value=260526, + step=1, key="ens_seed", help="Fixed seed for reproducible ensemble runs.", ) ens_sd = st.slider( - "Ensemble perturbation (± fraction)", 0.01, 0.20, 0.08, 0.01, + "Ensemble perturbation (± fraction)", + 0.01, + 0.20, + 0.08, + 0.01, key="ens_sd", ) with st.spinner(f"Running {ens_draws} draws across all scenarios..."): @@ -2442,25 +2728,29 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: st.dataframe(ens_df, hide_index=True, width="stretch") ens_fig = go.Figure() scenarios_ordered = sorted(ens_df["scenario_id"].tolist()) - ens_fig.add_trace(go.Bar( - x=scenarios_ordered, - y=ens_df.set_index("scenario_id").loc[scenarios_ordered, "mean"], - name="Mean hybrid viability", - marker_color="#2f6f67", - error_y=dict( - type="data", symmetric=False, - array=ens_df.set_index("scenario_id").loc[scenarios_ordered, "p95"].values - - ens_df.set_index("scenario_id").loc[scenarios_ordered, "mean"].values, - arrayminus=ens_df.set_index("scenario_id").loc[scenarios_ordered, "mean"].values - - ens_df.set_index("scenario_id").loc[scenarios_ordered, "p05"].values, - color="#4f7eb6", - ), - )) + ens_fig.add_trace( + go.Bar( + x=scenarios_ordered, + y=ens_df.set_index("scenario_id").loc[scenarios_ordered, "mean"], + name="Mean hybrid viability", + marker_color="#2f6f67", + error_y=dict( + type="data", + symmetric=False, + array=ens_df.set_index("scenario_id").loc[scenarios_ordered, "p95"].values + - ens_df.set_index("scenario_id").loc[scenarios_ordered, "mean"].values, + arrayminus=ens_df.set_index("scenario_id").loc[scenarios_ordered, "mean"].values + - ens_df.set_index("scenario_id").loc[scenarios_ordered, "p05"].values, + color="#4f7eb6", + ), + ) + ) ens_fig.update_layout( title="Ensemble uncertainty: hybrid viability across scenarios", xaxis_title="Scenario", yaxis_title="Hybrid viability index (p05/p50/p95)", - height=440, margin=dict(l=10, r=10, t=45, b=10), + height=440, + margin=dict(l=10, r=10, t=45, b=10), ) st.plotly_chart(ens_fig, width="stretch") st.caption( @@ -2486,24 +2776,40 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: label_low = st.text_input("Label for low group", "Low value", key="cohort_label_low") label_high = st.text_input("Label for high group", "High value", key="cohort_label_high") cs_df = run_cohort_stratified( - selected_scenario, subgroup_field=cohort_field_name, - low_value=float(low_val), high_value=float(high_val), - label_low=label_low, label_high=label_high, + selected_scenario, + subgroup_field=cohort_field_name, + low_value=float(low_val), + high_value=float(high_val), + label_low=label_low, + label_high=label_high, ) with st.expander("Cohort comparison data", expanded=False): st.dataframe(cs_df, hide_index=True, width="stretch") cs_fig = go.Figure() metrics_list = cs_df["metric"].tolist() - cs_fig.add_trace(go.Bar( - x=metrics_list, y=cs_df[label_low], name=label_low, marker_color="#c47a2c", - )) - cs_fig.add_trace(go.Bar( - x=metrics_list, y=cs_df[label_high], name=label_high, marker_color="#2f6f67", - )) + cs_fig.add_trace( + go.Bar( + x=metrics_list, + y=cs_df[label_low], + name=label_low, + marker_color="#c47a2c", + ) + ) + cs_fig.add_trace( + go.Bar( + x=metrics_list, + y=cs_df[label_high], + name=label_high, + marker_color="#2f6f67", + ) + ) cs_fig.update_layout( title=f"Cohort comparison: {cohort_field_name} ({selected_scenario})", - yaxis_title="Index score (0-100)", xaxis_title="", - barmode="group", height=440, margin=dict(l=10, r=10, t=45, b=150), + yaxis_title="Index score (0-100)", + xaxis_title="", + barmode="group", + height=440, + margin=dict(l=10, r=10, t=45, b=150), ) st.plotly_chart(cs_fig, width="stretch") st.caption( @@ -2529,14 +2835,26 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: # ── Wave 2: Variance decomposition ───────────────────────────────── st.markdown("### Variance decomposition") _render_result_manifest_badge("seeded_stochastic", selected_scenario) - st.markdown("**What this shows:** separates total hybrid-viability variance into structural (parameter), subgroup (equity), and stochastic (residual) components.") + st.markdown( + "**What this shows:** separates total hybrid-viability variance into structural (parameter), subgroup (equity), and stochastic (residual) components." + ) with st.spinner("Running variance decomposition preview..."): vd_df = run_variance_decomposition(selected_scenario, draws=80, seed=260526) with st.expander("Variance decomposition data", expanded=False): st.dataframe(vd_df, hide_index=True, width="stretch") - vd_fig = px.bar(vd_df, x="source", y="variance", color="source", text="proportion", - title="Variance decomposition: what drives hybrid viability?", - color_discrete_map={"Structural (parameter)": "#2f6f67", "Subgroup (equity)": "#4f7eb6", "Stochastic (residual)": "#c47a2c"}) + vd_fig = px.bar( + vd_df, + x="source", + y="variance", + color="source", + text="proportion", + title="Variance decomposition: what drives hybrid viability?", + color_discrete_map={ + "Structural (parameter)": "#2f6f67", + "Subgroup (equity)": "#4f7eb6", + "Stochastic (residual)": "#c47a2c", + }, + ) vd_fig.update_traces(texttemplate="%{text:.1%}", textposition="outside") vd_fig.update_layout(height=400, margin=dict(l=10, r=10, t=45, b=10), showlegend=False) st.plotly_chart(vd_fig, width="stretch") @@ -2548,10 +2866,14 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: st.markdown("**What this shows:** hybrid viability across equity protection and complexity levels.") hm_df = run_heatmap_matrix(selected_scenario) cols = [c for c in hm_df.columns if c != "equity_level"] - hm_fig = px.imshow(hm_df.set_index("equity_level")[cols], aspect="auto", text_auto=True, - color_continuous_scale="Viridis", - labels={"x": "Complexity level", "y": "Equity level", "color": "Viability"}, - title=f"Scenario × subgroup heatmap ({selected_scenario})") + hm_fig = px.imshow( + hm_df.set_index("equity_level")[cols], + aspect="auto", + text_auto=True, + color_continuous_scale="Viridis", + labels={"x": "Complexity level", "y": "Equity level", "color": "Viability"}, + title=f"Scenario × subgroup heatmap ({selected_scenario})", + ) hm_fig.update_layout(height=400, margin=dict(l=10, r=10, t=45, b=10)) st.plotly_chart(hm_fig, width="stretch") with st.expander("Scenario × subgroup matrix data", expanded=False): @@ -2562,20 +2884,75 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: st.markdown("### Policy shock sequences") _render_result_manifest_badge("live_deterministic", selected_scenario) st.markdown("**What this shows:** how an abrupt policy change affects hospital and fiscal pressure over time.") - shock_field = st.selectbox("Shock parameter", ["activity_signal", "governance", "capitation", "equity_protection", "copayment_burden"], key="shock_field") + shock_field = st.selectbox( + "Shock parameter", + ["activity_signal", "governance", "capitation", "equity_protection", "copayment_burden"], + key="shock_field", + ) shock_delta = st.slider("Shock change (±)", -50, 50, -20, 5, key="shock_delta") shock_months = st.slider("Post-shock months", 12, 48, 24, 6, key="shock_months") with st.spinner("Simulating shock..."): - shock_df = run_policy_shock_sequence(selected_scenario, shock_field=shock_field, shock_delta=float(shock_delta), post_shock_months=int(shock_months)) + shock_df = run_policy_shock_sequence( + selected_scenario, + shock_field=shock_field, + shock_delta=float(shock_delta), + post_shock_months=int(shock_months), + ) shock_fig = go.Figure() - shock_fig.add_trace(go.Scatter(x=shock_df["month"], y=shock_df["baseline_hospital_pressure"], mode="lines", name="Baseline", line=dict(color="#4f7eb6", width=2, dash="dash"))) - shock_fig.add_trace(go.Scatter(x=shock_df["month"], y=shock_df["shock_hospital_pressure"], mode="lines", name="Shock", line=dict(color="#c47a2c", width=3))) - shock_fig.update_layout(title="Policy shock: hospital pressure", xaxis_title="Month", yaxis_title="Hospital pressure", height=380, margin=dict(l=10, r=10, t=45, b=10), hovermode="x unified") + shock_fig.add_trace( + go.Scatter( + x=shock_df["month"], + y=shock_df["baseline_hospital_pressure"], + mode="lines", + name="Baseline", + line=dict(color="#4f7eb6", width=2, dash="dash"), + ) + ) + shock_fig.add_trace( + go.Scatter( + x=shock_df["month"], + y=shock_df["shock_hospital_pressure"], + mode="lines", + name="Shock", + line=dict(color="#c47a2c", width=3), + ) + ) + shock_fig.update_layout( + title="Policy shock: hospital pressure", + xaxis_title="Month", + yaxis_title="Hospital pressure", + height=380, + margin=dict(l=10, r=10, t=45, b=10), + hovermode="x unified", + ) st.plotly_chart(shock_fig, width="stretch") shock_fig2 = go.Figure() - shock_fig2.add_trace(go.Scatter(x=shock_df["month"], y=shock_df["baseline_fiscal_pressure"], mode="lines", name="Baseline", line=dict(color="#4f7eb6", width=2, dash="dash"))) - shock_fig2.add_trace(go.Scatter(x=shock_df["month"], y=shock_df["shock_fiscal_pressure"], mode="lines", name="Shock", line=dict(color="#c47a2c", width=3))) - shock_fig2.update_layout(title="Fiscal pressure response", xaxis_title="Month", yaxis_title="Fiscal pressure", height=340, margin=dict(l=10, r=10, t=45, b=10), hovermode="x unified") + shock_fig2.add_trace( + go.Scatter( + x=shock_df["month"], + y=shock_df["baseline_fiscal_pressure"], + mode="lines", + name="Baseline", + line=dict(color="#4f7eb6", width=2, dash="dash"), + ) + ) + shock_fig2.add_trace( + go.Scatter( + x=shock_df["month"], + y=shock_df["shock_fiscal_pressure"], + mode="lines", + name="Shock", + line=dict(color="#c47a2c", width=3), + ) + ) + shock_fig2.update_layout( + title="Fiscal pressure response", + xaxis_title="Month", + yaxis_title="Fiscal pressure", + height=340, + margin=dict(l=10, r=10, t=45, b=10), + hovermode="x unified", + ) st.plotly_chart(shock_fig2, width="stretch") with st.expander("Policy shock sequence data", expanded=False): st.dataframe(shock_df, hide_index=True, width="stretch") @@ -2707,7 +3084,9 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: with st.expander("Agent subgroup replay data", expanded=False): st.dataframe(subgroup_summary, hide_index=True, width="stretch") subgroup_focus = subgroup_summary[ - subgroup_summary["metric"].isin(["mean_access_probability", "served_contacts", "unmet_attempts", "high_barrier_share"]) + subgroup_summary["metric"].isin( + ["mean_access_probability", "served_contacts", "unmet_attempts", "high_barrier_share"] + ) ].copy() subgroup_focus["value"] = subgroup_focus["value"].astype(float) subgroup_fig = px.bar( @@ -2758,10 +3137,45 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: with st.expander("Uncertainty ribbon data", expanded=False): st.dataframe(ribbon_df, hide_index=True, width="stretch") ribbon_fig = go.Figure() - ribbon_fig.add_trace(go.Scatter(x=ribbon_df["month"], y=ribbon_df["hp_p95"], mode="lines", line=dict(width=0), showlegend=False, name="HP p95")) - ribbon_fig.add_trace(go.Scatter(x=ribbon_df["month"], y=ribbon_df["hp_p05"], mode="lines", fill="tonexty", fillcolor="rgba(79,126,182,0.22)", line=dict(width=0), name="Hospital pressure p05-p95")) - ribbon_fig.add_trace(go.Scatter(x=ribbon_df["month"], y=ribbon_df["hp_p50"], mode="lines", line=dict(color="#4f7eb6", width=3), name="Hospital pressure p50")) - ribbon_fig.add_trace(go.Scatter(x=ribbon_df["month"], y=ribbon_df["fp_p50"], mode="lines", line=dict(color="#c47a2c", width=3), name="Fiscal pressure p50")) + ribbon_fig.add_trace( + go.Scatter( + x=ribbon_df["month"], + y=ribbon_df["hp_p95"], + mode="lines", + line=dict(width=0), + showlegend=False, + name="HP p95", + ) + ) + ribbon_fig.add_trace( + go.Scatter( + x=ribbon_df["month"], + y=ribbon_df["hp_p05"], + mode="lines", + fill="tonexty", + fillcolor="rgba(79,126,182,0.22)", + line=dict(width=0), + name="Hospital pressure p05-p95", + ) + ) + ribbon_fig.add_trace( + go.Scatter( + x=ribbon_df["month"], + y=ribbon_df["hp_p50"], + mode="lines", + line=dict(color="#4f7eb6", width=3), + name="Hospital pressure p50", + ) + ) + ribbon_fig.add_trace( + go.Scatter( + x=ribbon_df["month"], + y=ribbon_df["fp_p50"], + mode="lines", + line=dict(color="#c47a2c", width=3), + name="Fiscal pressure p50", + ) + ) ribbon_fig.update_layout( title=f"Uncertainty ribbon: stock-flow pressure paths ({selected_scenario})", xaxis_title="Month", @@ -2771,7 +3185,9 @@ def render_live_model_lab(precomputed_df: pd.DataFrame) -> None: hovermode="x unified", ) st.plotly_chart(ribbon_fig, width="stretch") - st.caption("Ribbon shows seeded p05-p95 hospital-pressure spread; medians are demonstrative, not empirical intervals.") + st.caption( + "Ribbon shows seeded p05-p95 hospital-pressure spread; medians are demonstrative, not empirical intervals." + ) st.markdown("### Value of information") _render_result_manifest_badge("seeded_stochastic") @@ -2814,9 +3230,7 @@ def render_app() -> None: slider_definitions = {definition.field_name: definition for definition in EDUCATIONAL_LEVER_DEFINITIONS} with st.sidebar.expander("Educational explainer controls", expanded=False): - st.caption( - "Teaching controls only: educational sliders do not rerun the full parameterised model." - ) + st.caption("Teaching controls only: educational sliders do not rerun the full parameterised model.") st.caption( "0 means absent/weak; 100 means strong/reliably implemented. These are qualitative teaching levers, not estimated structural parameters." ) @@ -2981,26 +3395,30 @@ def render_app() -> None: for sc in SCENARIOS: idx = calculate_indices(sc) cal = calibrate_distribution(idx) - dist_rows.append({ - "scenario_id": sc.scenario_id, - "GP visits/1000": f"{cal['dist_gp_visits_per_1000']['mean']:.0f} [{cal['dist_gp_visits_per_1000']['p05']:.0f}\u2013{cal['dist_gp_visits_per_1000']['p95']:.0f}]", - "ED/100k": f"{cal['dist_ed_per_100k']['mean']:.0f} [{cal['dist_ed_per_100k']['p05']:.0f}\u2013{cal['dist_ed_per_100k']['p95']:.0f}]", - "Admissions/100k": f"{cal['dist_admissions_per_100k']['mean']:.0f} [{cal['dist_admissions_per_100k']['p05']:.0f}\u2013{cal['dist_admissions_per_100k']['p95']:.0f}]", - "Spend/capita NZD": f"${cal['dist_spend_per_capita_nzd']['mean']:.0f} [${cal['dist_spend_per_capita_nzd']['p05']:.0f}\u2013${cal['dist_spend_per_capita_nzd']['p95']:.0f}]", - }) + dist_rows.append( + { + "scenario_id": sc.scenario_id, + "GP visits/1000": f"{cal['dist_gp_visits_per_1000']['mean']:.0f} [{cal['dist_gp_visits_per_1000']['p05']:.0f}\u2013{cal['dist_gp_visits_per_1000']['p95']:.0f}]", + "ED/100k": f"{cal['dist_ed_per_100k']['mean']:.0f} [{cal['dist_ed_per_100k']['p05']:.0f}\u2013{cal['dist_ed_per_100k']['p95']:.0f}]", + "Admissions/100k": f"{cal['dist_admissions_per_100k']['mean']:.0f} [{cal['dist_admissions_per_100k']['p05']:.0f}\u2013{cal['dist_admissions_per_100k']['p95']:.0f}]", + "Spend/capita NZD": f"${cal['dist_spend_per_capita_nzd']['mean']:.0f} [${cal['dist_spend_per_capita_nzd']['p05']:.0f}\u2013${cal['dist_spend_per_capita_nzd']['p95']:.0f}]", + } + ) for source_key, label in [ ("dist_gp_visits_per_1000", "GP visits/1000"), ("dist_ed_per_100k", "ED/100k"), ("dist_admissions_per_100k", "Admissions/100k"), ("dist_spend_per_capita_nzd", "Spend/capita NZD"), ]: - dist_plot_rows.append({ - "scenario_id": sc.scenario_id, - "metric": label, - "mean": cal[source_key]["mean"], - "p05": cal[source_key]["p05"], - "p95": cal[source_key]["p95"], - }) + dist_plot_rows.append( + { + "scenario_id": sc.scenario_id, + "metric": label, + "mean": cal[source_key]["mean"], + "p05": cal[source_key]["p05"], + "p95": cal[source_key]["p95"], + } + ) dist_df = pd.DataFrame(dist_rows) dist_plot_df = pd.DataFrame(dist_plot_rows) st.dataframe(dist_df, hide_index=True, width="stretch") @@ -3026,14 +3444,22 @@ def render_app() -> None: "combined with a Bass diffusion curve (p=0.15 innovation, q=0.40 imitation) " "to model gradual policy adoption over 5 years." ) - bi_scenarios = st.multiselect("Scenarios for budget impact", ["F0","F3","F4","F8"], default=["F0","F4"], key="bi_scenarios") - bi_pop = st.number_input("Enrolled population", min_value=100000, max_value=10000000, value=4500000, step=100000, key="bi_pop") + bi_scenarios = st.multiselect( + "Scenarios for budget impact", ["F0", "F3", "F4", "F8"], default=["F0", "F4"], key="bi_scenarios" + ) + bi_pop = st.number_input( + "Enrolled population", min_value=100000, max_value=10000000, value=4500000, step=100000, key="bi_pop" + ) with st.spinner("Computing budget impact with diffusion..."): bi_df = run_budget_impact(tuple(bi_scenarios), enrolled_population=int(bi_pop)) - st.dataframe(bi_df[~bi_df["year"].astype(str).str.isdigit()].reset_index(drop=True), hide_index=True, width="stretch") + st.dataframe( + bi_df[~bi_df["year"].astype(str).str.isdigit()].reset_index(drop=True), hide_index=True, width="stretch" + ) bi_fig = px.line( bi_df[bi_df["year"] != "Total"].astype({"year": int}), - x="year", y="discounted_budget_nzd", color="scenario_id", + x="year", + y="discounted_budget_nzd", + color="scenario_id", markers=True, title="Discounted budget impact by scenario (with Bass diffusion)", labels={"year": "Year", "discounted_budget_nzd": "Discounted budget (NZD)", "scenario_id": "Scenario"}, diff --git a/models/primarycare_model/calibration/calibration_target_readiness.py b/models/primarycare_model/calibration/calibration_target_readiness.py index 6900b21..cccb6d1 100644 --- a/models/primarycare_model/calibration/calibration_target_readiness.py +++ b/models/primarycare_model/calibration/calibration_target_readiness.py @@ -77,9 +77,7 @@ def build_calibration_target_readiness_matrix(*, strict: bool = False) -> tuple[ blockers.append(f"{target.target_id}: source {target.source_id} is not source_ready") blockers.extend(f"{target.target_id}: {issue}" for issue in source_row.issues) if relative_error > target.tolerance: - blockers.append( - f"{target.target_id}: relative_error {relative_error} exceeds tolerance {target.tolerance}" - ) + blockers.append(f"{target.target_id}: relative_error {relative_error} exceeds tolerance {target.tolerance}") calibration_gate_ready = source_ready and relative_error <= target.tolerance claim_status = "public_aggregate_target_ready" if calibration_gate_ready else "calibration_readiness_only" @@ -122,7 +120,11 @@ def readiness_matrix_as_json(*, strict: bool = False) -> str: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Report public aggregate calibration target readiness.") - parser.add_argument("--strict", action="store_true", help="Fail until every calibration target is source-ready and within tolerance.") + parser.add_argument( + "--strict", + action="store_true", + help="Fail until every calibration target is source-ready and within tolerance.", + ) parser.add_argument("--json", action="store_true", help="Print the calibration target readiness matrix as JSON.") args = parser.parse_args(argv) diff --git a/models/primarycare_model/calibration/calibration_validation_gates.py b/models/primarycare_model/calibration/calibration_validation_gates.py index 860352e..aa93782 100644 --- a/models/primarycare_model/calibration/calibration_validation_gates.py +++ b/models/primarycare_model/calibration/calibration_validation_gates.py @@ -79,7 +79,9 @@ def _has_pho_access_numeric_validation_extract(*validation_uses: str) -> bool: ) -def _strict_holdout_blockers(gate_id: str, status: ValidationGateStatus, optional_holdout_blocker: str) -> tuple[str, ...]: +def _strict_holdout_blockers( + gate_id: str, status: ValidationGateStatus, optional_holdout_blocker: str +) -> tuple[str, ...]: if status == "public_holdout_comparison_failed": return holdout_gate_blockers(gate_id) if status == "public_data_unavailable": @@ -136,7 +138,9 @@ def build_calibration_validation_gate_matrix(*, strict: bool = False) -> tuple[C public_data_requirement="Public regional or rurality aggregate extracts with held-out areas.", status=geographic_status, claim_status="calibration_readiness_only", - blockers=_strict_holdout_blockers("CAL-G-003", geographic_status, optional_holdout_blocker) if strict else (), + blockers=_strict_holdout_blockers("CAL-G-003", geographic_status, optional_holdout_blocker) + if strict + else (), ), CalibrationValidationGateRow( gate_id="CAL-G-004", @@ -188,12 +192,16 @@ def strict_validation_gate_issues() -> tuple[str, ...]: def validation_gate_issues(*, require_all_validation_data: bool) -> tuple[str, ...]: issues: list[str] = [] for row in build_calibration_validation_gate_matrix(strict=True): - if row.status in { - "public_data_unavailable", - "public_validation_source_registered", - "public_validation_numeric_ready", - "public_holdout_comparison_failed", - } and not require_all_validation_data: + if ( + row.status + in { + "public_data_unavailable", + "public_validation_source_registered", + "public_validation_numeric_ready", + "public_holdout_comparison_failed", + } + and not require_all_validation_data + ): continue if row.status != "passed" and row.gate_id != "CAL-G-007": issues.append(f"{row.gate_id}: {row.label} is not passed; claim remains calibration_readiness_only") @@ -224,10 +232,7 @@ def main(argv: list[str] | None = None) -> int: print(validation_gate_matrix_as_json(strict=args.strict)) else: for row in build_calibration_validation_gate_matrix(strict=args.strict): - print( - f"{row.gate_id}: family={row.gate_family}; status={row.status}; " - f"claim={row.claim_status}" - ) + print(f"{row.gate_id}: family={row.gate_family}; status={row.status}; claim={row.claim_status}") if args.strict: issues = validation_gate_issues(require_all_validation_data=args.require_all_validation_data) diff --git a/models/primarycare_model/calibration/posterior_predictive_checks.py b/models/primarycare_model/calibration/posterior_predictive_checks.py index 218bbed..7b83dc7 100644 --- a/models/primarycare_model/calibration/posterior_predictive_checks.py +++ b/models/primarycare_model/calibration/posterior_predictive_checks.py @@ -110,8 +110,12 @@ def posterior_predictive_checks_as_json(*, strict: bool = False) -> str: def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Report posterior predictive check readiness for public aggregate calibration.") - parser.add_argument("--strict", action="store_true", help="Fail until posterior predictive checks are source-ready and passed.") + parser = argparse.ArgumentParser( + description="Report posterior predictive check readiness for public aggregate calibration." + ) + parser.add_argument( + "--strict", action="store_true", help="Fail until posterior predictive checks are source-ready and passed." + ) parser.add_argument("--json", action="store_true", help="Print the PPC readiness payload as JSON.") args = parser.parse_args(argv) diff --git a/models/primarycare_model/calibration/public_aggregate_calibration.py b/models/primarycare_model/calibration/public_aggregate_calibration.py index 225dce6..a82873d 100644 --- a/models/primarycare_model/calibration/public_aggregate_calibration.py +++ b/models/primarycare_model/calibration/public_aggregate_calibration.py @@ -26,17 +26,19 @@ def run_public_aggregate_calibration() -> dict[str, object]: source_row = source_rows.get(target.source_id) source_ready = source_row is not None and source_row.source_ready passed = relative_error <= target.tolerance and source_ready - checks.append({ - "target_id": target.target_id, - "target_family": target.target_family, - "observed_value": target.observed_value, - "predicted_value": predicted, - "relative_error": round(relative_error, 6), - "tolerance": target.tolerance, - "source_ready": source_ready, - "passed": passed, - "claim_boundary": target.claim_boundary, - }) + checks.append( + { + "target_id": target.target_id, + "target_family": target.target_family, + "observed_value": target.observed_value, + "predicted_value": predicted, + "relative_error": round(relative_error, 6), + "tolerance": target.tolerance, + "source_ready": source_ready, + "passed": passed, + "claim_boundary": target.claim_boundary, + } + ) all_targets_passed = all(item["passed"] for item in checks) validation_gates = [row.to_json_dict() for row in build_calibration_validation_gate_matrix(strict=False)] all_validation_gates_passed = all(row["status"] == "passed" for row in validation_gates) diff --git a/models/primarycare_model/calibration/public_holdout_validation.py b/models/primarycare_model/calibration/public_holdout_validation.py index bc09e8a..7c5d64e 100644 --- a/models/primarycare_model/calibration/public_holdout_validation.py +++ b/models/primarycare_model/calibration/public_holdout_validation.py @@ -21,11 +21,7 @@ HoldoutComparisonStatus = Literal["passed", "comparison_failed"] PHO_ACCESS_NUMERIC = ( - ROOT - / "data" - / "public_processed" - / "src_hnz_pho_access_timeseries" - / "pho_access_numeric_extract.csv" + ROOT / "data" / "public_processed" / "src_hnz_pho_access_timeseries" / "pho_access_numeric_extract.csv" ) @@ -240,9 +236,7 @@ def _period_persistence_comparison_for_rows( claim_status="calibration_readiness_only", interpretation_note=interpretation_note, next_data_model_requirement=( - _next_data_model_requirement(gate_id, validation_family) - if failed_observations - else passed_requirement + _next_data_model_requirement(gate_id, validation_family) if failed_observations else passed_requirement ), ) @@ -274,9 +268,7 @@ def build_public_holdout_comparisons() -> tuple[PublicHoldoutComparison, ...]: ) subgroup_rows = tuple( - row - for row in rows - if row["stratifier"] in {"ethnicity", "deprivation"} and row["group"] != "Total" + row for row in rows if row["stratifier"] in {"ethnicity", "deprivation"} and row["group"] != "Total" ) for stratifier, group in sorted({(row["stratifier"], row["group"]) for row in subgroup_rows}): group_rows = tuple(row for row in subgroup_rows if row["stratifier"] == stratifier and row["group"] == group) diff --git a/models/primarycare_model/calibration/public_policy_shock_plausibility.py b/models/primarycare_model/calibration/public_policy_shock_plausibility.py index f561eb8..3249cd8 100644 --- a/models/primarycare_model/calibration/public_policy_shock_plausibility.py +++ b/models/primarycare_model/calibration/public_policy_shock_plausibility.py @@ -163,7 +163,9 @@ def _numeric_comparison_contract(row: dict[str, object]) -> NumericComparisonCon "pre/post metric and the source is source_ready." ), ) - required_columns = tuple(str(column) for column in raw_contract.get("required_columns", REQUIRED_NUMERIC_COMPARISON_COLUMNS)) + required_columns = tuple( + str(column) for column in raw_contract.get("required_columns", REQUIRED_NUMERIC_COMPARISON_COLUMNS) + ) return NumericComparisonContract( required_columns=required_columns, readiness_rule=str(raw_contract["readiness_rule"]), @@ -242,20 +244,16 @@ def _numeric_comparison_readiness( if pre_value is not None and post_value is not None and observed_delta is not None: expected_delta = post_value - pre_value if abs(observed_delta - expected_delta) > OBSERVED_DELTA_TOLERANCE: - issues.append( - f"Row {index}: observed_delta must equal post_value - pre_value " - f"({expected_delta:g})." - ) + issues.append(f"Row {index}: observed_delta must equal post_value - pre_value ({expected_delta:g}).") expected_direction = _direction_from_delta(observed_delta) if observed_direction in ALLOWED_DIRECTIONS and observed_direction != expected_direction: - issues.append( - f"Row {index}: observed_direction must match observed_delta " - f"({expected_direction})." - ) + issues.append(f"Row {index}: observed_direction must match observed_delta ({expected_direction}).") if result not in ALLOWED_COMPARISON_RESULTS: issues.append(f"Row {index}: comparison_result must be one of {sorted(ALLOWED_COMPARISON_RESULTS)}.") if result == "passed" and observed_direction != modelled_direction: - issues.append(f"Row {index}: comparison_result=passed requires observed_direction to match modelled_direction.") + issues.append( + f"Row {index}: comparison_result=passed requires observed_direction to match modelled_direction." + ) passed = passed or result == "passed" failed = failed or result == "comparison_failed" @@ -331,8 +329,7 @@ def policy_shock_gate_status() -> PolicyShockGateStatus: ): return "passed" if any( - row.comparison_status == "numeric_ready" - or row.numeric_comparison_readiness.status == "numeric_pre_post_ready" + row.comparison_status == "numeric_ready" or row.numeric_comparison_readiness.status == "numeric_pre_post_ready" for row in gate_rows ): return "public_validation_numeric_ready" @@ -348,7 +345,8 @@ def policy_shock_gate_blockers() -> tuple[str, ...]: f"CAL-G-005: {row.shock_id} comparison failed; claim remains calibration_readiness_only" for row in build_public_policy_shock_evidence() if row.gate_role == "numeric_comparison" - if row.comparison_status == "comparison_failed" or row.numeric_comparison_readiness.status == "comparison_failed" + if row.comparison_status == "comparison_failed" + or row.numeric_comparison_readiness.status == "comparison_failed" ) artifact_issues = tuple( f"CAL-G-005: {row.shock_id}: {issue}" diff --git a/models/primarycare_model/calibration/public_temporal_holdout_validation.py b/models/primarycare_model/calibration/public_temporal_holdout_validation.py index da2ba2d..1f899e7 100644 --- a/models/primarycare_model/calibration/public_temporal_holdout_validation.py +++ b/models/primarycare_model/calibration/public_temporal_holdout_validation.py @@ -20,12 +20,7 @@ from models.primarycare_model.data.public_source_snapshot import ROOT TEMPORAL_HOLDOUT_TARGETS = ( - ROOT - / "models" - / "primarycare_model" - / "registries" - / "public" - / "temporal_holdout_targets.public.v1.yaml" + ROOT / "models" / "primarycare_model" / "registries" / "public" / "temporal_holdout_targets.public.v1.yaml" ) TemporalHoldoutStatus = Literal[ @@ -195,7 +190,7 @@ def _comparison_for_target( ) holdout_period = periods[-1] - training_periods = periods[: -1] + training_periods = periods[:-1] training_rows = tuple(row for row in filtered_rows if row[target.period_column] in training_periods) holdout_rows = tuple(row for row in filtered_rows if row[target.period_column] == holdout_period) fallback_rate = _weighted_rate(training_rows, target) diff --git a/models/primarycare_model/calibration_v150.py b/models/primarycare_model/calibration_v150.py index 2671c65..3b76e0b 100644 --- a/models/primarycare_model/calibration_v150.py +++ b/models/primarycare_model/calibration_v150.py @@ -6,6 +6,7 @@ - a deterministic least-squares calibration routine that can be replaced by Bayesian calibration; - a synthetic-data demo so the pipeline is executable before confidential linked NZ data are available. """ + from __future__ import annotations import math @@ -27,6 +28,7 @@ class CalibrationParameters: base_ed_presentations: float = 220.0 base_public_cost: float = 1_000_000.0 + @dataclass(frozen=True) class ScenarioInputs: scheduled_benefit_strength: float @@ -37,6 +39,7 @@ class ScenarioInputs: scope_flexibility: float governance_controls: float + @dataclass(frozen=True) class MonthlyObservation: month: int @@ -46,6 +49,7 @@ class MonthlyObservation: ambulance_conveyances: float public_cost: float + DEFAULT_BASELINE = ScenarioInputs( scheduled_benefit_strength=0.10, capitation_weighting_strength=0.35, @@ -84,15 +88,16 @@ class MonthlyObservation: scope_supply_multiplier=0.20, ) + def simulate_months(params: CalibrationParameters, inputs: ScenarioInputs, months: int = 24) -> pd.DataFrame: """Run a minimal dynamic monthly simulation. The equations are stylised. They are placeholders for empirically estimated transition functions once governed record linkage is available. """ - rows=[] - unmet=55.0 - for m in range(1, months+1): + rows = [] + unmet = 55.0 + for m in range(1, months + 1): seasonal = 1 + 0.05 * math.sin(2 * math.pi * m / 12) supply_gain = ( params.marginal_supply_response * inputs.scheduled_benefit_strength @@ -102,30 +107,59 @@ def simulate_months(params: CalibrationParameters, inputs: ScenarioInputs, month ) price_suppression = params.copayment_elasticity * inputs.copayment_level primary_contacts = params.base_primary_contacts * seasonal * (1 + supply_gain - price_suppression) - unmet = max(0.0, 0.82 * unmet + 72 * (1 - supply_gain) + 35 * price_suppression - 18 * inputs.governance_controls) + unmet = max( + 0.0, 0.82 * unmet + 72 * (1 - supply_gain) + 35 * price_suppression - 18 * inputs.governance_controls + ) ed_presentations = params.base_ed_presentations * seasonal + params.unmet_need_to_ed_rate * unmet * 2.5 - ambulance_conveyances = 80 * seasonal + 0.45 * unmet - 35 * params.ambulance_deflection_rate * inputs.ambulance_alternative_strength + ambulance_conveyances = ( + 80 * seasonal + 0.45 * unmet - 35 * params.ambulance_deflection_rate * inputs.ambulance_alternative_strength + ) gaming_cost = 120000 * max(0.0, inputs.scheduled_benefit_strength - inputs.governance_controls) - public_cost = params.base_public_cost + 90 * primary_contacts + 1550 * ed_presentations + 900 * ambulance_conveyances + gaming_cost - rows.append(MonthlyObservation(m, primary_contacts, unmet, ed_presentations, ambulance_conveyances, public_cost)) + public_cost = ( + params.base_public_cost + + 90 * primary_contacts + + 1550 * ed_presentations + + 900 * ambulance_conveyances + + gaming_cost + ) + rows.append( + MonthlyObservation(m, primary_contacts, unmet, ed_presentations, ambulance_conveyances, public_cost) + ) return pd.DataFrame([asdict(r) for r in rows]) + def make_synthetic_observations(months: int = 24, seed: int = 150) -> pd.DataFrame: - rng=random.Random(seed) - df=simulate_months(TRUE_SYNTHETIC, DEFAULT_BASELINE, months) - noisy=df.copy() - for col, sd in [('primary_contacts',35),('unmet_need_index',4),('ed_presentations',8),('ambulance_conveyances',5),('public_cost',35000)]: - noisy[col]=[max(0,v+rng.gauss(0,sd)) for v in noisy[col]] + rng = random.Random(seed) + df = simulate_months(TRUE_SYNTHETIC, DEFAULT_BASELINE, months) + noisy = df.copy() + for col, sd in [ + ("primary_contacts", 35), + ("unmet_need_index", 4), + ("ed_presentations", 8), + ("ambulance_conveyances", 5), + ("public_cost", 35000), + ]: + noisy[col] = [max(0, v + rng.gauss(0, sd)) for v in noisy[col]] return noisy -def objective(params: CalibrationParameters, observed: pd.DataFrame, inputs: ScenarioInputs = DEFAULT_BASELINE) -> float: - pred=simulate_months(params, inputs, len(observed)) - err=0.0 - for col, scale in [('primary_contacts',1000),('unmet_need_index',100),('ed_presentations',250),('ambulance_conveyances',150),('public_cost',1_500_000)]: - diff=(pred[col].values-observed[col].values)/scale + +def objective( + params: CalibrationParameters, observed: pd.DataFrame, inputs: ScenarioInputs = DEFAULT_BASELINE +) -> float: + pred = simulate_months(params, inputs, len(observed)) + err = 0.0 + for col, scale in [ + ("primary_contacts", 1000), + ("unmet_need_index", 100), + ("ed_presentations", 250), + ("ambulance_conveyances", 150), + ("public_cost", 1_500_000), + ]: + diff = (pred[col].values - observed[col].values) / scale err += float((diff**2).mean()) return err + def calibrate_grid(observed: pd.DataFrame, starting: CalibrationParameters = STARTING_PRIOR) -> CalibrationParameters: """Small deterministic coordinate-search calibration. @@ -133,37 +167,64 @@ def calibrate_grid(observed: pd.DataFrame, starting: CalibrationParameters = STA avoids additional dependencies. Replace with Bayesian calibration or simulated method of moments once real data are available. """ - values=asdict(starting) - fixed={k: values.pop(k) for k in ['base_primary_contacts','base_ed_presentations','base_public_cost']} - names=list(values) - steps={name:0.12 for name in names} - best=CalibrationParameters(**values, **fixed) - best_score=objective(best, observed) + values = asdict(starting) + fixed = {k: values.pop(k) for k in ["base_primary_contacts", "base_ed_presentations", "base_public_cost"]} + names = list(values) + steps = {name: 0.12 for name in names} + best = CalibrationParameters(**values, **fixed) + best_score = objective(best, observed) for _ in range(8): - improved=False + improved = False for name in names: - current=asdict(best) - for direction in (-1,1): - cand=current.copy() - cand[name]=min(0.95,max(0.01,cand[name]+direction*steps[name])) - cp=CalibrationParameters(**cand) - score=objective(cp, observed) + current = asdict(best) + for direction in (-1, 1): + cand = current.copy() + cand[name] = min(0.95, max(0.01, cand[name] + direction * steps[name])) + cp = CalibrationParameters(**cand) + score = objective(cp, observed) if score < best_score: best, best_score, improved = cp, score, True if not improved: - for name in names: steps[name]*=0.5 + for name in names: + steps[name] *= 0.5 return best + def run_calibration_demo(months: int = 24) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: - observed=make_synthetic_observations(months) - fitted=calibrate_grid(observed) - pred=simulate_months(fitted, DEFAULT_BASELINE, months) - params=[] - true=asdict(TRUE_SYNTHETIC); fit=asdict(fitted); prior=asdict(STARTING_PRIOR) - for name in ['marginal_supply_response','unmet_need_to_ed_rate','copayment_elasticity','ambulance_deflection_rate','acc_stabilisation_effect','scope_supply_multiplier']: - params.append({'parameter':name,'starting_prior':prior[name],'synthetic_truth':true[name],'fitted_estimate':fit[name]}) - scen=[] - for label,inp in [('baseline', DEFAULT_BASELINE), ('full_hybrid', FULL_HYBRID)]: - out=simulate_months(fitted, inp, months) - scen.append({'scenario':label,'mean_primary_contacts':out.primary_contacts.mean(),'mean_unmet_need':out.unmet_need_index.mean(),'mean_ed_presentations':out.ed_presentations.mean(),'mean_ambulance_conveyances':out.ambulance_conveyances.mean(),'mean_public_cost':out.public_cost.mean()}) + observed = make_synthetic_observations(months) + fitted = calibrate_grid(observed) + pred = simulate_months(fitted, DEFAULT_BASELINE, months) + params = [] + true = asdict(TRUE_SYNTHETIC) + fit = asdict(fitted) + prior = asdict(STARTING_PRIOR) + for name in [ + "marginal_supply_response", + "unmet_need_to_ed_rate", + "copayment_elasticity", + "ambulance_deflection_rate", + "acc_stabilisation_effect", + "scope_supply_multiplier", + ]: + params.append( + { + "parameter": name, + "starting_prior": prior[name], + "synthetic_truth": true[name], + "fitted_estimate": fit[name], + } + ) + scen = [] + for label, inp in [("baseline", DEFAULT_BASELINE), ("full_hybrid", FULL_HYBRID)]: + out = simulate_months(fitted, inp, months) + scen.append( + { + "scenario": label, + "mean_primary_contacts": out.primary_contacts.mean(), + "mean_unmet_need": out.unmet_need_index.mean(), + "mean_ed_presentations": out.ed_presentations.mean(), + "mean_ambulance_conveyances": out.ambulance_conveyances.mean(), + "mean_public_cost": out.public_cost.mean(), + } + ) return observed, pred, pd.DataFrame(params), pd.DataFrame(scen) diff --git a/models/primarycare_model/contracts/calibration_targets.py b/models/primarycare_model/contracts/calibration_targets.py index 89ad647..fc261c9 100644 --- a/models/primarycare_model/contracts/calibration_targets.py +++ b/models/primarycare_model/contracts/calibration_targets.py @@ -9,9 +9,15 @@ from models.primarycare_model.contracts.parameters import StrictContract TargetFamily = Literal[ - "population_denominators", "primary_care_access", "unmet_need_cost_barriers", - "workforce_supply", "hospital_ed_pressure", "avoidable_admissions", - "equity_gradients", "rurality_gradients", "fiscal_aggregates" + "population_denominators", + "primary_care_access", + "unmet_need_cost_barriers", + "workforce_supply", + "hospital_ed_pressure", + "avoidable_admissions", + "equity_gradients", + "rurality_gradients", + "fiscal_aggregates", ] diff --git a/models/primarycare_model/contracts/oia.py b/models/primarycare_model/contracts/oia.py index 000bb07..361488f 100644 --- a/models/primarycare_model/contracts/oia.py +++ b/models/primarycare_model/contracts/oia.py @@ -75,9 +75,5 @@ class OIAComponentEntry(StrictContract): ) impact_description: str = Field( min_length=1, - description=( - "Plain-English description of what having this data would " - "unlock or improve in the model" - ), + description=("Plain-English description of what having this data would unlock or improve in the model"), ) - diff --git a/models/primarycare_model/contracts/public_parameters.py b/models/primarycare_model/contracts/public_parameters.py index 7ee595e..3e0c2f9 100644 --- a/models/primarycare_model/contracts/public_parameters.py +++ b/models/primarycare_model/contracts/public_parameters.py @@ -11,7 +11,9 @@ DistributionType = Literal["fixed", "normal", "triangular", "beta", "uniform"] EvidenceQuality = Literal["low", "medium", "high"] SensitivityPriority = Literal["low", "medium", "high"] -CalibrationRole = Literal["baseline", "denominator", "scaling", "calibration_target", "validation_target", "scenario_modifier"] +CalibrationRole = Literal[ + "baseline", "denominator", "scaling", "calibration_target", "validation_target", "scenario_modifier" +] ClaimBoundary = Literal["public_benchmark", "calibration_readiness", "empirically_supported_if_gated"] diff --git a/models/primarycare_model/dashboard_service.py b/models/primarycare_model/dashboard_service.py index cb8423c..5597355 100644 --- a/models/primarycare_model/dashboard_service.py +++ b/models/primarycare_model/dashboard_service.py @@ -224,10 +224,20 @@ def parse_dashboard_state(pathname: str | None = None, search: str | None = None scenarios=scenarios or ("F0", "F4"), simulation_kind=kind, # type: ignore[arg-type] scenario_id=scenario_id, - draws=_clamp_int(_first_query_value(query, "draws", str(DEFAULT_MONTE_CARLO_DRAWS)), DEFAULT_MONTE_CARLO_DRAWS, 10, MAX_MONTE_CARLO_DRAWS), + draws=_clamp_int( + _first_query_value(query, "draws", str(DEFAULT_MONTE_CARLO_DRAWS)), + DEFAULT_MONTE_CARLO_DRAWS, + 10, + MAX_MONTE_CARLO_DRAWS, + ), seed=_clamp_int(_first_query_value(query, "seed", "20260526"), 20260526, 1, 999999), months=_clamp_int(_first_query_value(query, "months", "36"), 36, 6, MAX_MONTHS), - population_size=_clamp_int(_first_query_value(query, "population", str(DEFAULT_ABM_POPULATION)), DEFAULT_ABM_POPULATION, 50, MAX_ABM_POPULATION), + population_size=_clamp_int( + _first_query_value(query, "population", str(DEFAULT_ABM_POPULATION)), + DEFAULT_ABM_POPULATION, + 50, + MAX_ABM_POPULATION, + ), educational_settings=tuple(sorted(settings.items())), ) @@ -597,44 +607,138 @@ def post_reading_map_table() -> pd.DataFrame: def current_reform_table() -> pd.DataFrame: rows = [ - ("Capitation reweighting", "Changing how baseline enrolled-population funding is allocated.", "May improve fairness of distribution, but does not by itself create a strong payment signal for the next clinically necessary appointment."), - ("Primary care access target", "A public target for getting people timely primary care access.", "Useful as a signal, but targets need workforce, funding and data support to change behaviour."), - ("National Primary Care Dataset", "A data programme intended to improve visibility of primary care activity and access.", "Important for future calibration; not yet enough on its own to prove the model's assumptions."), - ("Digital access and telehealth", "Online and remote access routes for some care needs.", "Can help access, but cannot replace local in-person care for every patient, place or condition."), - ("Urgent and after-hours care work", "Policy attention to alternatives before emergency department presentation.", "May matter, but needs funding architecture and workforce support to shift demand safely."), - ("PHO accountability and commissioning", "Use of organisations and contracts to manage enrolled-population responsibility.", "Central to place accountability, but pass-through, transaction costs and incentives need verification."), + ( + "Capitation reweighting", + "Changing how baseline enrolled-population funding is allocated.", + "May improve fairness of distribution, but does not by itself create a strong payment signal for the next clinically necessary appointment.", + ), + ( + "Primary care access target", + "A public target for getting people timely primary care access.", + "Useful as a signal, but targets need workforce, funding and data support to change behaviour.", + ), + ( + "National Primary Care Dataset", + "A data programme intended to improve visibility of primary care activity and access.", + "Important for future calibration; not yet enough on its own to prove the model's assumptions.", + ), + ( + "Digital access and telehealth", + "Online and remote access routes for some care needs.", + "Can help access, but cannot replace local in-person care for every patient, place or condition.", + ), + ( + "Urgent and after-hours care work", + "Policy attention to alternatives before emergency department presentation.", + "May matter, but needs funding architecture and workforce support to shift demand safely.", + ), + ( + "PHO accountability and commissioning", + "Use of organisations and contracts to manage enrolled-population responsibility.", + "Central to place accountability, but pass-through, transaction costs and incentives need verification.", + ), ] - return pd.DataFrame(rows, columns=["Current pathway component", "Plain-English meaning", "Why it matters for this model"]) + return pd.DataFrame( + rows, columns=["Current pathway component", "Plain-English meaning", "Why it matters for this model"] + ) def public_status_table() -> pd.DataFrame: rows = [ ("Model status", "Public-data anchored benchmark", "Ready for explanation; not ready for forecasting."), - ("Dashboard status", "Dash on Hugging Face plus Streamlit compatibility", "Dash is the migration target; Streamlit remains the parity baseline until retired."), + ( + "Dashboard status", + "Dash on Hugging Face plus Streamlit compatibility", + "Dash is the migration target; Streamlit remains the parity baseline until retired.", + ), ("Evidence status", "Evidence readiness", "OIA/data requests still need submission or update."), ("Calibration status", "Readiness mapped", "Real linked data and validation tests still required."), ("Claim status", "Bounded", "No precise fiscal, hospital-demand, workforce or implementation-impact claims."), - ("Deployment status", "GitHub Pages and Hugging Face", "GitHub is source/front door; Hugging Face hosts the interactive lab."), + ( + "Deployment status", + "GitHub Pages and Hugging Face", + "GitHub is source/front door; Hugging Face hosts the interactive lab.", + ), ] return pd.DataFrame(rows, columns=["Area", "Current state", "What this means"]) def model_surface_status_table() -> pd.DataFrame: rows = [ - ("Reference scenarios", "surfaced", "/reference-scenarios", "Precomputed public scenario indices with comparison, scatter, heatmap, profile and budget-impact views."), - ("Microeconomics labs", "surfaced", "/microeconomics", "Marginal supply, capitation budget, scheduled payment and access route mix."), + ( + "Reference scenarios", + "surfaced", + "/reference-scenarios", + "Precomputed public scenario indices with comparison, scatter, heatmap, profile and budget-impact views.", + ), + ( + "Microeconomics labs", + "surfaced", + "/microeconomics", + "Marginal supply, capitation budget, scheduled payment and access route mix.", + ), ("Game theory labs", "surfaced", "/game-theory", "Claims audit, coordination and gaming-risk frontier views."), - ("Monte Carlo uncertainty", "surfaced", "/live-model", "Seeded bounded uncertainty run; demonstrative intervals only."), + ( + "Monte Carlo uncertainty", + "surfaced", + "/live-model", + "Seeded bounded uncertainty run; demonstrative intervals only.", + ), ("Stock-flow dynamics", "surfaced", "/live-model", "Bounded monthly teaching trace; not a forecast."), - ("ABM agent lens", "surfaced", "/live-model", "Capped synthetic-agent allocation lens; not patient-level evidence."), - ("Sensitivity / decomposition", "surfaced", "/live-model", "Tornado, waterfall, variance and phase portrait diagnostics."), - ("Outcome clustering", "surfaced", "/methodology", "Internal model-structure grouping over benchmark scenarios."), - ("Budget impact / diffusion", "surfaced", "/reference-scenarios", "Illustrative Bass diffusion budget view, not a fiscal forecast."), - ("VOI / evidence priority", "surfaced", "/advanced-visuals", "Seeded EVPI/EVPPI teaching view for evidence-priority ranking."), - ("Structural uncertainty", "surfaced", "/advanced-visuals", "Public structural-model registry interval and weights."), - ("Policy shocks", "surfaced", "/advanced-visuals", "Abrupt-change teaching sequence for hospital and fiscal pressure."), - ("Linked-data calibration", "deferred_public_boundary", "/calibration-diagnostics", "Readiness-only until source-ready public/linked validation gates pass."), - ("Patient-level forecasting", "retired", "/runtime-health", "Out of public scope; claim boundary prohibits patient-level forecasts."), + ( + "ABM agent lens", + "surfaced", + "/live-model", + "Capped synthetic-agent allocation lens; not patient-level evidence.", + ), + ( + "Sensitivity / decomposition", + "surfaced", + "/live-model", + "Tornado, waterfall, variance and phase portrait diagnostics.", + ), + ( + "Outcome clustering", + "surfaced", + "/methodology", + "Internal model-structure grouping over benchmark scenarios.", + ), + ( + "Budget impact / diffusion", + "surfaced", + "/reference-scenarios", + "Illustrative Bass diffusion budget view, not a fiscal forecast.", + ), + ( + "VOI / evidence priority", + "surfaced", + "/advanced-visuals", + "Seeded EVPI/EVPPI teaching view for evidence-priority ranking.", + ), + ( + "Structural uncertainty", + "surfaced", + "/advanced-visuals", + "Public structural-model registry interval and weights.", + ), + ( + "Policy shocks", + "surfaced", + "/advanced-visuals", + "Abrupt-change teaching sequence for hospital and fiscal pressure.", + ), + ( + "Linked-data calibration", + "deferred_public_boundary", + "/calibration-diagnostics", + "Readiness-only until source-ready public/linked validation gates pass.", + ), + ( + "Patient-level forecasting", + "retired", + "/runtime-health", + "Out of public scope; claim boundary prohibits patient-level forecasts.", + ), ] return pd.DataFrame(rows, columns=["Model surface", "Status", "Dash route", "Boundary / implementation note"]) @@ -692,17 +796,72 @@ def readiness_chart_bundle() -> ChartBundle: def figure_inventory_table() -> pd.DataFrame: rows = [ ("Static table", "Current reform pathway", "Current state", "Explains the real comparator in plain English."), - ("Static table", "Post reading map", "Post guide", "Maps posts to report sections, dashboard modules, visuals and caveats."), - ("Dynamic bar chart", "Reference scenario viability", "Reference scenarios", "Compares model-generated viability indices."), - ("Dynamic scatter plot", "Supply generation versus hospital pressure", "Reference scenarios", "Shows the internal supply-pressure tradeoff."), - ("Dynamic heatmap", "Scenario score matrix", "Reference scenarios", "Shows multiple indices across scenarios at once."), - ("Dynamic radar chart", "Selected scenario profile", "Reference scenarios", "Shows one selected scenario across dimensions."), - ("Dynamic line/bar/stacked charts", "Microeconomics labs", "Microeconomics", "Shows marginal supply, capitation constraint, scheduled payment and access mix."), - ("Dynamic payoff charts", "Game theory labs", "Game theory", "Shows audit, coordination and gaming-risk incentives."), - ("Dynamic stochastic charts", "Uncertainty, stock-flow, agent lens", "Live model", "Shows bounded seeded teaching simulations."), - ("Dynamic sensitivity charts", "Tornado, waterfall, variance, phase portrait", "Live model", "Shows live model diagnostics from runtime helpers."), - ("Static/dynamic tables", "Evidence, OIA, calibration readiness", "Evidence/OIA and Calibration", "Shows sources and what is still needed before calibration."), - ("Contract panel", "Public cockpit", "Public cockpit", "Shows cockpit sections, required visuals, provenance, VOI and downloads."), + ( + "Static table", + "Post reading map", + "Post guide", + "Maps posts to report sections, dashboard modules, visuals and caveats.", + ), + ( + "Dynamic bar chart", + "Reference scenario viability", + "Reference scenarios", + "Compares model-generated viability indices.", + ), + ( + "Dynamic scatter plot", + "Supply generation versus hospital pressure", + "Reference scenarios", + "Shows the internal supply-pressure tradeoff.", + ), + ( + "Dynamic heatmap", + "Scenario score matrix", + "Reference scenarios", + "Shows multiple indices across scenarios at once.", + ), + ( + "Dynamic radar chart", + "Selected scenario profile", + "Reference scenarios", + "Shows one selected scenario across dimensions.", + ), + ( + "Dynamic line/bar/stacked charts", + "Microeconomics labs", + "Microeconomics", + "Shows marginal supply, capitation constraint, scheduled payment and access mix.", + ), + ( + "Dynamic payoff charts", + "Game theory labs", + "Game theory", + "Shows audit, coordination and gaming-risk incentives.", + ), + ( + "Dynamic stochastic charts", + "Uncertainty, stock-flow, agent lens", + "Live model", + "Shows bounded seeded teaching simulations.", + ), + ( + "Dynamic sensitivity charts", + "Tornado, waterfall, variance, phase portrait", + "Live model", + "Shows live model diagnostics from runtime helpers.", + ), + ( + "Static/dynamic tables", + "Evidence, OIA, calibration readiness", + "Evidence/OIA and Calibration", + "Shows sources and what is still needed before calibration.", + ), + ( + "Contract panel", + "Public cockpit", + "Public cockpit", + "Shows cockpit sections, required visuals, provenance, VOI and downloads.", + ), ] return pd.DataFrame(rows, columns=["Type", "Figure or table", "Dash route", "Purpose"]) @@ -940,10 +1099,34 @@ def microeconomics_bundles() -> tuple[ChartBundle, ...]: access_fig.update_layout(height=380, margin=dict(l=20, r=20, t=56, b=20)) return ( - ChartBundle("Marginal supply response", payment_fig, payment_df, "Shows the Streamlit marginal-supply lab as a bounded deterministic curve.", csv_filename="gtpcnz-micro-marginal-supply.csv"), - ChartBundle("Capitation budget constraint", budget_fig, budget_df, "Shows capitation as a finite envelope against illustrative need pressure.", csv_filename="gtpcnz-micro-capitation-budget.csv"), - ChartBundle("Scheduled activity payment", scheduled_fig, scheduled_df, "Shows gross scheduled payment, control cost and net signal.", csv_filename="gtpcnz-micro-scheduled-payment.csv"), - ChartBundle("Access route mix", access_fig, access_df, "Shows local, digital and deferred shares across access-barrier bands.", csv_filename="gtpcnz-micro-access-mix.csv"), + ChartBundle( + "Marginal supply response", + payment_fig, + payment_df, + "Shows the Streamlit marginal-supply lab as a bounded deterministic curve.", + csv_filename="gtpcnz-micro-marginal-supply.csv", + ), + ChartBundle( + "Capitation budget constraint", + budget_fig, + budget_df, + "Shows capitation as a finite envelope against illustrative need pressure.", + csv_filename="gtpcnz-micro-capitation-budget.csv", + ), + ChartBundle( + "Scheduled activity payment", + scheduled_fig, + scheduled_df, + "Shows gross scheduled payment, control cost and net signal.", + csv_filename="gtpcnz-micro-scheduled-payment.csv", + ), + ChartBundle( + "Access route mix", + access_fig, + access_df, + "Shows local, digital and deferred shares across access-barrier bands.", + csv_filename="gtpcnz-micro-access-mix.csv", + ), ) @@ -960,8 +1143,20 @@ def game_theory_bundles() -> tuple[ChartBundle, ...]: gaming_attraction = strategic_response(0.62 * gain + 0.22 * (1 - quality) + 0.16 * (1 - place), 0.42, 7.0) audit_rows.extend( [ - {"audit_strength": audit_level, "strategy": "Honest payoff", "payoff": round(48 + 34 * honest_bonus + 14 * diminishing_return(gain) - 8 * diminishing_return(audit, 2.0), 1)}, - {"audit_strength": audit_level, "strategy": "Gaming payoff", "payoff": round(48 + 42 * gaming_attraction - 36 * detection_risk - 8 * diminishing_return(audit, 1.8), 1)}, + { + "audit_strength": audit_level, + "strategy": "Honest payoff", + "payoff": round( + 48 + 34 * honest_bonus + 14 * diminishing_return(gain) - 8 * diminishing_return(audit, 2.0), 1 + ), + }, + { + "audit_strength": audit_level, + "strategy": "Gaming payoff", + "payoff": round( + 48 + 42 * gaming_attraction - 36 * detection_risk - 8 * diminishing_return(audit, 1.8), 1 + ), + }, ] ) audit_df = pd.DataFrame(audit_rows) @@ -982,8 +1177,16 @@ def game_theory_bundles() -> tuple[ChartBundle, ...]: cherry_signal = 0.52 * (1 - place_frac) + 0.28 * gain + 0.20 * (1 - quality) coordination_rows.extend( [ - {"place_accountability": place_level, "strategy": "Cooperate", "payoff": round(46 + 48 * strategic_response(coop_signal, 0.48, 7.0), 1)}, - {"place_accountability": place_level, "strategy": "Cherry-pick", "payoff": round(46 + 48 * strategic_response(cherry_signal, 0.32, 7.0), 1)}, + { + "place_accountability": place_level, + "strategy": "Cooperate", + "payoff": round(46 + 48 * strategic_response(coop_signal, 0.48, 7.0), 1), + }, + { + "place_accountability": place_level, + "strategy": "Cherry-pick", + "payoff": round(46 + 48 * strategic_response(cherry_signal, 0.32, 7.0), 1), + }, ] ) coordination_df = pd.DataFrame(coordination_rows) @@ -993,7 +1196,11 @@ def game_theory_bundles() -> tuple[ChartBundle, ...]: y="payoff", color="strategy", title="Game theory lab 2: payoff and best response", - labels={"place_accountability": "Place accountability", "payoff": "Illustrative payoff", "strategy": "Strategy"}, + labels={ + "place_accountability": "Place accountability", + "payoff": "Illustrative payoff", + "strategy": "Strategy", + }, ) coordination_fig.update_layout(height=380, margin=dict(l=20, r=20, t=56, b=20)) @@ -1021,9 +1228,27 @@ def game_theory_bundles() -> tuple[ChartBundle, ...]: frontier_fig.update_layout(height=380, margin=dict(l=20, r=20, t=56, b=20)) return ( - ChartBundle("Claims audit game", audit_fig, audit_df, "Shows honest versus gaming payoffs as audit strength rises.", csv_filename="gtpcnz-game-audit-payoffs.csv"), - ChartBundle("Coordination game", coordination_fig, coordination_df, "Shows how place accountability shifts cooperate versus cherry-pick incentives.", csv_filename="gtpcnz-game-coordination-payoffs.csv"), - ChartBundle("Gaming-risk frontier", frontier_fig, frontier_df, "Shows access gain and gaming risk as controls change.", csv_filename="gtpcnz-game-risk-frontier.csv"), + ChartBundle( + "Claims audit game", + audit_fig, + audit_df, + "Shows honest versus gaming payoffs as audit strength rises.", + csv_filename="gtpcnz-game-audit-payoffs.csv", + ), + ChartBundle( + "Coordination game", + coordination_fig, + coordination_df, + "Shows how place accountability shifts cooperate versus cherry-pick incentives.", + csv_filename="gtpcnz-game-coordination-payoffs.csv", + ), + ChartBundle( + "Gaming-risk frontier", + frontier_fig, + frontier_df, + "Shows access gain and gaming risk as controls change.", + csv_filename="gtpcnz-game-risk-frontier.csv", + ), ) @@ -1049,8 +1274,12 @@ def simulation_bundle( def live_model_diagnostic_bundles(scenario_id: str = "F4") -> tuple[ChartBundle, ...]: tornado_df = run_tornado_sensitivity(scenario_id, delta_step=10.0) tornado_fig = go.Figure() - tornado_fig.add_trace(go.Bar(y=tornado_df["lever"], x=tornado_df["low_delta_viability"], name="Low delta", orientation="h")) - tornado_fig.add_trace(go.Bar(y=tornado_df["lever"], x=tornado_df["high_delta_viability"], name="High delta", orientation="h")) + tornado_fig.add_trace( + go.Bar(y=tornado_df["lever"], x=tornado_df["low_delta_viability"], name="Low delta", orientation="h") + ) + tornado_fig.add_trace( + go.Bar(y=tornado_df["lever"], x=tornado_df["high_delta_viability"], name="High delta", orientation="h") + ) tornado_fig.update_layout( barmode="relative", height=430, @@ -1105,14 +1334,40 @@ def live_model_diagnostic_bundles(scenario_id: str = "F4") -> tuple[ChartBundle, phase_fig.update_layout(height=430, margin=dict(l=20, r=20, t=56, b=20)) return ( - ChartBundle("Tornado sensitivity", tornado_fig, tornado_df, "One-at-a-time sensitivity preview, not a full causal attribution.", csv_filename=f"gtpcnz-{scenario_id}-tornado.csv"), - ChartBundle("Hybrid viability decomposition", waterfall_fig, waterfall_df, "Shows the additive weighted structure behind hybrid viability.", csv_filename=f"gtpcnz-{scenario_id}-waterfall.csv"), - ChartBundle("Variance decomposition", variance_fig, variance_df, "Separates structural, subgroup and stochastic variance components in a bounded preview.", csv_filename=f"gtpcnz-{scenario_id}-variance.csv"), - ChartBundle("Phase portrait", phase_fig, phase_df, "Shows local directional gradients in two-dimensional parameter space.", csv_filename=f"gtpcnz-{scenario_id}-phase-portrait.csv"), + ChartBundle( + "Tornado sensitivity", + tornado_fig, + tornado_df, + "One-at-a-time sensitivity preview, not a full causal attribution.", + csv_filename=f"gtpcnz-{scenario_id}-tornado.csv", + ), + ChartBundle( + "Hybrid viability decomposition", + waterfall_fig, + waterfall_df, + "Shows the additive weighted structure behind hybrid viability.", + csv_filename=f"gtpcnz-{scenario_id}-waterfall.csv", + ), + ChartBundle( + "Variance decomposition", + variance_fig, + variance_df, + "Separates structural, subgroup and stochastic variance components in a bounded preview.", + csv_filename=f"gtpcnz-{scenario_id}-variance.csv", + ), + ChartBundle( + "Phase portrait", + phase_fig, + phase_df, + "Shows local directional gradients in two-dimensional parameter space.", + csv_filename=f"gtpcnz-{scenario_id}-phase-portrait.csv", + ), ) -def budget_impact_bundle(scenario_ids: tuple[str, ...] = ("F0", "F4"), enrolled_population: int = 4_500_000) -> ChartBundle: +def budget_impact_bundle( + scenario_ids: tuple[str, ...] = ("F0", "F4"), enrolled_population: int = 4_500_000 +) -> ChartBundle: table = run_budget_impact(scenario_ids, enrolled_population=enrolled_population) chart_df = table[table["year"] != "Total"].astype({"year": int}) fig = px.line( @@ -1156,13 +1411,30 @@ def methodology_bundles() -> tuple[ChartBundle, ...]: color="hybrid_viability_score", size="gaming_risk_score", title="Composite sweep across all benchmark levers", - labels={"access_score": "Access index", "hospital_pressure_score": "Hospital pressure index", "hybrid_viability_score": "Viability", "gaming_risk_score": "Gaming risk"}, + labels={ + "access_score": "Access index", + "hospital_pressure_score": "Hospital pressure index", + "hybrid_viability_score": "Viability", + "gaming_risk_score": "Gaming risk", + }, ) meta_fig.update_layout(height=390, margin=dict(l=20, r=20, t=56, b=20)) return ( - ChartBundle("Outcome clustering", cluster_fig, cluster_df, "Groups benchmark scenarios by outcome pattern.", csv_filename="gtpcnz-outcome-clustering.csv"), - ChartBundle("Composite meta-analysis", meta_fig, meta_df, "Samples benchmark levers to show broad internal model structure.", csv_filename="gtpcnz-composite-meta-analysis.csv"), + ChartBundle( + "Outcome clustering", + cluster_fig, + cluster_df, + "Groups benchmark scenarios by outcome pattern.", + csv_filename="gtpcnz-outcome-clustering.csv", + ), + ChartBundle( + "Composite meta-analysis", + meta_fig, + meta_df, + "Samples benchmark levers to show broad internal model structure.", + csv_filename="gtpcnz-composite-meta-analysis.csv", + ), ) @@ -1267,7 +1539,11 @@ def structural_uncertainty_bundle() -> ChartBundle: y="score", color="plausibility_weight", title="Structural uncertainty registry: model variants and plausibility weights", - labels={"structural_model_id": "Structural model", "score": "Illustrative score", "plausibility_weight": "Plausibility weight"}, + labels={ + "structural_model_id": "Structural model", + "score": "Illustrative score", + "plausibility_weight": "Plausibility weight", + }, ) fig.update_layout(height=390, margin=dict(l=20, r=20, t=56, b=110)) return ChartBundle( @@ -1334,10 +1610,32 @@ def policy_shock_bundle(scenario_id: str = "F4") -> ChartBundle: post_shock_months=24, ) fig = go.Figure() - fig.add_trace(go.Scatter(x=table["month"], y=table["baseline_hospital_pressure"], mode="lines", name="Baseline hospital pressure")) - fig.add_trace(go.Scatter(x=table["month"], y=table["shock_hospital_pressure"], mode="lines", name="Shock hospital pressure")) - fig.add_trace(go.Scatter(x=table["month"], y=table["baseline_fiscal_pressure"], mode="lines", name="Baseline fiscal pressure", line=dict(dash="dot"))) - fig.add_trace(go.Scatter(x=table["month"], y=table["shock_fiscal_pressure"], mode="lines", name="Shock fiscal pressure", line=dict(dash="dot"))) + fig.add_trace( + go.Scatter( + x=table["month"], y=table["baseline_hospital_pressure"], mode="lines", name="Baseline hospital pressure" + ) + ) + fig.add_trace( + go.Scatter(x=table["month"], y=table["shock_hospital_pressure"], mode="lines", name="Shock hospital pressure") + ) + fig.add_trace( + go.Scatter( + x=table["month"], + y=table["baseline_fiscal_pressure"], + mode="lines", + name="Baseline fiscal pressure", + line=dict(dash="dot"), + ) + ) + fig.add_trace( + go.Scatter( + x=table["month"], + y=table["shock_fiscal_pressure"], + mode="lines", + name="Shock fiscal pressure", + line=dict(dash="dot"), + ) + ) fig.update_layout( title=f"Policy shock sequence for {scenario_id}: governance -20", xaxis_title="Month", @@ -1426,7 +1724,12 @@ def regime_sweep_bundle(scenario_id: str = "F4") -> ChartBundle: hover_data=["hospital_pressure_score"], color_continuous_scale="Viridis", title=f"Regime sweep for {scenario_id}: activity signal by governance", - labels={"activity_signal": "Activity signal", "governance": "Governance", "hybrid_viability_score": "Hybrid viability", "gaming_risk_score": "Gaming risk"}, + labels={ + "activity_signal": "Activity signal", + "governance": "Governance", + "hybrid_viability_score": "Hybrid viability", + "gaming_risk_score": "Gaming risk", + }, ) fig.update_layout(height=430, margin=dict(l=20, r=20, t=56, b=20)) return ChartBundle( diff --git a/models/primarycare_model/data/public_processed_schema.py b/models/primarycare_model/data/public_processed_schema.py index 64345fa..389ca72 100644 --- a/models/primarycare_model/data/public_processed_schema.py +++ b/models/primarycare_model/data/public_processed_schema.py @@ -170,7 +170,9 @@ def main(argv: list[str] | None = None) -> int: import argparse parser = argparse.ArgumentParser(description="Validate processed public input schemas.") - parser.add_argument("--require-processed", action="store_true", help="Fail if expected processed artifacts are absent.") + parser.add_argument( + "--require-processed", action="store_true", help="Fail if expected processed artifacts are absent." + ) args = parser.parse_args(argv) issues = validate_processed_input_schemas(require_processed=args.require_processed) if issues: diff --git a/models/primarycare_model/data/public_source_fetch.py b/models/primarycare_model/data/public_source_fetch.py index 53c8f1e..3cca72b 100644 --- a/models/primarycare_model/data/public_source_fetch.py +++ b/models/primarycare_model/data/public_source_fetch.py @@ -49,7 +49,9 @@ def expected_raw_artifact_path(source_id: str) -> Path: def expected_fetch_metadata_path(source_id: str) -> Path: - return expected_raw_artifact_path(source_id).with_suffix(expected_raw_artifact_path(source_id).suffix + ".fetch.json") + return expected_raw_artifact_path(source_id).with_suffix( + expected_raw_artifact_path(source_id).suffix + ".fetch.json" + ) def verify_public_source_fetch_scripts() -> tuple[str, ...]: @@ -182,15 +184,21 @@ def download_public_source(source_id: str) -> Path: "claim_boundary": plan.claim_boundary, "not_valid_for": "private administrative, confidential, patient-level, stakeholder, or calibration-claim use", } - expected_fetch_metadata_path(source_id).write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8") + expected_fetch_metadata_path(source_id).write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) return output_path def run_fetch_cli(source_id: str, argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=f"Fetch or check public source readiness for {source_id}.") - parser.add_argument("--check-only", action="store_true", help="Check fetch readiness without network access or writes.") + parser.add_argument( + "--check-only", action="store_true", help="Check fetch readiness without network access or writes." + ) parser.add_argument("--require-raw", action="store_true", help="Fail until the expected raw public file exists.") - parser.add_argument("--download", action="store_true", help="Download the registry-pinned public URL into data/public_raw.") + parser.add_argument( + "--download", action="store_true", help="Download the registry-pinned public URL into data/public_raw." + ) args = parser.parse_args(argv) if args.download: diff --git a/models/primarycare_model/data/public_source_readiness_matrix.py b/models/primarycare_model/data/public_source_readiness_matrix.py index ce258c8..b85d0cc 100644 --- a/models/primarycare_model/data/public_source_readiness_matrix.py +++ b/models/primarycare_model/data/public_source_readiness_matrix.py @@ -52,9 +52,13 @@ def _raw_artifact_status(source_id: str, expected_raw_artifact: str) -> tuple[st if expected_path.exists(): return "expected_raw_artifact_present", () if raw_files: - issues.append(f"{source_id}: raw files exist but expected artifact {expected_path.relative_to(ROOT).as_posix()} is missing") + issues.append( + f"{source_id}: raw files exist but expected artifact {expected_path.relative_to(ROOT).as_posix()} is missing" + ) return "raw_files_present_expected_artifact_missing", tuple(issues) - return "pending_download", (f"{source_id}: no raw public source files under {raw_dir.relative_to(ROOT).as_posix()}",) + return "pending_download", ( + f"{source_id}: no raw public source files under {raw_dir.relative_to(ROOT).as_posix()}", + ) def _checksum_status(source_id: str, registry_checksum: str) -> tuple[str, tuple[str, ...]]: @@ -82,7 +86,9 @@ def _processed_artifact_status(source_id: str, expected_processed_artifact: str) f"{expected_path.relative_to(ROOT).as_posix()} is missing" ) return "processed_files_present_expected_artifact_missing", tuple(issues) - return "pending_transform", (f"{source_id}: no processed public source files under {processed_dir.relative_to(ROOT).as_posix()}",) + return "pending_transform", ( + f"{source_id}: no processed public source files under {processed_dir.relative_to(ROOT).as_posix()}", + ) def build_public_source_readiness_matrix(*, strict: bool = False) -> tuple[PublicSourceReadinessRow, ...]: @@ -96,7 +102,9 @@ def build_public_source_readiness_matrix(*, strict: bool = False) -> tuple[Publi transform = check_source_transform_readiness(plan.source_id, require_raw=strict) raw_status, raw_issues = _raw_artifact_status(plan.source_id, plan.expected_raw_artifact) checksum_status, checksum_issues = _checksum_status(plan.source_id, source.checksum) - processed_status, processed_issues = _processed_artifact_status(plan.source_id, plan.expected_processed_artifact) + processed_status, processed_issues = _processed_artifact_status( + plan.source_id, plan.expected_processed_artifact + ) readiness_issues: list[str] = [] if strict: @@ -149,8 +157,12 @@ def readiness_matrix_as_json(*, strict: bool = False) -> str: def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Report public-source readiness across retrieval, fetch, transform, and processed outputs.") - parser.add_argument("--strict", action="store_true", help="Fail until every source has raw, checksum, and processed artifacts.") + parser = argparse.ArgumentParser( + description="Report public-source readiness across retrieval, fetch, transform, and processed outputs." + ) + parser.add_argument( + "--strict", action="store_true", help="Fail until every source has raw, checksum, and processed artifacts." + ) parser.add_argument("--json", action="store_true", help="Print the readiness matrix as JSON.") args = parser.parse_args(argv) diff --git a/models/primarycare_model/data/public_source_snapshot.py b/models/primarycare_model/data/public_source_snapshot.py index 49cebc5..7702ff4 100644 --- a/models/primarycare_model/data/public_source_snapshot.py +++ b/models/primarycare_model/data/public_source_snapshot.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import hashlib from datetime import UTC, date, datetime from pathlib import Path @@ -43,6 +44,7 @@ def _lists_to_tuples(obj: Any) -> Any: return obj +@functools.cache def load_public_sources() -> tuple[PublicSource, ...]: payload = yaml.safe_load((PUBLIC_REGISTRY / "sources.public.v1.yaml").read_text(encoding="utf-8")) return TypeAdapter(tuple[PublicSource, ...]).validate_python(_lists_to_tuples(payload["sources"])) @@ -115,7 +117,9 @@ def verify_public_source_readiness( if verify_licences and source.licence_status not in ALLOWED_LICENCE_STATUSES: issues.append(f"{source.source_id}: licence_status {source.licence_status!r} is not allowed") if verify_files and not raw_files: - issues.append(f"{source.source_id}: no raw public source files under {source_raw_dir(source.source_id).relative_to(ROOT)}") + issues.append( + f"{source.source_id}: no raw public source files under {source_raw_dir(source.source_id).relative_to(ROOT)}" + ) if verify_checksums: if source.checksum == "pending-download": issues.append(f"{source.source_id}: checksum is pending-download") diff --git a/models/primarycare_model/data/public_source_transforms.py b/models/primarycare_model/data/public_source_transforms.py index 700e027..16185e8 100644 --- a/models/primarycare_model/data/public_source_transforms.py +++ b/models/primarycare_model/data/public_source_transforms.py @@ -283,7 +283,9 @@ def _transform_html_tables(plan: PublicSourceRetrievalPlan, raw_artifact: Path, return TransformOutput(plan.source_id, output_path, len(rows), "processed_reference_extract") -def _transform_link_inventory(plan: PublicSourceRetrievalPlan, raw_artifact: Path, output_path: Path) -> TransformOutput: +def _transform_link_inventory( + plan: PublicSourceRetrievalPlan, raw_artifact: Path, output_path: Path +) -> TransformOutput: parser = _parse_html(raw_artifact) raw_hash = _sha256(raw_artifact) rows = [ @@ -330,7 +332,9 @@ def _transform_artifact_manifest( "transform_note": "raw public PDF manifest only; table extraction not implemented in this bounded transform", } ] - _write_csv(output_path, rows, ["source_id", "artifact_name", "artifact_sha256", "artifact_size_bytes", "transform_note"]) + _write_csv( + output_path, rows, ["source_id", "artifact_name", "artifact_sha256", "artifact_size_bytes", "transform_note"] + ) _write_metadata( output_path, source_id=plan.source_id, @@ -494,10 +498,7 @@ def _worksheet_rows( def _xlsx_sheet_metadata(raw_artifact: Path, *, period: str | None = None) -> list[dict[str, object]]: with zipfile.ZipFile(raw_artifact) as archive: workbook = ElementTree.fromstring(archive.read("xl/workbook.xml")) - sheet_names = [ - str(sheet.attrib["name"]) - for sheet in workbook.findall("main:sheets/main:sheet", XLSX_NS) - ] + sheet_names = [str(sheet.attrib["name"]) for sheet in workbook.findall("main:sheets/main:sheet", XLSX_NS)] rows: list[dict[str, object]] = [] for index, sheet_name in enumerate(sheet_names, start=1): worksheet_path = f"xl/worksheets/sheet{index}.xml" @@ -761,7 +762,9 @@ def check_source_transform_readiness(source_id: str, *, require_raw: bool = Fals ) issues: list[str] = [] - script_issues = tuple(issue for issue in verify_public_source_transform_scripts() if issue.startswith(f"{source_id}:")) + script_issues = tuple( + issue for issue in verify_public_source_transform_scripts() if issue.startswith(f"{source_id}:") + ) issues.extend(script_issues) raw_dir = ROOT / plan.expected_raw_dir @@ -805,7 +808,9 @@ def run_transform_cli(source_id: str, argv: list[str] | None = None) -> int: except (FileNotFoundError, ValueError) as exc: print(str(exc)) return 1 - print(f"{source_id} transform wrote {_relative(output.artifact)} rows={output.rows_written} status={output.status}") + print( + f"{source_id} transform wrote {_relative(output.artifact)} rows={output.rows_written} status={output.status}" + ) return 0 print(f"{source_id} transform readiness: {result.status}") return 0 diff --git a/models/primarycare_model/data/public_temporal_period_acquisition.py b/models/primarycare_model/data/public_temporal_period_acquisition.py index c2c3842..3e233fb 100644 --- a/models/primarycare_model/data/public_temporal_period_acquisition.py +++ b/models/primarycare_model/data/public_temporal_period_acquisition.py @@ -102,8 +102,7 @@ def load_temporal_period_acquisition_plans( for item in _lists_to_tuples(payload["temporal_period_acquisition_plans"]): acquired = tuple(AcquiredPublicPeriod(**period) for period in item["acquired_public_periods"]) missing = tuple( - MissingPublicPeriodRequirement(**requirement) - for requirement in item["missing_public_period_requirements"] + MissingPublicPeriodRequirement(**requirement) for requirement in item["missing_public_period_requirements"] ) plan = TemporalPeriodAcquisitionPlan( plan_id=str(item["plan_id"]), @@ -191,7 +190,9 @@ def temporal_period_acquisition_issues(*, require_ready: bool = False) -> tuple[ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Check public temporal-period acquisition readiness.") parser.add_argument("--json", action="store_true", help="Print the temporal-period acquisition matrix as JSON.") - parser.add_argument("--require-ready", action="store_true", help="Fail until all required public periods are present.") + parser.add_argument( + "--require-ready", action="store_true", help="Fail until all required public periods are present." + ) args = parser.parse_args(argv) rows = build_temporal_period_readiness() diff --git a/models/primarycare_model/data/public_validation_sources.py b/models/primarycare_model/data/public_validation_sources.py index cb26d72..a9027f5 100644 --- a/models/primarycare_model/data/public_validation_sources.py +++ b/models/primarycare_model/data/public_validation_sources.py @@ -54,7 +54,9 @@ def verify_public_validation_source_candidates() -> tuple[str, ...]: candidates = load_public_validation_source_candidates() candidate_ids = [candidate.candidate_id for candidate in candidates] - duplicate_candidate_ids = sorted({candidate_id for candidate_id in candidate_ids if candidate_ids.count(candidate_id) > 1}) + duplicate_candidate_ids = sorted( + {candidate_id for candidate_id in candidate_ids if candidate_ids.count(candidate_id) > 1} + ) issues.extend(f"{candidate_id}: duplicate candidate_id" for candidate_id in duplicate_candidate_ids) registered = [candidate for candidate in candidates if candidate.candidate_status == "registered_retrieval_plan"] @@ -97,5 +99,7 @@ def verify_public_validation_source_candidates() -> tuple[str, ...]: if not candidate.transform_script.startswith("scripts/transform_"): issues.append(f"{candidate.candidate_id}: transform_script must be an explicit transform script") if candidate.independence_assessment != "independent_of_current_calibration_targets": - issues.append(f"{candidate.candidate_id}: registered retrieval candidate must be independently assessed") + issues.append( + f"{candidate.candidate_id}: registered retrieval candidate must be independently assessed" + ) return tuple(issues) diff --git a/models/primarycare_model/data_layer.py b/models/primarycare_model/data_layer.py index 38fdab0..d2802ad 100644 --- a/models/primarycare_model/data_layer.py +++ b/models/primarycare_model/data_layer.py @@ -31,106 +31,124 @@ # Arrow schema definitions mirroring the pydantic models # --------------------------------------------------------------------------- -PATIENT_ARROW_SCHEMA = pa.schema([ - pa.field("age", pa.int32(), nullable=False), - pa.field("gender", pa.utf8(), nullable=False), - pa.field("ethnicity", pa.utf8(), nullable=False), - pa.field("deprivation_index", pa.int32(), nullable=False), - pa.field("comorbidities", pa.list_(pa.utf8()), nullable=True), - pa.field("enrollment_status", pa.utf8(), nullable=False), -]) - -PROVIDER_ARROW_SCHEMA = pa.schema([ - pa.field("id", pa.utf8(), nullable=False), - pa.field("type", pa.utf8(), nullable=False), - pa.field("region", pa.utf8(), nullable=False), - pa.field("patient_list", pa.list_(pa.utf8()), nullable=True), - pa.field("capacity", pa.int32(), nullable=False), - pa.field("capitation_panel_size", pa.int32(), nullable=False), -]) - -CONFIG_ARROW_SCHEMA = pa.schema([ - pa.field("seed", pa.int32(), nullable=False), - pa.field("num_patients", pa.int32(), nullable=False), - pa.field("num_providers", pa.int32(), nullable=False), - pa.field("time_horizon_months", pa.int32(), nullable=False), - pa.field("tick_interval_days", pa.int32(), nullable=False), -]) - -SCENARIO_ARROW_SCHEMA = pa.schema([ - pa.field("name", pa.utf8(), nullable=False), - pa.field("description", pa.utf8(), nullable=True), - pa.field("funding_model", pa.utf8(), nullable=False), - pa.field("capitation_rate", pa.float64(), nullable=True), - pa.field("ffs_fee_schedule", pa.map_(pa.utf8(), pa.float64()), nullable=True), -]) - -POLICY_ARROW_SCHEMA = pa.schema([ - pa.field("name", pa.utf8(), nullable=False), - pa.field("description", pa.utf8(), nullable=True), - pa.field("target_population", pa.utf8(), nullable=True), - pa.field("start_month", pa.int32(), nullable=False), - pa.field("end_month", pa.int32(), nullable=True), -]) - -MONTHLY_METRICS_ARROW_SCHEMA = pa.schema([ - pa.field("month", pa.int32(), nullable=False), - pa.field("total_patients", pa.int32(), nullable=False), - pa.field("total_providers", pa.int32(), nullable=False), - pa.field("total_visits", pa.int32(), nullable=False), - pa.field("total_capitation_payments", pa.float64(), nullable=False), - pa.field("total_ffs_payments", pa.float64(), nullable=False), - pa.field("total_funding", pa.float64(), nullable=False), - pa.field("avg_wait_time_days", pa.float64(), nullable=True), - pa.field("unmet_demand", pa.int32(), nullable=False), -]) +PATIENT_ARROW_SCHEMA = pa.schema( + [ + pa.field("age", pa.int32(), nullable=False), + pa.field("gender", pa.utf8(), nullable=False), + pa.field("ethnicity", pa.utf8(), nullable=False), + pa.field("deprivation_index", pa.int32(), nullable=False), + pa.field("comorbidities", pa.list_(pa.utf8()), nullable=True), + pa.field("enrollment_status", pa.utf8(), nullable=False), + ] +) + +PROVIDER_ARROW_SCHEMA = pa.schema( + [ + pa.field("id", pa.utf8(), nullable=False), + pa.field("type", pa.utf8(), nullable=False), + pa.field("region", pa.utf8(), nullable=False), + pa.field("patient_list", pa.list_(pa.utf8()), nullable=True), + pa.field("capacity", pa.int32(), nullable=False), + pa.field("capitation_panel_size", pa.int32(), nullable=False), + ] +) + +CONFIG_ARROW_SCHEMA = pa.schema( + [ + pa.field("seed", pa.int32(), nullable=False), + pa.field("num_patients", pa.int32(), nullable=False), + pa.field("num_providers", pa.int32(), nullable=False), + pa.field("time_horizon_months", pa.int32(), nullable=False), + pa.field("tick_interval_days", pa.int32(), nullable=False), + ] +) + +SCENARIO_ARROW_SCHEMA = pa.schema( + [ + pa.field("name", pa.utf8(), nullable=False), + pa.field("description", pa.utf8(), nullable=True), + pa.field("funding_model", pa.utf8(), nullable=False), + pa.field("capitation_rate", pa.float64(), nullable=True), + pa.field("ffs_fee_schedule", pa.map_(pa.utf8(), pa.float64()), nullable=True), + ] +) + +POLICY_ARROW_SCHEMA = pa.schema( + [ + pa.field("name", pa.utf8(), nullable=False), + pa.field("description", pa.utf8(), nullable=True), + pa.field("target_population", pa.utf8(), nullable=True), + pa.field("start_month", pa.int32(), nullable=False), + pa.field("end_month", pa.int32(), nullable=True), + ] +) + +MONTHLY_METRICS_ARROW_SCHEMA = pa.schema( + [ + pa.field("month", pa.int32(), nullable=False), + pa.field("total_patients", pa.int32(), nullable=False), + pa.field("total_providers", pa.int32(), nullable=False), + pa.field("total_visits", pa.int32(), nullable=False), + pa.field("total_capitation_payments", pa.float64(), nullable=False), + pa.field("total_ffs_payments", pa.float64(), nullable=False), + pa.field("total_funding", pa.float64(), nullable=False), + pa.field("avg_wait_time_days", pa.float64(), nullable=True), + pa.field("unmet_demand", pa.int32(), nullable=False), + ] +) # --------------------------------------------------------------------------- # SHAP attribution schema — feature-level SHAP values per simulation tick # --------------------------------------------------------------------------- -SHAP_ARROW_SCHEMA = pa.schema([ - pa.field("scenario_name", pa.utf8(), nullable=False), - pa.field("month", pa.int32(), nullable=False), - pa.field("batch_idx", pa.int32(), nullable=False), - pa.field("feature_name", pa.utf8(), nullable=False), - pa.field("shap_value", pa.float64(), nullable=False), - pa.field("feature_value", pa.float64(), nullable=True), - pa.field("base_value", pa.float64(), nullable=False), - pa.field("explainer_type", pa.utf8(), nullable=False), -]) - -SHAP_SUMMARY_ARROW_SCHEMA = pa.schema([ - pa.field("scenario_name", pa.utf8(), nullable=False), - pa.field("feature_name", pa.utf8(), nullable=False), - pa.field("mean_abs_shap", pa.float64(), nullable=False), - pa.field("std_shap", pa.float64(), nullable=False), - pa.field("mean_feature_value", pa.float64(), nullable=True), - pa.field("importance_rank", pa.int32(), nullable=False), -]) +SHAP_ARROW_SCHEMA = pa.schema( + [ + pa.field("scenario_name", pa.utf8(), nullable=False), + pa.field("month", pa.int32(), nullable=False), + pa.field("batch_idx", pa.int32(), nullable=False), + pa.field("feature_name", pa.utf8(), nullable=False), + pa.field("shap_value", pa.float64(), nullable=False), + pa.field("feature_value", pa.float64(), nullable=True), + pa.field("base_value", pa.float64(), nullable=False), + pa.field("explainer_type", pa.utf8(), nullable=False), + ] +) + +SHAP_SUMMARY_ARROW_SCHEMA = pa.schema( + [ + pa.field("scenario_name", pa.utf8(), nullable=False), + pa.field("feature_name", pa.utf8(), nullable=False), + pa.field("mean_abs_shap", pa.float64(), nullable=False), + pa.field("std_shap", pa.float64(), nullable=False), + pa.field("mean_feature_value", pa.float64(), nullable=True), + pa.field("importance_rank", pa.int32(), nullable=False), + ] +) # --------------------------------------------------------------------------- # Telemetry schema for simulation run logs # --------------------------------------------------------------------------- -TELEMETRY_ARROW_SCHEMA = pa.schema([ - pa.field("timestamp", pa.timestamp("us"), nullable=False), - pa.field("run_id", pa.utf8(), nullable=False), - pa.field("scenario_name", pa.utf8(), nullable=False), - pa.field("tick", pa.int32(), nullable=False), - pa.field("month", pa.int32(), nullable=False), - pa.field("patient_count", pa.int32(), nullable=False), - pa.field("provider_count", pa.int32(), nullable=False), - pa.field("total_visits", pa.int32(), nullable=False), - pa.field("capitation_flow", pa.float64(), nullable=False), - pa.field("ffs_flow", pa.float64(), nullable=False), - pa.field("total_funding_flow", pa.float64(), nullable=False), - pa.field("avg_wait_days", pa.float64(), nullable=True), - pa.field("unmet_demand", pa.int32(), nullable=False), - pa.field("cpu_usage_pct", pa.float64(), nullable=True), - pa.field("memory_mb", pa.float64(), nullable=True), -]) +TELEMETRY_ARROW_SCHEMA = pa.schema( + [ + pa.field("timestamp", pa.timestamp("us"), nullable=False), + pa.field("run_id", pa.utf8(), nullable=False), + pa.field("scenario_name", pa.utf8(), nullable=False), + pa.field("tick", pa.int32(), nullable=False), + pa.field("month", pa.int32(), nullable=False), + pa.field("patient_count", pa.int32(), nullable=False), + pa.field("provider_count", pa.int32(), nullable=False), + pa.field("total_visits", pa.int32(), nullable=False), + pa.field("capitation_flow", pa.float64(), nullable=False), + pa.field("ffs_flow", pa.float64(), nullable=False), + pa.field("total_funding_flow", pa.float64(), nullable=False), + pa.field("avg_wait_days", pa.float64(), nullable=True), + pa.field("unmet_demand", pa.int32(), nullable=False), + pa.field("cpu_usage_pct", pa.float64(), nullable=True), + pa.field("memory_mb", pa.float64(), nullable=True), + ] +) # --------------------------------------------------------------------------- @@ -191,6 +209,7 @@ def metrics_to_table(metrics: list[MonthlyMetrics]) -> pa.Table: ] return pa.Table.from_arrays(arrays, schema=MONTHLY_METRICS_ARROW_SCHEMA) + # --------------------------------------------------------------------------- # Zero-copy: Arrow Table -> Polars DataFrame # --------------------------------------------------------------------------- @@ -354,14 +373,15 @@ def aggregate_by_ethnicity(metrics_df: pl.DataFrame, patients_df: pl.DataFrame) """ polars = _require_polars() return ( - patients_df - .group_by("ethnicity") - .agg([ - polars.count().alias("patient_count"), - polars.col("age").mean().alias("avg_age"), - polars.col("deprivation_index").mean().alias("avg_deprivation"), - polars.col("comorbidities").list.len().mean().alias("avg_comorbidities"), - ]) + patients_df.group_by("ethnicity") + .agg( + [ + polars.count().alias("patient_count"), + polars.col("age").mean().alias("avg_age"), + polars.col("deprivation_index").mean().alias("avg_deprivation"), + polars.col("comorbidities").list.len().mean().alias("avg_comorbidities"), + ] + ) .sort("ethnicity") ) @@ -383,13 +403,14 @@ def aggregate_by_region(metrics_df: pl.DataFrame, providers_df: pl.DataFrame) -> """ polars = _require_polars() return ( - providers_df - .group_by("region") - .agg([ - polars.count().alias("provider_count"), - polars.col("capacity").sum().alias("total_capacity"), - polars.col("capitation_panel_size").sum().alias("total_capitation_panel"), - ]) + providers_df.group_by("region") + .agg( + [ + polars.count().alias("provider_count"), + polars.col("capacity").sum().alias("total_capacity"), + polars.col("capitation_panel_size").sum().alias("total_capitation_panel"), + ] + ) .sort("region") ) @@ -410,18 +431,19 @@ def aggregate_by_month(metrics_df: pl.DataFrame) -> pl.DataFrame: """ polars = _require_polars() return ( - metrics_df - .group_by("month") - .agg([ - polars.col("total_patients").first().alias("patients"), - polars.col("total_providers").first().alias("providers"), - polars.col("total_visits").sum().alias("total_visits"), - polars.col("total_funding").sum().alias("total_funding"), - polars.col("total_capitation_payments").sum().alias("total_capitation"), - polars.col("total_ffs_payments").sum().alias("total_ffs"), - polars.col("unmet_demand").sum().alias("total_unmet_demand"), - polars.col("avg_wait_time_days").mean().alias("mean_wait_days"), - ]) + metrics_df.group_by("month") + .agg( + [ + polars.col("total_patients").first().alias("patients"), + polars.col("total_providers").first().alias("providers"), + polars.col("total_visits").sum().alias("total_visits"), + polars.col("total_funding").sum().alias("total_funding"), + polars.col("total_capitation_payments").sum().alias("total_capitation"), + polars.col("total_ffs_payments").sum().alias("total_ffs"), + polars.col("unmet_demand").sum().alias("total_unmet_demand"), + polars.col("avg_wait_time_days").mean().alias("mean_wait_days"), + ] + ) .sort("month") ) @@ -440,17 +462,17 @@ def funding_flow_summary(metrics_df: pl.DataFrame) -> pl.DataFrame: Single-row summary of capitation vs FFS proportions. """ polars = _require_polars() - return ( - metrics_df - .select([ + return metrics_df.select( + [ polars.col("total_capitation_payments").sum().alias("total_capitation"), polars.col("total_ffs_payments").sum().alias("total_ffs"), polars.col("total_funding").sum().alias("total_funding"), - ]) - .with_columns([ + ] + ).with_columns( + [ (polars.col("total_capitation") / polars.col("total_funding") * 100).alias("capitation_pct"), (polars.col("total_ffs") / polars.col("total_funding") * 100).alias("ffs_pct"), - ]) + ] ) diff --git a/models/primarycare_model/demonstrative_games.py b/models/primarycare_model/demonstrative_games.py index 9edaf06..d194591 100644 --- a/models/primarycare_model/demonstrative_games.py +++ b/models/primarycare_model/demonstrative_games.py @@ -245,10 +245,19 @@ def as_row(self) -> dict[str, object]: ) -def _welfare(access: float, viability: float, equity: float, fiscal: float, hospital_pressure: float, gaming: float) -> float: +def _welfare( + access: float, viability: float, equity: float, fiscal: float, hospital_pressure: float, gaming: float +) -> float: """Stylised system welfare with hospital pressure and gaming as penalties.""" - return clip(0.28 * access + 0.18 * viability + 0.22 * equity + 0.18 * fiscal + 0.14 * (100 - hospital_pressure) - 0.18 * gaming) + return clip( + 0.28 * access + + 0.18 * viability + + 0.22 * equity + + 0.18 * fiscal + + 0.14 * (100 - hospital_pressure) + - 0.18 * gaming + ) def _outcome( @@ -297,46 +306,127 @@ def model_g1_hospital_salience(s: Scenario) -> GameOutcome: ) marginal_supply = diminishing_return(s.marginal_contact_benefit) rescue_bias = s.hospital_political_penalty * (1 - 0.70 * upstream_salience) ** 1.25 - access = 22 + 30 * marginal_supply + 18 * upstream_salience + 10 * response_curve(s.ambulance_kpi_salience, 0.50, 5.0) + access = ( + 22 + 30 * marginal_supply + 18 * upstream_salience + 10 * response_curve(s.ambulance_kpi_salience, 0.50, 5.0) + ) viability = 35 + 30 * marginal_supply - 15 * s.budget_tightness**1.2 + 10 * upstream_salience - equity = 35 + 25 * diminishing_return(s.equity_program_strength) + 20 * diminishing_return(s.copayment_protections) + 10 * upstream_salience - fiscal = 82 - 30 * marginal_supply - 10 * (1 - s.gaming_controls) ** 1.2 + 12 * diminishing_return(s.data_observability) + equity = ( + 35 + + 25 * diminishing_return(s.equity_program_strength) + + 20 * diminishing_return(s.copayment_protections) + + 10 * upstream_salience + ) + fiscal = ( + 82 - 30 * marginal_supply - 10 * (1 - s.gaming_controls) ** 1.2 + 12 * diminishing_return(s.data_observability) + ) hospital = 35 + 45 * rescue_bias + 15 * (1 - upstream_salience) ** 1.2 - 25 * marginal_supply gaming = 12 + 25 * marginal_supply * (1 - s.gaming_controls) ** 1.3 + 10 * (1 - s.data_observability) ** 1.2 label = "hospital-rescue equilibrium" if rescue_bias > 0.55 else "upstream-salience equilibrium" - return _outcome("G1", "Hospital-salience budget game", s, access, viability, equity, fiscal, hospital, gaming, label, "Hospital rescue bias falls only when upstream access becomes visible, funded and politically salient.") + return _outcome( + "G1", + "Hospital-salience budget game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Hospital rescue bias falls only when upstream access becomes visible, funded and politically salient.", + ) def model_g2_hnz_allocation(s: Scenario) -> GameOutcome: - attention_signal = 0.18 + 0.28 * s.primary_kpi_salience + 0.20 * s.ambulance_kpi_salience + 0.22 * s.data_observability - 0.18 * s.hospital_political_penalty + attention_signal = ( + 0.18 + + 0.28 * s.primary_kpi_salience + + 0.20 * s.ambulance_kpi_salience + + 0.22 * s.data_observability + - 0.18 * s.hospital_political_penalty + ) upstream_attention = response_curve(attention_signal, 0.38, 7.5) marginal_supply = diminishing_return(s.marginal_contact_benefit) access = 20 + 50 * upstream_attention + 18 * marginal_supply viability = 30 + 35 * marginal_supply + 15 * diminishing_return(s.direct_claiming) - 20 * s.budget_tightness**1.2 equity = 32 + 30 * diminishing_return(s.equity_program_strength) + 18 * upstream_attention - fiscal = 75 - 20 * marginal_supply + 12 * diminishing_return(s.data_observability) + 10 * diminishing_return(s.gaming_controls) - hospital = 80 - 38 * upstream_attention - 18 * marginal_supply - 10 * diminishing_return(s.ambulance_alternative_funding) - gaming = 20 + 12 * (1 - s.data_observability) ** 1.2 + 18 * response_curve(s.primary_kpi_salience * (1 - s.gaming_controls), 0.22, 8.0) + fiscal = ( + 75 + - 20 * marginal_supply + + 12 * diminishing_return(s.data_observability) + + 10 * diminishing_return(s.gaming_controls) + ) + hospital = ( + 80 - 38 * upstream_attention - 18 * marginal_supply - 10 * diminishing_return(s.ambulance_alternative_funding) + ) + gaming = ( + 20 + + 12 * (1 - s.data_observability) ** 1.2 + + 18 * response_curve(s.primary_kpi_salience * (1 - s.gaming_controls), 0.22, 8.0) + ) label = "hospital-operations dominance" if upstream_attention < 0.45 else "balanced internal accountability" - return _outcome("G2", "Health NZ internal allocation game", s, access, viability, equity, fiscal, hospital, gaming, label, "Management attention shifts upstream when primary and ambulance outcomes have hospital-equivalent salience.") + return _outcome( + "G2", + "Health NZ internal allocation game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Management attention shifts upstream when primary and ambulance outcomes have hospital-equivalent salience.", + ) def model_g3_capitation_supply(s: Scenario) -> GameOutcome: - effective_margin = s.marginal_contact_benefit + 0.35 * s.copayment_level + 0.18 * s.capitation_weighting - 0.62 - 0.15 * s.pho_transaction_cost - 0.10 * s.budget_tightness + effective_margin = ( + s.marginal_contact_benefit + + 0.35 * s.copayment_level + + 0.18 * s.capitation_weighting + - 0.62 + - 0.15 * s.pho_transaction_cost + - 0.10 * s.budget_tightness + ) supply_elasticity = sigmoid(6 * effective_margin) access = 18 + 74 * supply_elasticity viability = 25 + 70 * supply_elasticity - 8 * s.budget_tightness - equity = 35 + 25 * s.capitation_weighting + 20 * s.copayment_protections + 15 * s.equity_program_strength - 18 * s.copayment_level * (1 - s.copayment_protections) + equity = ( + 35 + + 25 * s.capitation_weighting + + 20 * s.copayment_protections + + 15 * s.equity_program_strength + - 18 * s.copayment_level * (1 - s.copayment_protections) + ) fiscal = 85 - 25 * s.marginal_contact_benefit - 10 * supply_elasticity + 12 * s.gaming_controls hospital = 86 - 55 * supply_elasticity - 8 * s.ambulance_alternative_funding gaming = 10 + 25 * s.marginal_contact_benefit * (1 - s.gaming_controls) label = "marginal-rationing equilibrium" if supply_elasticity < 0.45 else "marginal-expansion equilibrium" - return _outcome("G3", "Capitation marginal-supply game", s, access, viability, equity, fiscal, hospital, gaming, label, "Additional contacts expand only when combined public benefit and co-payment exceed marginal cost, admin cost and risk.") + return _outcome( + "G3", + "Capitation marginal-supply game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Additional contacts expand only when combined public benefit and co-payment exceed marginal cost, admin cost and risk.", + ) def model_g4_consumer_pathway(s: Scenario) -> GameOutcome: effective_price = s.copayment_level * (1 - 0.75 * s.copayment_protections) - availability = 0.35 * s.marginal_contact_benefit + 0.20 * s.scope_flexibility + 0.15 * s.telehealth_integration + 0.15 * s.local_inperson_loading + 0.15 * s.ambulance_alternative_funding + availability = ( + 0.35 * s.marginal_contact_benefit + + 0.20 * s.scope_flexibility + + 0.15 * s.telehealth_integration + + 0.15 * s.local_inperson_loading + + 0.15 * s.ambulance_alternative_funding + ) early_access_share = sigmoid(3.2 * availability - 2.6 * effective_price - 0.6) access = 15 + 78 * early_access_share viability = 35 + 25 * availability + 18 * s.marginal_contact_benefit @@ -345,7 +435,19 @@ def model_g4_consumer_pathway(s: Scenario) -> GameOutcome: hospital = 88 - 52 * early_access_share + 12 * effective_price gaming = 12 + 12 * (1 - s.gaming_controls) + 10 * s.telehealth_scale * (1 - s.telehealth_integration) label = "delay/ED-substitution equilibrium" if early_access_share < 0.50 else "early-access equilibrium" - return _outcome("G4", "Consumer access pathway game", s, access, viability, equity, fiscal, hospital, gaming, label, "Patients move to lower-cost access when price, wait, travel and trust costs are lower than delay or ED substitution.") + return _outcome( + "G4", + "Consumer access pathway game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Patients move to lower-cost access when price, wait, travel and trust costs are lower than delay or ED substitution.", + ) def model_g5_pho_intermediation(s: Scenario) -> GameOutcome: @@ -358,33 +460,84 @@ def model_g5_pho_intermediation(s: Scenario) -> GameOutcome: hospital = 75 - 30 * s.direct_claiming - 12 * function_value - 10 * s.marginal_contact_benefit + 22 * entry_barrier gaming = 18 + 20 * s.direct_claiming * (1 - s.gaming_controls) + 12 * (1 - s.data_observability) label = "intermediated-gatekeeping equilibrium" if entry_barrier > 0.45 else "optional/direct-claiming equilibrium" - return _outcome("G5", "PHO intermediation game", s, access, viability, equity, fiscal, hospital, gaming, label, "Direct claiming improves entry only if PHO/locality equity and population-health functions are explicitly preserved.") + return _outcome( + "G5", + "PHO intermediation game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Direct claiming improves entry only if PHO/locality equity and population-health functions are explicitly preserved.", + ) def model_g6_acc_cross_funder(s: Scenario) -> GameOutcome: - viability_signal = 0.35 * s.marginal_contact_benefit + 0.30 * s.acc_activity_funding - 0.30 * s.acc_constraint + 0.15 * s.direct_claiming + 0.10 * s.capitation_weighting + viability_signal = ( + 0.35 * s.marginal_contact_benefit + + 0.30 * s.acc_activity_funding + - 0.30 * s.acc_constraint + + 0.15 * s.direct_claiming + + 0.10 * s.capitation_weighting + ) spillover_risk = 0.65 * s.acc_constraint + 0.25 * (1 - s.data_observability) + 0.10 * (1 - s.stakeholder_alignment) access = 25 + 55 * viability_signal - 15 * spillover_risk viability = 30 + 65 * viability_signal - 20 * spillover_risk equity = 40 + 20 * s.equity_program_strength + 15 * s.copayment_protections + 10 * s.data_observability - fiscal = 70 + 15 * s.data_observability - 12 * s.marginal_contact_benefit - 15 * s.acc_activity_funding * (1 - s.acc_constraint) + fiscal = ( + 70 + + 15 * s.data_observability + - 12 * s.marginal_contact_benefit + - 15 * s.acc_activity_funding * (1 - s.acc_constraint) + ) hospital = 78 - 38 * viability_signal + 25 * spillover_risk - 8 * s.ambulance_alternative_funding gaming = 14 + 10 * (1 - s.data_observability) + 12 * (1 - s.gaming_controls) label = "siloed-cost-shifting equilibrium" if spillover_risk > 0.30 else "whole-of-Crown flow equilibrium" - return _outcome("G6", "ACC/Health NZ cross-funder game", s, access, viability, equity, fiscal, hospital, gaming, label, "ACC activity funding can stabilise lower-cost capacity; constraining it in isolation risks spillover to Health NZ and patients.") + return _outcome( + "G6", + "ACC/Health NZ cross-funder game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "ACC activity funding can stabilise lower-cost capacity; constraining it in isolation risks spillover to Health NZ and patients.", + ) def model_g7_ambulance_conveyance(s: Scenario) -> GameOutcome: - alternative_resolution = s.ambulance_alternative_funding * (0.45 * s.safety_governance + 0.30 * s.ambulance_kpi_salience + 0.25 * s.data_observability) + alternative_resolution = s.ambulance_alternative_funding * ( + 0.45 * s.safety_governance + 0.30 * s.ambulance_kpi_salience + 0.25 * s.data_observability + ) conveyance_default = 1 - alternative_resolution access = 28 + 48 * alternative_resolution + 14 * s.primary_kpi_salience viability = 35 + 30 * alternative_resolution + 12 * s.acc_activity_funding equity = 42 + 18 * alternative_resolution + 20 * s.equity_program_strength + 10 * s.local_inperson_loading fiscal = 68 + 10 * alternative_resolution + 10 * s.data_observability - 12 * s.ambulance_alternative_funding hospital = 85 - 55 * alternative_resolution + 10 * conveyance_default - gaming = 10 + 35 * s.ambulance_alternative_funding * max(0, 0.75 - s.safety_governance) + 10 * (1 - s.data_observability) + gaming = ( + 10 + 35 * s.ambulance_alternative_funding * max(0, 0.75 - s.safety_governance) + 10 * (1 - s.data_observability) + ) label = "ED-conveyance default" if alternative_resolution < 0.45 else "safe alternative-disposition equilibrium" - return _outcome("G7", "Ambulance conveyance game", s, access, viability, equity, fiscal, hospital, gaming, label, "Alternative disposition is stable only when payment, clinical governance, data and follow-up reduce organisational risk.") + return _outcome( + "G7", + "Ambulance conveyance game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Alternative disposition is stable only when payment, clinical governance, data and follow-up reduce organisational risk.", + ) def model_g8_scope_supply(s: Scenario) -> GameOutcome: @@ -397,21 +550,65 @@ def model_g8_scope_supply(s: Scenario) -> GameOutcome: hospital = 82 - 42 * safe_scope - 18 * s.marginal_contact_benefit + 15 * bottleneck gaming = 8 + 50 * max(0, s.scope_flexibility - s.safety_governance) + 18 * (1 - s.gaming_controls) label = "professional-bottleneck equilibrium" if safe_scope < 0.45 else "scope-enabled supply equilibrium" - return _outcome("G8", "Scope-of-practice supply game", s, access, viability, equity, fiscal, hospital, gaming, label, "Funding eligibility should follow safe scope and governance, not professional category alone.") + return _outcome( + "G8", + "Scope-of-practice supply game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Funding eligibility should follow safe scope and governance, not professional category alone.", + ) def model_g9_telehealth_local_supply(s: Scenario) -> GameOutcome: - simple_access = 0.45 * s.telehealth_scale + 0.25 * s.marginal_contact_benefit + 0.20 * s.data_observability + 0.10 * s.scope_flexibility - local_viability = 0.45 * s.local_inperson_loading + 0.25 * s.telehealth_integration + 0.20 * s.marginal_contact_benefit - 0.30 * s.telehealth_scale * (1 - s.telehealth_integration) + simple_access = ( + 0.45 * s.telehealth_scale + + 0.25 * s.marginal_contact_benefit + + 0.20 * s.data_observability + + 0.10 * s.scope_flexibility + ) + local_viability = ( + 0.45 * s.local_inperson_loading + + 0.25 * s.telehealth_integration + + 0.20 * s.marginal_contact_benefit + - 0.30 * s.telehealth_scale * (1 - s.telehealth_integration) + ) fragmentation = s.telehealth_scale * (1 - s.telehealth_integration) * (1 - 0.5 * s.data_observability) access = 25 + 45 * simple_access + 25 * max(0, local_viability) viability = 30 + 55 * max(0, local_viability) - equity = 42 + 20 * s.telehealth_integration + 20 * s.local_inperson_loading + 15 * s.equity_program_strength - 18 * fragmentation + equity = ( + 42 + + 20 * s.telehealth_integration + + 20 * s.local_inperson_loading + + 15 * s.equity_program_strength + - 18 * fragmentation + ) fiscal = 70 + 8 * s.telehealth_scale - 10 * s.local_inperson_loading + 10 * s.gaming_controls hospital = 80 - 24 * simple_access - 28 * max(0, local_viability) + 20 * fragmentation gaming = 12 + 35 * fragmentation + 12 * (1 - s.gaming_controls) - label = "telehealth-substitution/fragmentation" if fragmentation > 0.30 or local_viability < 0.25 else "integrated hybrid-access equilibrium" - return _outcome("G9", "Telehealth/local-supply game", s, access, viability, equity, fiscal, hospital, gaming, label, "Telehealth improves simple access but must be integrated and paired with local in-person capacity.") + label = ( + "telehealth-substitution/fragmentation" + if fragmentation > 0.30 or local_viability < 0.25 + else "integrated hybrid-access equilibrium" + ) + return _outcome( + "G9", + "Telehealth/local-supply game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Telehealth improves simple access but must be integrated and paired with local in-person capacity.", + ) def model_g10_copayment(s: Scenario) -> GameOutcome: @@ -424,7 +621,19 @@ def model_g10_copayment(s: Scenario) -> GameOutcome: hospital = 45 + 48 * effective_burden - 18 * s.marginal_contact_benefit gaming = 10 + 28 * s.marginal_contact_benefit * (1 - s.gaming_controls) + 12 * max(0, 0.45 - s.copayment_level) label = "price-rationing equity failure" if effective_burden > 0.35 else "calibrated co-payment equilibrium" - return _outcome("G10", "Co-payment calibration game", s, access, viability, equity, fiscal, hospital, gaming, label, "Co-payment can moderate discretionary demand but becomes a delayed-care mechanism without protections.") + return _outcome( + "G10", + "Co-payment calibration game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Co-payment can moderate discretionary demand but becomes a delayed-care mechanism without protections.", + ) def model_g11_kpi_salience(s: Scenario) -> GameOutcome: @@ -437,11 +646,28 @@ def model_g11_kpi_salience(s: Scenario) -> GameOutcome: hospital = 84 - 42 * kpi_strength - 18 * s.marginal_contact_benefit + 15 * target_gaming gaming = 8 + 70 * target_gaming label = "hospital-target dominance" if kpi_strength < 0.45 else "upstream target salience with balancing measures" - return _outcome("G11", "KPI salience game", s, access, viability, equity, fiscal, hospital, gaming, label, "Top-tier KPIs shift behaviour only if paired with funding levers, data and balancing measures.") + return _outcome( + "G11", + "KPI salience game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Top-tier KPIs shift behaviour only if paired with funding levers, data and balancing measures.", + ) def model_g12_equity_trust(s: Scenario) -> GameOutcome: - trust_function = 0.45 * s.equity_program_strength + 0.25 * s.stakeholder_alignment + 0.20 * s.copayment_protections + 0.10 * s.local_inperson_loading + trust_function = ( + 0.45 * s.equity_program_strength + + 0.25 * s.stakeholder_alignment + + 0.20 * s.copayment_protections + + 0.10 * s.local_inperson_loading + ) direct_risk = s.direct_claiming * max(0, 0.65 - trust_function) access = 28 + 34 * s.marginal_contact_benefit + 28 * trust_function - 15 * direct_risk viability = 34 + 20 * s.marginal_contact_benefit + 20 * s.scope_flexibility + 15 * trust_function @@ -450,12 +676,31 @@ def model_g12_equity_trust(s: Scenario) -> GameOutcome: hospital = 78 - 28 * trust_function - 24 * s.marginal_contact_benefit + 18 * direct_risk gaming = 12 + 25 * direct_risk + 12 * (1 - s.data_observability) label = "transactional-access without trust" if direct_risk > 0.20 else "benefits plus equity-function equilibrium" - return _outcome("G12", "Equity and trust game", s, access, viability, equity, fiscal, hospital, gaming, label, "Demand-driven benefits need retained kaupapa Maori, Pacific, rural and locality functions to avoid transactional equity failure.") + return _outcome( + "G12", + "Equity and trust game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Demand-driven benefits need retained kaupapa Maori, Pacific, rural and locality functions to avoid transactional equity failure.", + ) def model_g13_political_economy(s: Scenario) -> GameOutcome: - coalition_score = 0.40 * s.narrative_coherence + 0.30 * s.stakeholder_alignment + 0.15 * s.data_observability + 0.15 * s.equity_program_strength - contestability = s.marginal_contact_benefit * (1 - s.narrative_coherence) + s.direct_claiming * (1 - s.stakeholder_alignment) + coalition_score = ( + 0.40 * s.narrative_coherence + + 0.30 * s.stakeholder_alignment + + 0.15 * s.data_observability + + 0.15 * s.equity_program_strength + ) + contestability = s.marginal_contact_benefit * (1 - s.narrative_coherence) + s.direct_claiming * ( + 1 - s.stakeholder_alignment + ) access = 28 + 35 * coalition_score + 22 * s.marginal_contact_benefit viability = 35 + 25 * coalition_score + 22 * s.marginal_contact_benefit equity = 40 + 25 * coalition_score + 20 * s.equity_program_strength + 10 * s.copayment_protections @@ -463,12 +708,26 @@ def model_g13_political_economy(s: Scenario) -> GameOutcome: hospital = 78 - 30 * coalition_score - 16 * s.marginal_contact_benefit + 18 * contestability gaming = 16 + 45 * contestability * (1 - s.gaming_controls) label = "institutional-defence equilibrium" if coalition_score < 0.50 else "access-architecture coalition" - return _outcome("G13", "Political economy game", s, access, viability, equity, fiscal, hospital, gaming, label, "Reform becomes feasible when framed as patient access and hospital avoidance rather than sector income or anti-PHO politics.") + return _outcome( + "G13", + "Political economy game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Reform becomes feasible when framed as patient access and hospital avoidance rather than sector income or anti-PHO politics.", + ) def model_g14_data_observability(s: Scenario) -> GameOutcome: observability = s.data_observability - budget_shift = observability * (0.45 * s.primary_kpi_salience + 0.30 * s.ambulance_kpi_salience + 0.25 * s.narrative_coherence) + budget_shift = observability * ( + 0.45 * s.primary_kpi_salience + 0.30 * s.ambulance_kpi_salience + 0.25 * s.narrative_coherence + ) hidden_need = 1 - observability access = 24 + 38 * budget_shift + 20 * s.marginal_contact_benefit + 12 * s.direct_claiming viability = 34 + 28 * budget_shift + 24 * s.marginal_contact_benefit @@ -477,7 +736,19 @@ def model_g14_data_observability(s: Scenario) -> GameOutcome: hospital = 82 - 34 * budget_shift - 20 * observability - 12 * s.marginal_contact_benefit + 18 * hidden_need gaming = 10 + 30 * (1 - observability) + 20 * (1 - s.gaming_controls) label = "hidden-unmet-need equilibrium" if observability < 0.55 else "observable upstream-flow equilibrium" - return _outcome("G14", "Data observability game", s, access, viability, equity, fiscal, hospital, gaming, label, "Upstream access failure becomes fundable only when data links it to ambulance, ED and avoidable admission outcomes.") + return _outcome( + "G14", + "Data observability game", + s, + access, + viability, + equity, + fiscal, + hospital, + gaming, + label, + "Upstream access failure becomes fundable only when data links it to ambulance, ED and avoidable admission outcomes.", + ) GAME_MODELS: Mapping[str, Callable[[Scenario], GameOutcome]] = { diff --git a/models/primarycare_model/diffusion.py b/models/primarycare_model/diffusion.py index a4d9fd4..78d8a64 100644 --- a/models/primarycare_model/diffusion.py +++ b/models/primarycare_model/diffusion.py @@ -2,6 +2,7 @@ Bass diffusion model for provider innovation adoption trajectories. Implements: dA/dt = (p + q*A/M)*(M - A) with discrete-time simulation. """ + from __future__ import annotations from dataclasses import dataclass, field @@ -23,6 +24,7 @@ class BassDiffusionParams: num_regions: Number of geographic regions for spatial spread region_connectivity: How connected regions are (0-1), affects spatial diffusion """ + p: float = 0.03 q: float = 0.40 M: int = 1000 @@ -35,6 +37,7 @@ class BassDiffusionParams: @dataclass class BassDiffusionResult: """Output from a Bass diffusion simulation.""" + time_series: pd.DataFrame region_time_series: pd.DataFrame | None = None summary: dict[str, float] = field(default_factory=dict) @@ -42,14 +45,16 @@ class BassDiffusionResult: @property def takeoff_year(self) -> int | None: """Year when adoption rate >= 10% of M.""" - if self.time_series.empty: return None + if self.time_series.empty: + return None idx = self.time_series[self.time_series["adopters"] >= 0.1 * self.time_series["M"].iloc[0]].index return int(self.time_series.loc[idx[0], "year"]) if len(idx) > 0 else None @property def saturation_year(self) -> int | None: """Year when adoption rate >= 90% of M.""" - if self.time_series.empty: return None + if self.time_series.empty: + return None idx = self.time_series[self.time_series["adopters"] >= 0.9 * self.time_series["M"].iloc[0]].index return int(self.time_series.loc[idx[0], "year"]) if len(idx) > 0 else None @@ -77,21 +82,25 @@ def simulate_bass(params: BassDiffusionParams) -> BassDiffusionResult: new_adopters_list.append(new_this_year) adoption_rate.append(A / M) - df = pd.DataFrame({ - "year": years, - "adopters": adopters, - "new_adopters": new_adopters_list, - "adoption_rate": adoption_rate, - "remaining": [M - a for a in adopters], - "p": p, - "q": q, - "M": M, - }) + df = pd.DataFrame( + { + "year": years, + "adopters": adopters, + "new_adopters": new_adopters_list, + "adoption_rate": adoption_rate, + "remaining": [M - a for a in adopters], + "p": p, + "q": q, + "M": M, + } + ) takeoff = BassDiffusionResult(time_series=df).takeoff_year saturation = BassDiffusionResult(time_series=df).saturation_year summary = { - "p": p, "q": q, "M": M, + "p": p, + "q": q, + "M": M, "final_adopters": adopters[-1], "final_rate": adoption_rate[-1], "takeoff_year": takeoff or 0, @@ -108,7 +117,10 @@ def simulate_bass(params: BassDiffusionParams) -> BassDiffusionResult: r_q = q * connectivity_boost * (0.70 + rng.random() * 0.60) r_M = max(1, int((M / params.num_regions) * (0.75 + rng.random() * 0.50))) r_params = BassDiffusionParams( - p=r_p, q=r_q, M=r_M, T=T, + p=r_p, + q=r_q, + M=r_M, + T=T, initial_adopters=max(1, int(params.initial_adopters / params.num_regions)), num_regions=1, ) diff --git a/models/primarycare_model/empirical_calibration.py b/models/primarycare_model/empirical_calibration.py index aaa5610..d34b7d0 100644 --- a/models/primarycare_model/empirical_calibration.py +++ b/models/primarycare_model/empirical_calibration.py @@ -246,19 +246,33 @@ def _validate_shocks( shock_inputs = DEFAULT_BASELINE if "copayment" in shock_type: shock_inputs = DEFAULT_BASELINE.__class__( - **{**asdict(DEFAULT_BASELINE), "copayment_level": max(0.01, min(0.95, DEFAULT_BASELINE.copayment_level + delta))} + **{ + **asdict(DEFAULT_BASELINE), + "copayment_level": max(0.01, min(0.95, DEFAULT_BASELINE.copayment_level + delta)), + } ) elif "scope" in shock_type: shock_inputs = DEFAULT_BASELINE.__class__( - **{**asdict(DEFAULT_BASELINE), "scope_flexibility": max(0.01, min(0.95, DEFAULT_BASELINE.scope_flexibility + delta))} + **{ + **asdict(DEFAULT_BASELINE), + "scope_flexibility": max(0.01, min(0.95, DEFAULT_BASELINE.scope_flexibility + delta)), + } ) elif "acc" in shock_type: shock_inputs = DEFAULT_BASELINE.__class__( - **{**asdict(DEFAULT_BASELINE), "acc_activity_strength": max(0.01, min(0.95, DEFAULT_BASELINE.acc_activity_strength + delta))} + **{ + **asdict(DEFAULT_BASELINE), + "acc_activity_strength": max(0.01, min(0.95, DEFAULT_BASELINE.acc_activity_strength + delta)), + } ) elif "ambulance" in shock_type: shock_inputs = DEFAULT_BASELINE.__class__( - **{**asdict(DEFAULT_BASELINE), "ambulance_alternative_strength": max(0.01, min(0.95, DEFAULT_BASELINE.ambulance_alternative_strength + delta))} + **{ + **asdict(DEFAULT_BASELINE), + "ambulance_alternative_strength": max( + 0.01, min(0.95, DEFAULT_BASELINE.ambulance_alternative_strength + delta) + ), + } ) shocked = simulate_months(params, shock_inputs, months=len(observations)) @@ -286,10 +300,14 @@ def _validate_shocks( } -def _validate_equity(observations: pd.DataFrame, params: CalibrationParameters, equity_df: pd.DataFrame) -> dict[str, Any]: +def _validate_equity( + observations: pd.DataFrame, params: CalibrationParameters, equity_df: pd.DataFrame +) -> dict[str, Any]: if equity_df.empty: return {"status": False, "passed": False, "message": "equity file missing"} - group_col = "equity_group" if "equity_group" in equity_df.columns else ("group" if "group" in equity_df.columns else None) + group_col = ( + "equity_group" if "equity_group" in equity_df.columns else ("group" if "group" in equity_df.columns else None) + ) if group_col is None: return {"status": False, "passed": False, "message": "equity file missing group column"} @@ -315,7 +333,11 @@ def _validate_equity(observations: pd.DataFrame, params: CalibrationParameters, corr = observed.corr(predicted) if pd.isna(corr): - return {"status": False, "passed": False, "message": "equity check failed (insufficient variation in predicted signal)"} + return { + "status": False, + "passed": False, + "message": "equity check failed (insufficient variation in predicted signal)", + } return { "status": True, @@ -332,7 +354,9 @@ def _validate_geographic( if geographic_df.empty: return {"status": False, "passed": False, "message": "geographic file missing"} - locality_col = "locality" if "locality" in geographic_df.columns else ("region" if "region" in geographic_df.columns else None) + locality_col = ( + "locality" if "locality" in geographic_df.columns else ("region" if "region" in geographic_df.columns else None) + ) if locality_col is None: return {"status": False, "passed": False, "message": "geographic file missing locality column"} @@ -367,7 +391,12 @@ def _validate_temporal_holdout(observations: pd.DataFrame) -> dict[str, Any]: train_fit = _fit_parameters(train) pred = _simulate_baseline(len(test), train_fit) score = _safe_score(pred, test, TARGET_METRICS) - return {"status": True, "passed": score <= 0.45, "metric": float(score), "message": f"temporal holdout score={score:.3f}"} + return { + "status": True, + "passed": score <= 0.45, + "metric": float(score), + "message": f"temporal holdout score={score:.3f}", + } @dataclass(frozen=True) @@ -406,16 +435,24 @@ def load_linked_inputs( shock_path = _find_first_existing(base_dir, SHOCK_DATA_FILES) monthly = _coerce_required_frame(monthly_path, list(TARGET_METRICS)) if monthly_path else pd.DataFrame() - geographic = _coerce_required_frame( - geographic_path, - ["primary_contacts"], - keep=("locality", "unmet_need_index", "ed_presentations", "ambulance_conveyances", "public_cost"), - ) if geographic_path else pd.DataFrame() - equity = _coerce_required_frame( - equity_path, - ["primary_contacts"], - keep=("equity_group", "unmet_need_index", "ed_presentations", "ambulance_conveyances", "public_cost"), - ) if equity_path else pd.DataFrame() + geographic = ( + _coerce_required_frame( + geographic_path, + ["primary_contacts"], + keep=("locality", "unmet_need_index", "ed_presentations", "ambulance_conveyances", "public_cost"), + ) + if geographic_path + else pd.DataFrame() + ) + equity = ( + _coerce_required_frame( + equity_path, + ["primary_contacts"], + keep=("equity_group", "unmet_need_index", "ed_presentations", "ambulance_conveyances", "public_cost"), + ) + if equity_path + else pd.DataFrame() + ) shock = _load_grouped_shocks(shock_path) return monthly, geographic, equity, shock @@ -513,7 +550,11 @@ def run_empirical_calibration_pipeline( supported_where_valid=passed_all and in_sample <= tolerance_temporal_score, in_sample_score=float(in_sample), holdout_score=temporal.get("metric") if temporal.get("metric") is not None else None, - parameter_estimates={k: float(v) for k, v in asdict(fitted).items() if k not in {"base_primary_contacts", "base_ed_presentations", "base_public_cost"}}, + parameter_estimates={ + k: float(v) + for k, v in asdict(fitted).items() + if k not in {"base_primary_contacts", "base_ed_presentations", "base_public_cost"} + }, parameter_bounds=bounds, validation=validation, source_monthly="linked-nz-monthly-observations.csv" if not monthly_df.empty else None, @@ -558,7 +599,11 @@ def calibration_ready_rows(summary: LinkedCalibrationSummary) -> tuple[tuple[str ( "ED and inpatient data", "ED presentations, admissions, diagnosis and disposition", - "Ready for calibration" if summary.available and summary.supported_where_valid else "In progress" if summary.available else "Needed", + "Ready for calibration" + if summary.available and summary.supported_where_valid + else "In progress" + if summary.available + else "Needed", "Downstream hospital-pressure validation", ), ( diff --git a/models/primarycare_model/engines/abm_adapter.py b/models/primarycare_model/engines/abm_adapter.py index 049f16f..c57d0b1 100644 --- a/models/primarycare_model/engines/abm_adapter.py +++ b/models/primarycare_model/engines/abm_adapter.py @@ -91,7 +91,15 @@ def calculate(self, inp: ABMInput) -> dict[str, float]: ) ) gaming_risk = self._clamp( - 100 * (0.35 * activity + 0.18 * scope + 0.18 * self._as_fraction(inp.complexity) - 0.30 * governance - 0.18 * data - 0.16 * place) + 100 + * ( + 0.35 * activity + + 0.18 * scope + + 0.18 * self._as_fraction(inp.complexity) + - 0.30 * governance + - 0.18 * data + - 0.16 * place + ) ) return { diff --git a/models/primarycare_model/engines/diffusion_adapter.py b/models/primarycare_model/engines/diffusion_adapter.py index c35aac4..5a39c54 100644 --- a/models/primarycare_model/engines/diffusion_adapter.py +++ b/models/primarycare_model/engines/diffusion_adapter.py @@ -89,12 +89,14 @@ def run(self, inputs: DiffusionInput) -> DiffusionOutput: peak_adoption = new_adopters peak_month = month - trace.append({ - "month": month, - "new_adopters": round(new_adopters, 2), - "cumulative_adopters": round(adopters, 2), - "penetration_rate": round(adopters / m * 100, 2), - }) + trace.append( + { + "month": month, + "new_adopters": round(new_adopters, 2), + "cumulative_adopters": round(adopters, 2), + "penetration_rate": round(adopters / m * 100, 2), + } + ) scenario_result = ScenarioResult( scenario_id=inp.scenario_id, diff --git a/models/primarycare_model/engines/jax_mc_adapter.py b/models/primarycare_model/engines/jax_mc_adapter.py index 3a1894e..25c27c5 100644 --- a/models/primarycare_model/engines/jax_mc_adapter.py +++ b/models/primarycare_model/engines/jax_mc_adapter.py @@ -45,6 +45,7 @@ class MCOutput(EngineOutput): draw_data: tuple[dict[str, float | int | str], ...] uncertainty_summaries: tuple[UncertaintySummary, ...] + class MonteCarloAdapter: """Typed adapter for Monte Carlo simulation. @@ -76,15 +77,54 @@ def _calculate_indices(self, inp: MCInput) -> dict[str, float]: compl = self._as_fraction(inp.complexity) hosp_sal = self._as_fraction(inp.hospital_salience) - supply = self._clamp(100 * (0.34 * activity + 0.18 * capitation + 0.24 * scope + 0.12 * urgent + 0.12 * place - 0.12 * budget)) - access = self._clamp(100 * (0.42 * supply / 100 + 0.18 * urgent + 0.15 * equity + 0.12 * place + 0.10 * data - 0.16 * copay)) + supply = self._clamp( + 100 * (0.34 * activity + 0.18 * capitation + 0.24 * scope + 0.12 * urgent + 0.12 * place - 0.12 * budget) + ) + access = self._clamp( + 100 * (0.42 * supply / 100 + 0.18 * urgent + 0.15 * equity + 0.12 * place + 0.10 * data - 0.16 * copay) + ) equity_leg = self._clamp(100 * (0.34 * equity + 0.24 * place + 0.16 * capitation + 0.14 * data - 0.16 * copay)) - gov_res = self._clamp(100 * (0.44 * governance + 0.20 * data + 0.18 * place + 0.10 * equity + 0.08 * capitation)) - hosp_def = self._clamp(100 * (0.32 * access / 100 + 0.22 * urgent + 0.16 * supply / 100 + 0.16 * data + 0.14 * place - 0.10 * compl)) - gaming = self._clamp(100 * (0.35 * activity + 0.18 * scope + 0.18 * compl - 0.30 * governance - 0.18 * data - 0.16 * place)) - fiscal = self._clamp(100 * (0.22 * activity + 0.18 * gaming / 100 + 0.16 * compl + 0.14 * (1 - budget) - 0.18 * governance - 0.14 * hosp_def / 100)) - hosp_pressure = self._clamp(100 * (0.34 * hosp_sal + 0.26 * (1 - hosp_def / 100) + 0.16 * compl + 0.14 * budget - 0.18 * access / 100 - 0.12 * urgent)) - hybrid = self._clamp(0.24 * supply + 0.18 * access + 0.18 * equity_leg + 0.16 * gov_res + 0.14 * hosp_def + 0.06 * (100 - fiscal) + 0.04 * (100 - gaming)) + gov_res = self._clamp( + 100 * (0.44 * governance + 0.20 * data + 0.18 * place + 0.10 * equity + 0.08 * capitation) + ) + hosp_def = self._clamp( + 100 + * (0.32 * access / 100 + 0.22 * urgent + 0.16 * supply / 100 + 0.16 * data + 0.14 * place - 0.10 * compl) + ) + gaming = self._clamp( + 100 * (0.35 * activity + 0.18 * scope + 0.18 * compl - 0.30 * governance - 0.18 * data - 0.16 * place) + ) + fiscal = self._clamp( + 100 + * ( + 0.22 * activity + + 0.18 * gaming / 100 + + 0.16 * compl + + 0.14 * (1 - budget) + - 0.18 * governance + - 0.14 * hosp_def / 100 + ) + ) + hosp_pressure = self._clamp( + 100 + * ( + 0.34 * hosp_sal + + 0.26 * (1 - hosp_def / 100) + + 0.16 * compl + + 0.14 * budget + - 0.18 * access / 100 + - 0.12 * urgent + ) + ) + hybrid = self._clamp( + 0.24 * supply + + 0.18 * access + + 0.18 * equity_leg + + 0.16 * gov_res + + 0.14 * hosp_def + + 0.06 * (100 - fiscal) + + 0.04 * (100 - gaming) + ) return { "hybrid_viability_score": round(hybrid, 2), @@ -109,7 +149,13 @@ def run(self, inputs: MCInput) -> MCOutput: ref = self._calculate_indices(inp) # Stochastic draws - keys = ["hybrid_viability_score", "access_score", "hospital_pressure_score", "gaming_risk_score", "fiscal_risk_score"] + keys = [ + "hybrid_viability_score", + "access_score", + "hospital_pressure_score", + "gaming_risk_score", + "fiscal_risk_score", + ] draw_metrics: dict[str, list[float]] = {k: [] for k in keys} draw_rows: list[dict[str, float | int | str]] = [] @@ -181,4 +227,3 @@ def run(self, inputs: MCInput) -> MCOutput: draw_data=tuple(draw_rows), uncertainty_summaries=tuple(summaries), ) - diff --git a/models/primarycare_model/engines/mpc_adapter.py b/models/primarycare_model/engines/mpc_adapter.py index 49d3f92..e339e04 100644 --- a/models/primarycare_model/engines/mpc_adapter.py +++ b/models/primarycare_model/engines/mpc_adapter.py @@ -52,6 +52,7 @@ class MPCOutput(EngineOutput): optimised_levers: dict[str, float] total_cost: float + class ModelPredictiveControlAdapter: """Typed adapter for model-predictive control simulation. @@ -69,7 +70,8 @@ def _clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float: def _as_fraction(value: float) -> float: return float(max(0.0, min(100.0, value))) / 100.0 - def _calculate_indices(self, + def _calculate_indices( + self, activity: float, capitation: float, place: float, @@ -86,18 +88,39 @@ def _calculate_indices(self, def f(v: float) -> float: return float(max(0.0, min(100.0, v))) / 100.0 - a, c, p, s, u, d, g, e, cp, b, _hs, cx = [f(v) for v in (activity, capitation, place, scope, urgent, data, governance, equity, copay, budget, hosp_sal, compl)] + a, c, p, s, u, d, g, e, cp, b, _hs, cx = [ + f(v) + for v in ( + activity, + capitation, + place, + scope, + urgent, + data, + governance, + equity, + copay, + budget, + hosp_sal, + compl, + ) + ] supply = self._clamp(100 * (0.34 * a + 0.18 * c + 0.24 * s + 0.12 * u + 0.12 * p - 0.12 * b)) access = self._clamp(100 * (0.42 * supply / 100 + 0.18 * u + 0.15 * e + 0.12 * p + 0.10 * d - 0.16 * cp)) gaming = self._clamp(100 * (0.35 * a + 0.18 * s + 0.18 * cx - 0.30 * g - 0.18 * d - 0.16 * p)) fiscal = self._clamp(100 * (0.22 * a + 0.18 * gaming / 100 + 0.16 * cx + 0.14 * (1 - b) - 0.18 * g)) hybrid = self._clamp( - 0.24 * supply + 0.18 * access + 0.24 * supply + + 0.18 * access + 0.18 * self._clamp(100 * (0.34 * e + 0.24 * p + 0.16 * c + 0.14 * d - 0.16 * cp)) + 0.16 * self._clamp(100 * (0.44 * g + 0.20 * d + 0.18 * p + 0.10 * e + 0.08 * c)) - + 0.14 * self._clamp(100 * (0.32 * access / 100 + 0.22 * u + 0.16 * supply / 100 + 0.16 * d + 0.14 * p - 0.10 * cx)) - + 0.06 * (100 - fiscal) + 0.04 * (100 - gaming) + + 0.14 + * self._clamp( + 100 * (0.32 * access / 100 + 0.22 * u + 0.16 * supply / 100 + 0.16 * d + 0.14 * p - 0.10 * cx) + ) + + 0.06 * (100 - fiscal) + + 0.04 * (100 - gaming) ) return {"hybrid": hybrid, "access": access, "supply": supply, "gaming": gaming, "fiscal": fiscal} @@ -107,13 +130,35 @@ def run(self, inputs: MPCInput) -> MPCOutput: seed = inp.seed if inp.seed is not None else 20260526 np.random.default_rng(seed) - base = [inp.activity_signal, inp.capitation, inp.place_accountability, inp.scope_capacity, - inp.urgent_ambulance, inp.data_visibility, inp.governance, inp.equity_protection, - inp.copayment_burden, inp.budget_tightness, inp.hospital_salience, inp.complexity] - - lever_names = ["activity_signal", "capitation", "place_accountability", "scope_capacity", - "urgent_ambulance", "data_visibility", "governance", "equity_protection", - "copayment_burden", "budget_tightness", "hospital_salience", "complexity"] + base = [ + inp.activity_signal, + inp.capitation, + inp.place_accountability, + inp.scope_capacity, + inp.urgent_ambulance, + inp.data_visibility, + inp.governance, + inp.equity_protection, + inp.copayment_burden, + inp.budget_tightness, + inp.hospital_salience, + inp.complexity, + ] + + lever_names = [ + "activity_signal", + "capitation", + "place_accountability", + "scope_capacity", + "urgent_ambulance", + "data_visibility", + "governance", + "equity_protection", + "copayment_burden", + "budget_tightness", + "hospital_salience", + "complexity", + ] # Optimise by grid search over control steps step_size = 5.0 @@ -128,7 +173,9 @@ def run(self, inputs: MPCInput) -> MPCOutput: trial = list(candidate) trial[i] = self._clamp(trial[i] + delta) idx = self._calculate_indices(*trial) - cost = float(inp.w_hybrid * idx["hybrid"] + inp.w_fiscal * idx["fiscal"] + inp.w_gaming * idx["gaming"]) + cost = float( + inp.w_hybrid * idx["hybrid"] + inp.w_fiscal * idx["fiscal"] + inp.w_gaming * idx["gaming"] + ) if cost < best_cost: best_cost = cost best_levers = list(trial) @@ -147,13 +194,15 @@ def run(self, inputs: MPCInput) -> MPCOutput: blend = step / horizon blended = [self._clamp(current[i] + blend * (best_levers[i] - current[i])) for i in range(12)] idx = self._calculate_indices(*blended) - trajectory.append({ - "step": step, - "hybrid_viability": round(idx["hybrid"], 2), - "access_score": round(idx["access"], 2), - "fiscal_risk_score": round(idx["fiscal"], 2), - "gaming_risk_score": round(idx["gaming"], 2), - }) + trajectory.append( + { + "step": step, + "hybrid_viability": round(idx["hybrid"], 2), + "access_score": round(idx["access"], 2), + "fiscal_risk_score": round(idx["fiscal"], 2), + "gaming_risk_score": round(idx["gaming"], 2), + } + ) current = blended final_idx = self._calculate_indices(*best_levers) diff --git a/models/primarycare_model/engines/nash_opt_adapter.py b/models/primarycare_model/engines/nash_opt_adapter.py index 8b17043..33d74a1 100644 --- a/models/primarycare_model/engines/nash_opt_adapter.py +++ b/models/primarycare_model/engines/nash_opt_adapter.py @@ -87,7 +87,9 @@ def _clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float: return float(max(lower, min(upper, value))) @staticmethod - def _funder_payoff(audit: float, effort: float, budget: float, complexity: float, activity: float, place: float) -> float: + def _funder_payoff( + audit: float, effort: float, budget: float, complexity: float, activity: float, place: float + ) -> float: compliance_benefit = 30.0 * (effort / 100.0) gaming_penalty = -20.0 * (activity / 100.0) * (1.0 - audit / 100.0) audit_cost = -15.0 * (audit / 100.0) @@ -139,9 +141,17 @@ def run(self, inputs: NashInput) -> NashOutput: # Funders choose an audit level to maximise their payoff # given the current provider effort. audit_candidates = np.linspace(max(0, audit - 20), min(100, audit + 20), 11) - funder_payoffs = [self._funder_payoff( - a, effort, inp.budget_tightness, inp.complexity, inp.activity_signal, inp.funder_place_accountability - ) for a in audit_candidates] + funder_payoffs = [ + self._funder_payoff( + a, + effort, + inp.budget_tightness, + inp.complexity, + inp.activity_signal, + inp.funder_place_accountability, + ) + for a in audit_candidates + ] best_audit_idx = int(np.argmax(funder_payoffs)) target_audit = float(audit_candidates[best_audit_idx]) @@ -149,9 +159,12 @@ def run(self, inputs: NashInput) -> NashOutput: # Providers choose an effort level to maximise their payoff # given the current funder audit. effort_candidates = np.linspace(max(0, effort - 20), min(100, effort + 20), 11) - provider_payoffs = [self._provider_payoff( - e, audit, inp.provider_scope_utilisation, inp.complexity, inp.funder_place_accountability - ) for e in effort_candidates] + provider_payoffs = [ + self._provider_payoff( + e, audit, inp.provider_scope_utilisation, inp.complexity, inp.funder_place_accountability + ) + for e in effort_candidates + ] best_effort_idx = int(np.argmax(provider_payoffs)) target_effort = float(effort_candidates[best_effort_idx]) @@ -161,13 +174,15 @@ def run(self, inputs: NashInput) -> NashOutput: new_audit = self._clamp(audit + lr * (target_audit - audit)) new_effort = self._clamp(effort + lr * (target_effort - effort)) - trajectory.append({ - "iteration": iteration, - "funder_audit": round(new_audit, 2), - "provider_effort": round(new_effort, 2), - "funder_payoff": round(float(funder_payoffs[best_audit_idx]), 2), - "provider_payoff": round(float(provider_payoffs[best_effort_idx]), 2), - }) + trajectory.append( + { + "iteration": iteration, + "funder_audit": round(new_audit, 2), + "provider_effort": round(new_effort, 2), + "funder_payoff": round(float(funder_payoffs[best_audit_idx]), 2), + "provider_payoff": round(float(provider_payoffs[best_effort_idx]), 2), + } + ) # ── Convergence check ── # Both strategies must change by less than 0.01 for the @@ -182,8 +197,16 @@ def run(self, inputs: NashInput) -> NashOutput: audit, effort = new_audit, new_effort final_iter = iteration - hybrid = self._clamp(30.0 + 0.30 * audit + 0.25 * effort + 0.15 * inp.funder_place_accountability - 0.10 * inp.complexity) - access = self._clamp(30.0 + 0.20 * effort + 0.20 * inp.funder_place_accountability + 0.15 * inp.activity_signal - 0.15 * inp.budget_tightness) + hybrid = self._clamp( + 30.0 + 0.30 * audit + 0.25 * effort + 0.15 * inp.funder_place_accountability - 0.10 * inp.complexity + ) + access = self._clamp( + 30.0 + + 0.20 * effort + + 0.20 * inp.funder_place_accountability + + 0.15 * inp.activity_signal + - 0.15 * inp.budget_tightness + ) supply = self._clamp(25.0 + 0.25 * inp.activity_signal + 0.20 * inp.provider_scope_utilisation + 0.15 * effort) hosp_pressure = self._clamp(100 - 0.25 * access - 0.15 * audit) gaming = self._clamp(40.0 + 0.30 * inp.activity_signal - 0.35 * audit - 0.15 * effort) diff --git a/models/primarycare_model/engines/sd_adapter.py b/models/primarycare_model/engines/sd_adapter.py index 3a51eec..5f69c84 100644 --- a/models/primarycare_model/engines/sd_adapter.py +++ b/models/primarycare_model/engines/sd_adapter.py @@ -96,10 +96,25 @@ def f(x: float) -> float: ) ) gaming_risk = self._clamp( - 100 * (0.35 * activity + 0.18 * scope + 0.18 * f(inp.complexity) - 0.30 * governance - 0.18 * data - 0.16 * place) + 100 + * ( + 0.35 * activity + + 0.18 * scope + + 0.18 * f(inp.complexity) + - 0.30 * governance + - 0.18 * data + - 0.16 * place + ) ) fiscal_risk = self._clamp( - 100 * (0.22 * activity + 0.18 * gaming_risk / 100 + 0.16 * f(inp.complexity) + 0.14 * (1 - budget) - 0.18 * governance) + 100 + * ( + 0.22 * activity + + 0.18 * gaming_risk / 100 + + 0.16 * f(inp.complexity) + + 0.14 * (1 - budget) + - 0.18 * governance + ) ) # --- stock-flow dynamics --- @@ -110,11 +125,15 @@ def f(x: float) -> float: for month in range(1, months + 1): seasonal = 1.0 + 0.05 * np.sin(2 * np.pi * month / 12.0) need = 55.0 * seasonal + 0.18 * unmet + 0.15 * inp.complexity - capacity = max(1.0, capacity + 0.06 * supply + 0.04 * inp.place_accountability - 0.05 * inp.budget_tightness) + capacity = max( + 1.0, capacity + 0.06 * supply + 0.04 * inp.place_accountability - 0.05 * inp.budget_tightness + ) served = min(need + 0.20 * unmet, capacity * (0.72 + access / 250.0)) ambulance_resolved = min(need * (0.08 + inp.urgent_ambulance / 400.0), 12.0 + capacity / 10.0) unmet = max(0.0, 0.70 * unmet + need - served - ambulance_resolved) - hospital_pressure = self._clamp(35.0 + 0.42 * unmet + 0.28 * inp.hospital_salience - 0.32 * hospital_pressure_idx) + hospital_pressure = self._clamp( + 35.0 + 0.42 * unmet + 0.28 * inp.hospital_salience - 0.32 * hospital_pressure_idx + ) fiscal_pressure = self._clamp(20.0 + 0.28 * fiscal_risk + 0.12 * unmet + 0.08 * served) trace.append( @@ -131,9 +150,23 @@ def f(x: float) -> float: } ) - equity_legitimacy = self._clamp(100 * (0.34 * equity + 0.24 * place + 0.16 * capitation + 0.14 * data - 0.16 * copay)) - governance_resilience = self._clamp(100 * (0.44 * governance + 0.20 * data + 0.18 * place + 0.10 * equity + 0.08 * capitation)) - hospital_deflection = self._clamp(100 * (0.32 * access / 100 + 0.22 * urgent + 0.16 * supply / 100 + 0.16 * data + 0.14 * place - 0.10 * f(inp.complexity))) + equity_legitimacy = self._clamp( + 100 * (0.34 * equity + 0.24 * place + 0.16 * capitation + 0.14 * data - 0.16 * copay) + ) + governance_resilience = self._clamp( + 100 * (0.44 * governance + 0.20 * data + 0.18 * place + 0.10 * equity + 0.08 * capitation) + ) + hospital_deflection = self._clamp( + 100 + * ( + 0.32 * access / 100 + + 0.22 * urgent + + 0.16 * supply / 100 + + 0.16 * data + + 0.14 * place + - 0.10 * f(inp.complexity) + ) + ) hybrid = self._clamp( 0.24 * supply + 0.18 * access diff --git a/models/primarycare_model/engines/sensitivity_adapter.py b/models/primarycare_model/engines/sensitivity_adapter.py index 37b5cd5..d54d555 100644 --- a/models/primarycare_model/engines/sensitivity_adapter.py +++ b/models/primarycare_model/engines/sensitivity_adapter.py @@ -91,17 +91,51 @@ def _calculate_indices( def f(v: float) -> float: return float(max(0.0, min(100.0, v))) / 100.0 - a, c, p, s, u, d, g, e, cp, b, hs, cx = [f(v) for v in (activity, capitation, place, scope, urgent, data, governance, equity, copay, budget, hosp_sal, compl)] + a, c, p, s, u, d, g, e, cp, b, hs, cx = [ + f(v) + for v in ( + activity, + capitation, + place, + scope, + urgent, + data, + governance, + equity, + copay, + budget, + hosp_sal, + compl, + ) + ] - supply = SensitivityAnalysisAdapter._clamp(100 * (0.34 * a + 0.18 * c + 0.24 * s + 0.12 * u + 0.12 * p - 0.12 * b)) - access = SensitivityAnalysisAdapter._clamp(100 * (0.42 * supply / 100 + 0.18 * u + 0.15 * e + 0.12 * p + 0.10 * d - 0.16 * cp)) + supply = SensitivityAnalysisAdapter._clamp( + 100 * (0.34 * a + 0.18 * c + 0.24 * s + 0.12 * u + 0.12 * p - 0.12 * b) + ) + access = SensitivityAnalysisAdapter._clamp( + 100 * (0.42 * supply / 100 + 0.18 * u + 0.15 * e + 0.12 * p + 0.10 * d - 0.16 * cp) + ) equity_leg = SensitivityAnalysisAdapter._clamp(100 * (0.34 * e + 0.24 * p + 0.16 * c + 0.14 * d - 0.16 * cp)) gov_res = SensitivityAnalysisAdapter._clamp(100 * (0.44 * g + 0.20 * d + 0.18 * p + 0.10 * e + 0.08 * c)) - hosp_def = SensitivityAnalysisAdapter._clamp(100 * (0.32 * access / 100 + 0.22 * u + 0.16 * supply / 100 + 0.16 * d + 0.14 * p - 0.10 * cx)) - gaming = SensitivityAnalysisAdapter._clamp(100 * (0.35 * a + 0.18 * s + 0.18 * cx - 0.30 * g - 0.18 * d - 0.16 * p)) + hosp_def = SensitivityAnalysisAdapter._clamp( + 100 * (0.32 * access / 100 + 0.22 * u + 0.16 * supply / 100 + 0.16 * d + 0.14 * p - 0.10 * cx) + ) + gaming = SensitivityAnalysisAdapter._clamp( + 100 * (0.35 * a + 0.18 * s + 0.18 * cx - 0.30 * g - 0.18 * d - 0.16 * p) + ) fiscal = 100 * (0.22 * a + 0.18 * gaming / 100 + 0.16 * cx + 0.14 * (1 - b) - 0.18 * g - 0.14 * hosp_def / 100) - hosp_pressure = 100 * (0.34 * hs + 0.26 * (1 - hosp_def / 100) + 0.16 * cx + 0.14 * b - 0.18 * access / 100 - 0.12 * u) - hybrid = 0.24 * supply + 0.18 * access + 0.18 * equity_leg + 0.16 * gov_res + 0.14 * hosp_def + 0.06 * (100 - fiscal) + 0.04 * (100 - gaming) + hosp_pressure = 100 * ( + 0.34 * hs + 0.26 * (1 - hosp_def / 100) + 0.16 * cx + 0.14 * b - 0.18 * access / 100 - 0.12 * u + ) + hybrid = ( + 0.24 * supply + + 0.18 * access + + 0.18 * equity_leg + + 0.16 * gov_res + + 0.14 * hosp_def + + 0.06 * (100 - fiscal) + + 0.04 * (100 - gaming) + ) return { "hybrid_viability_score": round(hybrid, 2), @@ -139,7 +173,14 @@ def run(self, inputs: SensitivityInput) -> SensitivityOutput: step = float(min(max(inp.delta_step, 1.0), 50.0)) oat_rows: list[dict[str, float | str]] = [] - target_metrics = ["hybrid_viability_score", "access_score", "supply_generation_score", "hospital_pressure_score", "gaming_risk_score", "fiscal_risk_score"] + target_metrics = [ + "hybrid_viability_score", + "access_score", + "supply_generation_score", + "hospital_pressure_score", + "gaming_risk_score", + "fiscal_risk_score", + ] for i, lever in enumerate(SENSITIVITY_LEVERS): for tag, direction in [("low", -step), ("high", +step)]: diff --git a/models/primarycare_model/evidence/public_evidence_monitor.py b/models/primarycare_model/evidence/public_evidence_monitor.py index ee359e0..94014e0 100644 --- a/models/primarycare_model/evidence/public_evidence_monitor.py +++ b/models/primarycare_model/evidence/public_evidence_monitor.py @@ -10,23 +10,27 @@ def detect_public_evidence_candidates() -> tuple[EvidenceCandidate, ...]: candidates = [] for source in load_public_sources(): if source.checksum == "pending-download": - candidates.append(EvidenceCandidate( - candidate_id=f"candidate-{source.source_id}", - source=source.source_id, - relevance=0.75, - quality="public-source-metadata-needs-download-checksum", - transferability=0.7, - contradiction_signal="none_detected", - affected_parameters=(), - )) + candidates.append( + EvidenceCandidate( + candidate_id=f"candidate-{source.source_id}", + source=source.source_id, + relevance=0.75, + quality="public-source-metadata-needs-download-checksum", + transferability=0.7, + contradiction_signal="none_detected", + affected_parameters=(), + ) + ) else: - candidates.append(EvidenceCandidate( - candidate_id=f"freshness-review-{source.source_id}", - source=source.source_id, - relevance=0.5, - quality="public-source-freshness-review-after-verified-snapshot", - transferability=0.7, - contradiction_signal="none_detected", - affected_parameters=(), - )) + candidates.append( + EvidenceCandidate( + candidate_id=f"freshness-review-{source.source_id}", + source=source.source_id, + relevance=0.5, + quality="public-source-freshness-review-after-verified-snapshot", + transferability=0.7, + contradiction_signal="none_detected", + affected_parameters=(), + ) + ) return tuple(candidates) diff --git a/models/primarycare_model/full_parameterised_model_v170.py b/models/primarycare_model/full_parameterised_model_v170.py index 05bda87..54a4518 100644 --- a/models/primarycare_model/full_parameterised_model_v170.py +++ b/models/primarycare_model/full_parameterised_model_v170.py @@ -14,6 +14,7 @@ - a monthly dynamic model with all levers explicitly parameterised; - sensitivity analysis and calibration-target matrices for future empirical work. """ + from __future__ import annotations import math @@ -66,86 +67,1061 @@ class ScenarioSpec: PARAMETER_SPECS: tuple[ParameterSpec, ...] = ( # Demand and need - ParameterSpec("D01", "base_need_per_1000", "demand", "Underlying monthly primary-care-relevant need per 1,000 population before access constraints.", "contacts/1000/month", 62.0, 35.0, 100.0, "placeholder prior", "Needs NPCD/NZ Health Survey/encounter data", "Monthly encounters, unmet need, population denominators", "Estimate from encounter data plus unmet-need surveys by locality", "high"), - ParameterSpec("D02", "urgent_need_share", "demand", "Share of primary-care-relevant need that is urgent/same-day or after-hours sensitive.", "proportion", 0.28, 0.10, 0.50, "placeholder prior", "Needs urgent-care/appointment data", "Booking urgency, after-hours encounters, ED substitutable care", "Multinomial demand model by urgency and patient group", "high"), - ParameterSpec("D03", "routine_need_share", "demand", "Share of need that is routine or planned rather than urgent.", "proportion", 0.42, 0.25, 0.70, "derived prior", "Complements urgent/chronic shares", "Appointment type and reason-for-visit coding", "Estimate from appointment/encounter coding", "medium"), - ParameterSpec("D04", "chronic_need_index", "demand", "Relative chronic and multimorbidity demand load.", "0-1 index", 0.55, 0.10, 0.95, "source-informed prior", "Capitation reweighting identifies multimorbidity as relevant", "Multimorbidity counts, LTC registers, medication/diagnosis proxies", "Hierarchical need model", "high"), - ParameterSpec("D05", "rurality_demand_modifier", "demand/equity", "Additional access load or complexity caused by rurality and travel constraints.", "0-1 index", 0.45, 0.05, 0.95, "source-informed prior", "Rurality included in reweighting", "Rurality classification, travel time, provider proximity", "Geospatial access model", "high"), - ParameterSpec("D06", "deprivation_demand_modifier", "demand/equity", "Additional need/access burden related to socioeconomic deprivation.", "0-1 index", 0.55, 0.05, 0.95, "source-informed prior", "Deprivation included in reweighting", "NZDep, unmet need, ED and admission outcomes", "Stratified demand and outcome model", "high"), - ParameterSpec("D07", "multimorbidity_demand_modifier", "demand/equity", "Extra contact intensity required for multimorbidity beyond average need.", "0-1 index", 0.55, 0.05, 0.95, "source-informed prior", "Multimorbidity included in reweighting", "Condition counts, polypharmacy, hospital risk", "Regression/latent risk model", "high"), - ParameterSpec("D08", "price_elasticity_general", "demand/price", "How strongly general patients reduce primary care use when co-payments increase.", "elasticity index", 0.24, 0.01, 0.80, "placeholder prior", "Needs fee/utilisation variation", "Practice fees, CSC/VLCA status, utilisation changes", "Discrete-choice or panel utilisation model", "high"), - ParameterSpec("D09", "price_elasticity_high_need", "demand/equity", "How strongly high-need groups reduce or delay care when co-payments increase.", "elasticity index", 0.34, 0.01, 0.90, "placeholder prior", "NZ Health Survey reports cost barriers by group", "Fees and utilisation by ethnicity/deprivation/rurality/morbidity", "Stratified elasticity model", "high"), - ParameterSpec("D10", "telehealth_acceptability", "demand/digital", "Share of demand that can realistically be met by telehealth without undermining care quality.", "0-1 index", 0.42, 0.05, 0.90, "placeholder prior", "Needs digital primary care data", "Mode of consultation, outcome, re-presentation", "Substitution/complementarity model", "medium"), - ParameterSpec("D11", "unmet_need_persistence", "demand dynamics", "Persistence of unmet need from one month to the next.", "0-1 index", 0.68, 0.10, 0.95, "placeholder prior", "Needs longitudinal access data", "Delayed care, repeat attempts, ED conversion", "Dynamic panel/hazard model", "high"), - ParameterSpec("D12", "delay_complexity_growth", "demand dynamics", "Rate at which delayed care becomes more complex or costly.", "0-1 index", 0.22, 0.01, 0.70, "placeholder prior", "Needs linked access-to-hospital data", "Waiting time to ED/admission/acuity", "Hazard/competing risk model", "high"), - + ParameterSpec( + "D01", + "base_need_per_1000", + "demand", + "Underlying monthly primary-care-relevant need per 1,000 population before access constraints.", + "contacts/1000/month", + 62.0, + 35.0, + 100.0, + "placeholder prior", + "Needs NPCD/NZ Health Survey/encounter data", + "Monthly encounters, unmet need, population denominators", + "Estimate from encounter data plus unmet-need surveys by locality", + "high", + ), + ParameterSpec( + "D02", + "urgent_need_share", + "demand", + "Share of primary-care-relevant need that is urgent/same-day or after-hours sensitive.", + "proportion", + 0.28, + 0.10, + 0.50, + "placeholder prior", + "Needs urgent-care/appointment data", + "Booking urgency, after-hours encounters, ED substitutable care", + "Multinomial demand model by urgency and patient group", + "high", + ), + ParameterSpec( + "D03", + "routine_need_share", + "demand", + "Share of need that is routine or planned rather than urgent.", + "proportion", + 0.42, + 0.25, + 0.70, + "derived prior", + "Complements urgent/chronic shares", + "Appointment type and reason-for-visit coding", + "Estimate from appointment/encounter coding", + "medium", + ), + ParameterSpec( + "D04", + "chronic_need_index", + "demand", + "Relative chronic and multimorbidity demand load.", + "0-1 index", + 0.55, + 0.10, + 0.95, + "source-informed prior", + "Capitation reweighting identifies multimorbidity as relevant", + "Multimorbidity counts, LTC registers, medication/diagnosis proxies", + "Hierarchical need model", + "high", + ), + ParameterSpec( + "D05", + "rurality_demand_modifier", + "demand/equity", + "Additional access load or complexity caused by rurality and travel constraints.", + "0-1 index", + 0.45, + 0.05, + 0.95, + "source-informed prior", + "Rurality included in reweighting", + "Rurality classification, travel time, provider proximity", + "Geospatial access model", + "high", + ), + ParameterSpec( + "D06", + "deprivation_demand_modifier", + "demand/equity", + "Additional need/access burden related to socioeconomic deprivation.", + "0-1 index", + 0.55, + 0.05, + 0.95, + "source-informed prior", + "Deprivation included in reweighting", + "NZDep, unmet need, ED and admission outcomes", + "Stratified demand and outcome model", + "high", + ), + ParameterSpec( + "D07", + "multimorbidity_demand_modifier", + "demand/equity", + "Extra contact intensity required for multimorbidity beyond average need.", + "0-1 index", + 0.55, + 0.05, + 0.95, + "source-informed prior", + "Multimorbidity included in reweighting", + "Condition counts, polypharmacy, hospital risk", + "Regression/latent risk model", + "high", + ), + ParameterSpec( + "D08", + "price_elasticity_general", + "demand/price", + "How strongly general patients reduce primary care use when co-payments increase.", + "elasticity index", + 0.24, + 0.01, + 0.80, + "placeholder prior", + "Needs fee/utilisation variation", + "Practice fees, CSC/VLCA status, utilisation changes", + "Discrete-choice or panel utilisation model", + "high", + ), + ParameterSpec( + "D09", + "price_elasticity_high_need", + "demand/equity", + "How strongly high-need groups reduce or delay care when co-payments increase.", + "elasticity index", + 0.34, + 0.01, + 0.90, + "placeholder prior", + "NZ Health Survey reports cost barriers by group", + "Fees and utilisation by ethnicity/deprivation/rurality/morbidity", + "Stratified elasticity model", + "high", + ), + ParameterSpec( + "D10", + "telehealth_acceptability", + "demand/digital", + "Share of demand that can realistically be met by telehealth without undermining care quality.", + "0-1 index", + 0.42, + 0.05, + 0.90, + "placeholder prior", + "Needs digital primary care data", + "Mode of consultation, outcome, re-presentation", + "Substitution/complementarity model", + "medium", + ), + ParameterSpec( + "D11", + "unmet_need_persistence", + "demand dynamics", + "Persistence of unmet need from one month to the next.", + "0-1 index", + 0.68, + 0.10, + 0.95, + "placeholder prior", + "Needs longitudinal access data", + "Delayed care, repeat attempts, ED conversion", + "Dynamic panel/hazard model", + "high", + ), + ParameterSpec( + "D12", + "delay_complexity_growth", + "demand dynamics", + "Rate at which delayed care becomes more complex or costly.", + "0-1 index", + 0.22, + 0.01, + 0.70, + "placeholder prior", + "Needs linked access-to-hospital data", + "Waiting time to ED/admission/acuity", + "Hazard/competing risk model", + "high", + ), # Supply and workforce - ParameterSpec("S01", "gp_capacity_index", "supply", "General practitioner capacity relative to population need.", "0-1 index", 0.48, 0.05, 0.95, "placeholder prior", "Needs workforce/FTE/open-book data", "GP FTE, sessions, appointment slots", "Workforce supply model", "high"), - ParameterSpec("S02", "nurse_np_capacity_index", "supply", "Nurse and nurse practitioner capacity available for claimable primary activity.", "0-1 index", 0.42, 0.05, 0.95, "placeholder prior", "Needs workforce/FTE data", "NP/nurse FTE and scope of activity", "Workforce/scope supply model", "high"), - ParameterSpec("S03", "pharmacist_capacity_index", "supply/scope", "Pharmacist capacity for eligible primary medical or protocolised contacts.", "0-1 index", 0.36, 0.00, 0.90, "placeholder prior", "Needs pharmacy workforce/scope data", "Pharmacy locations, FTE, prescribing services", "Scope-enabled supply model", "medium"), - ParameterSpec("S04", "allied_health_capacity_index", "supply/scope", "Allied health capacity for eligible musculoskeletal, mental health or chronic-care contacts.", "0-1 index", 0.36, 0.00, 0.90, "placeholder prior", "Needs allied workforce data", "Physio, psychology, counselling, other FTE/activity", "Scope-enabled supply model", "medium"), - ParameterSpec("S05", "paramedic_alt_capacity_index", "supply/ambulance", "Paramedic or extended-care paramedic capacity for alternative disposition/treat-and-refer pathways.", "0-1 index", 0.28, 0.00, 0.90, "placeholder prior", "Needs ambulance workforce and pathway data", "Paramedic workforce, alternative pathway activity", "Ambulance pathway capacity model", "high"), - ParameterSpec("S06", "medical_productivity_per_fte", "supply", "Relative monthly contacts generated per medical FTE under current settings.", "0-1 index", 0.56, 0.10, 0.95, "placeholder prior", "Needs FTE/contact linkage", "Appointments per FTE, session templates", "Productivity regression by practice type", "high"), - ParameterSpec("S07", "np_nurse_productivity_per_fte", "supply", "Relative contacts generated per nurse/NP FTE when claimable and governed.", "0-1 index", 0.48, 0.10, 0.95, "placeholder prior", "Needs FTE/contact linkage", "NP/nurse consultations, protocol care", "Productivity/scope model", "high"), - ParameterSpec("S08", "scope_substitution_rate", "supply/scope", "Proportion of GP-bottlenecked contacts that can be safely shifted to other providers within scope.", "0-1 index", 0.30, 0.00, 0.80, "placeholder prior", "Needs clinical pathway/safety evidence", "Contact type, provider type, safety outcomes", "Classification + safety validation", "high"), - ParameterSpec("S09", "workforce_exit_rate", "supply dynamics", "Monthly risk of provider/practice capacity exit under financial/workload stress.", "0-1 index", 0.12, 0.00, 0.60, "placeholder prior", "Needs workforce/practice exit data", "Closures, reduced sessions, retirement/exit", "Discrete-time hazard model", "medium"), - ParameterSpec("S10", "market_entry_response", "supply dynamics", "Provider/practice entry or expansion response to clear activity-sensitive payment rules.", "0-1 index", 0.22, 0.00, 0.85, "placeholder prior", "Needs new entrant/PHO/provider data", "New practices, new PHOs, direct claim onboarding", "Entry/expansion model", "medium"), - ParameterSpec("S11", "local_inperson_constraint", "rural/supply", "Constraint on local in-person capacity, especially rural or under-served settings.", "0-1 index", 0.68, 0.00, 0.95, "source-informed prior", "Rurality/access issues recognised in policy", "Rural session availability, travel time, closures", "Geospatial supply model", "high"), - ParameterSpec("S12", "rural_loading_response", "rural/supply", "Supply response to rural/local in-person loading or higher scheduled benefits.", "0-1 index", 0.24, 0.00, 0.85, "placeholder prior", "Needs payment variation/pilot", "Rural payment changes and sessions/appointments", "Difference-in-differences/pilot evaluation", "high"), - + ParameterSpec( + "S01", + "gp_capacity_index", + "supply", + "General practitioner capacity relative to population need.", + "0-1 index", + 0.48, + 0.05, + 0.95, + "placeholder prior", + "Needs workforce/FTE/open-book data", + "GP FTE, sessions, appointment slots", + "Workforce supply model", + "high", + ), + ParameterSpec( + "S02", + "nurse_np_capacity_index", + "supply", + "Nurse and nurse practitioner capacity available for claimable primary activity.", + "0-1 index", + 0.42, + 0.05, + 0.95, + "placeholder prior", + "Needs workforce/FTE data", + "NP/nurse FTE and scope of activity", + "Workforce/scope supply model", + "high", + ), + ParameterSpec( + "S03", + "pharmacist_capacity_index", + "supply/scope", + "Pharmacist capacity for eligible primary medical or protocolised contacts.", + "0-1 index", + 0.36, + 0.00, + 0.90, + "placeholder prior", + "Needs pharmacy workforce/scope data", + "Pharmacy locations, FTE, prescribing services", + "Scope-enabled supply model", + "medium", + ), + ParameterSpec( + "S04", + "allied_health_capacity_index", + "supply/scope", + "Allied health capacity for eligible musculoskeletal, mental health or chronic-care contacts.", + "0-1 index", + 0.36, + 0.00, + 0.90, + "placeholder prior", + "Needs allied workforce data", + "Physio, psychology, counselling, other FTE/activity", + "Scope-enabled supply model", + "medium", + ), + ParameterSpec( + "S05", + "paramedic_alt_capacity_index", + "supply/ambulance", + "Paramedic or extended-care paramedic capacity for alternative disposition/treat-and-refer pathways.", + "0-1 index", + 0.28, + 0.00, + 0.90, + "placeholder prior", + "Needs ambulance workforce and pathway data", + "Paramedic workforce, alternative pathway activity", + "Ambulance pathway capacity model", + "high", + ), + ParameterSpec( + "S06", + "medical_productivity_per_fte", + "supply", + "Relative monthly contacts generated per medical FTE under current settings.", + "0-1 index", + 0.56, + 0.10, + 0.95, + "placeholder prior", + "Needs FTE/contact linkage", + "Appointments per FTE, session templates", + "Productivity regression by practice type", + "high", + ), + ParameterSpec( + "S07", + "np_nurse_productivity_per_fte", + "supply", + "Relative contacts generated per nurse/NP FTE when claimable and governed.", + "0-1 index", + 0.48, + 0.10, + 0.95, + "placeholder prior", + "Needs FTE/contact linkage", + "NP/nurse consultations, protocol care", + "Productivity/scope model", + "high", + ), + ParameterSpec( + "S08", + "scope_substitution_rate", + "supply/scope", + "Proportion of GP-bottlenecked contacts that can be safely shifted to other providers within scope.", + "0-1 index", + 0.30, + 0.00, + 0.80, + "placeholder prior", + "Needs clinical pathway/safety evidence", + "Contact type, provider type, safety outcomes", + "Classification + safety validation", + "high", + ), + ParameterSpec( + "S09", + "workforce_exit_rate", + "supply dynamics", + "Monthly risk of provider/practice capacity exit under financial/workload stress.", + "0-1 index", + 0.12, + 0.00, + 0.60, + "placeholder prior", + "Needs workforce/practice exit data", + "Closures, reduced sessions, retirement/exit", + "Discrete-time hazard model", + "medium", + ), + ParameterSpec( + "S10", + "market_entry_response", + "supply dynamics", + "Provider/practice entry or expansion response to clear activity-sensitive payment rules.", + "0-1 index", + 0.22, + 0.00, + 0.85, + "placeholder prior", + "Needs new entrant/PHO/provider data", + "New practices, new PHOs, direct claim onboarding", + "Entry/expansion model", + "medium", + ), + ParameterSpec( + "S11", + "local_inperson_constraint", + "rural/supply", + "Constraint on local in-person capacity, especially rural or under-served settings.", + "0-1 index", + 0.68, + 0.00, + 0.95, + "source-informed prior", + "Rurality/access issues recognised in policy", + "Rural session availability, travel time, closures", + "Geospatial supply model", + "high", + ), + ParameterSpec( + "S12", + "rural_loading_response", + "rural/supply", + "Supply response to rural/local in-person loading or higher scheduled benefits.", + "0-1 index", + 0.24, + 0.00, + 0.85, + "placeholder prior", + "Needs payment variation/pilot", + "Rural payment changes and sessions/appointments", + "Difference-in-differences/pilot evaluation", + "high", + ), # Funding and payment architecture - ParameterSpec("F01", "capitation_base_strength", "funding", "Strength of baseline capitation for continuity/population accountability.", "0-1 index", 0.70, 0.00, 0.95, "public architecture prior", "Capitation is core GP funding", "Capitation amounts and enrolments", "Payment-flow model", "high"), - ParameterSpec("F02", "capitation_weighting_adequacy", "funding/equity", "Adequacy of capitation weightings for current population need.", "0-1 index", 0.38, 0.00, 0.95, "source-informed prior", "Current reweighting work recognises formula limitations", "Current/proposed rate tables and practice impacts", "Formula/revenue modelling", "high"), - ParameterSpec("F03", "scheduled_medical_benefit_strength", "funding/FFS", "Strength of uncapped scheduled fee-for-service benefit for eligible primary medical contacts.", "0-1 index", 0.08, 0.00, 0.95, "policy option prior", "No general MBS-equivalent stream currently assumed", "Item schedule, contact categories and public contribution", "Policy schedule + claims modelling", "high"), - ParameterSpec("F04", "scheduled_benefit_price_adequacy", "funding/FFS", "Adequacy of item prices/public contribution relative to marginal cost.", "0-1 index", 0.10, 0.00, 0.95, "policy option prior", "Needs service costing", "Consult costs, provider time, overhead, co-payment", "Costing and provider supply model", "high"), - ParameterSpec("F05", "activity_signal_strength", "funding/FFS", "Marginal revenue signal attached to providing the next clinically necessary contact.", "0-1 index", 0.20, 0.00, 0.95, "source-informed prior", "ACC/programmes/co-payments create partial signals", "Marginal payment by contact type", "Payment elasticity model", "high"), - ParameterSpec("F06", "patient_copayment_level", "funding/price", "Average private co-payment burden affecting demand.", "0-1 index", 0.58, 0.00, 0.95, "source-informed prior", "Cost barriers publicly reported", "Practice fee schedules and patient payment data", "Price elasticity model", "high"), - ParameterSpec("F07", "copayment_protection_strength", "equity/price", "Strength of fee caps/subsidies/exemptions for children, high-need and low-income groups.", "0-1 index", 0.44, 0.00, 0.95, "source-informed prior", "CSC/VLCA/subsidy architecture exists", "Eligibility, fees, utilisation by group", "Equity-weighted price model", "high"), - ParameterSpec("F08", "acc_activity_strength", "ACC/funding", "Extent to which ACC activity/contract payments sustain upstream provider capacity.", "0-1 index", 0.55, 0.00, 0.95, "hypothesis prior", "ACC treatment payment architecture exists", "ACC claims/payments by provider/practice", "Cross-funder revenue model", "high"), - ParameterSpec("F09", "acc_constraint_intensity", "ACC/funding", "Degree of constraint or tightening in ACC activity funding.", "0-1 index", 0.12, 0.00, 0.95, "hypothesis prior", "Potential future policy lever", "Pricing changes, contract changes, claim acceptance", "Policy-shock model", "medium"), - ParameterSpec("F10", "pho_transaction_cost", "PHO/admin", "Administrative friction or pass-through opacity from PHO-mediated streams.", "0-1 index", 0.52, 0.00, 0.95, "source-informed hypothesis", "PHO transparency issues identified as source target", "PHO financials, pass-through, onboarding delay", "Transaction-cost analysis", "high"), - ParameterSpec("F11", "direct_claiming_strength", "admin/payment", "Strength of direct provider claims platform/rules-based payment architecture.", "0-1 index", 0.12, 0.00, 0.95, "policy option prior", "Current system lacks broad direct primary medical claims platform", "Claims infrastructure, processing cost, provider uptake", "Administrative cost-effectiveness model", "high"), - ParameterSpec("F12", "place_based_accountability_strength", "commissioning", "Population/geographic accountability that prevents cherry-picking under demand-led benefits.", "0-1 index", 0.42, 0.00, 0.95, "source-informed policy prior", "Place-based commissioning raised as important guardrail", "Locality responsibility, outreach, hard-to-reach service targets", "Commissioning/accountability model", "high"), - ParameterSpec("F13", "budget_tightness", "fiscal", "Constraint imposed by fixed envelopes/baselines/deficit pressure on upstream care.", "0-1 index", 0.76, 0.00, 0.95, "source-informed prior", "Separate appropriations exist but fiscal pressure remains", "Vote/baseline movements and budget decisions", "Fiscal-flow model", "high"), - ParameterSpec("F14", "global_cap_constraint", "fiscal", "Strength of global cap/fixed envelope on eligible primary medical activity.", "0-1 index", 0.82, 0.00, 0.95, "policy hypothesis prior", "Capitation/fixed pools constrain marginal activity", "Funding envelope, claims cap rules, waiting/rationing outcomes", "Supply response + fiscal model", "high"), - ParameterSpec("F15", "item_rules_strength", "fiscal/governance", "Strength of item definitions, documentation, duration/frequency and clinical necessity rules.", "0-1 index", 0.42, 0.00, 0.95, "policy option prior", "ACC-style analogy", "Item schedule rules and audit results", "Claims audit/rule evaluation", "high"), - + ParameterSpec( + "F01", + "capitation_base_strength", + "funding", + "Strength of baseline capitation for continuity/population accountability.", + "0-1 index", + 0.70, + 0.00, + 0.95, + "public architecture prior", + "Capitation is core GP funding", + "Capitation amounts and enrolments", + "Payment-flow model", + "high", + ), + ParameterSpec( + "F02", + "capitation_weighting_adequacy", + "funding/equity", + "Adequacy of capitation weightings for current population need.", + "0-1 index", + 0.38, + 0.00, + 0.95, + "source-informed prior", + "Current reweighting work recognises formula limitations", + "Current/proposed rate tables and practice impacts", + "Formula/revenue modelling", + "high", + ), + ParameterSpec( + "F03", + "scheduled_medical_benefit_strength", + "funding/FFS", + "Strength of uncapped scheduled fee-for-service benefit for eligible primary medical contacts.", + "0-1 index", + 0.08, + 0.00, + 0.95, + "policy option prior", + "No general MBS-equivalent stream currently assumed", + "Item schedule, contact categories and public contribution", + "Policy schedule + claims modelling", + "high", + ), + ParameterSpec( + "F04", + "scheduled_benefit_price_adequacy", + "funding/FFS", + "Adequacy of item prices/public contribution relative to marginal cost.", + "0-1 index", + 0.10, + 0.00, + 0.95, + "policy option prior", + "Needs service costing", + "Consult costs, provider time, overhead, co-payment", + "Costing and provider supply model", + "high", + ), + ParameterSpec( + "F05", + "activity_signal_strength", + "funding/FFS", + "Marginal revenue signal attached to providing the next clinically necessary contact.", + "0-1 index", + 0.20, + 0.00, + 0.95, + "source-informed prior", + "ACC/programmes/co-payments create partial signals", + "Marginal payment by contact type", + "Payment elasticity model", + "high", + ), + ParameterSpec( + "F06", + "patient_copayment_level", + "funding/price", + "Average private co-payment burden affecting demand.", + "0-1 index", + 0.58, + 0.00, + 0.95, + "source-informed prior", + "Cost barriers publicly reported", + "Practice fee schedules and patient payment data", + "Price elasticity model", + "high", + ), + ParameterSpec( + "F07", + "copayment_protection_strength", + "equity/price", + "Strength of fee caps/subsidies/exemptions for children, high-need and low-income groups.", + "0-1 index", + 0.44, + 0.00, + 0.95, + "source-informed prior", + "CSC/VLCA/subsidy architecture exists", + "Eligibility, fees, utilisation by group", + "Equity-weighted price model", + "high", + ), + ParameterSpec( + "F08", + "acc_activity_strength", + "ACC/funding", + "Extent to which ACC activity/contract payments sustain upstream provider capacity.", + "0-1 index", + 0.55, + 0.00, + 0.95, + "hypothesis prior", + "ACC treatment payment architecture exists", + "ACC claims/payments by provider/practice", + "Cross-funder revenue model", + "high", + ), + ParameterSpec( + "F09", + "acc_constraint_intensity", + "ACC/funding", + "Degree of constraint or tightening in ACC activity funding.", + "0-1 index", + 0.12, + 0.00, + 0.95, + "hypothesis prior", + "Potential future policy lever", + "Pricing changes, contract changes, claim acceptance", + "Policy-shock model", + "medium", + ), + ParameterSpec( + "F10", + "pho_transaction_cost", + "PHO/admin", + "Administrative friction or pass-through opacity from PHO-mediated streams.", + "0-1 index", + 0.52, + 0.00, + 0.95, + "source-informed hypothesis", + "PHO transparency issues identified as source target", + "PHO financials, pass-through, onboarding delay", + "Transaction-cost analysis", + "high", + ), + ParameterSpec( + "F11", + "direct_claiming_strength", + "admin/payment", + "Strength of direct provider claims platform/rules-based payment architecture.", + "0-1 index", + 0.12, + 0.00, + 0.95, + "policy option prior", + "Current system lacks broad direct primary medical claims platform", + "Claims infrastructure, processing cost, provider uptake", + "Administrative cost-effectiveness model", + "high", + ), + ParameterSpec( + "F12", + "place_based_accountability_strength", + "commissioning", + "Population/geographic accountability that prevents cherry-picking under demand-led benefits.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "source-informed policy prior", + "Place-based commissioning raised as important guardrail", + "Locality responsibility, outreach, hard-to-reach service targets", + "Commissioning/accountability model", + "high", + ), + ParameterSpec( + "F13", + "budget_tightness", + "fiscal", + "Constraint imposed by fixed envelopes/baselines/deficit pressure on upstream care.", + "0-1 index", + 0.76, + 0.00, + 0.95, + "source-informed prior", + "Separate appropriations exist but fiscal pressure remains", + "Vote/baseline movements and budget decisions", + "Fiscal-flow model", + "high", + ), + ParameterSpec( + "F14", + "global_cap_constraint", + "fiscal", + "Strength of global cap/fixed envelope on eligible primary medical activity.", + "0-1 index", + 0.82, + 0.00, + 0.95, + "policy hypothesis prior", + "Capitation/fixed pools constrain marginal activity", + "Funding envelope, claims cap rules, waiting/rationing outcomes", + "Supply response + fiscal model", + "high", + ), + ParameterSpec( + "F15", + "item_rules_strength", + "fiscal/governance", + "Strength of item definitions, documentation, duration/frequency and clinical necessity rules.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "policy option prior", + "ACC-style analogy", + "Item schedule rules and audit results", + "Claims audit/rule evaluation", + "high", + ), # Governance, visibility, and equity - ParameterSpec("G01", "safety_governance", "governance", "Credentialing, prescribing, scope and clinical governance for claimable activity.", "0-1 index", 0.66, 0.00, 0.95, "source-informed prior", "Scope and ACC-style provider rules are relevant", "Credentialing/audit/adverse events", "Safety governance evaluation", "high"), - ParameterSpec("G02", "gaming_controls", "governance", "Audit, anomaly detection, coding rules and balancing measures.", "0-1 index", 0.54, 0.00, 0.95, "placeholder prior", "Needs claims/data architecture", "Claims patterns, outliers, re-presentations", "Outlier/fraud/waste model", "high"), - ParameterSpec("G03", "audit_intensity", "governance", "Operational intensity and credibility of claims audit.", "0-1 index", 0.42, 0.00, 0.95, "placeholder prior", "Needs audit design", "Audit rates, recovery, quality flags", "Audit-effect model", "medium"), - ParameterSpec("G04", "data_observability_primary", "data", "Visibility of appointment, encounter and outcome data.", "0-1 index", 0.44, 0.00, 0.95, "source-informed prior", "NPCD implementation pending", "NPCD completeness/timeliness", "Data completeness model", "high"), - ParameterSpec("G05", "data_observability_ambulance", "data", "Visibility of ambulance demand, conveyance and alternative disposition data.", "0-1 index", 0.48, 0.00, 0.95, "source-informed prior", "Ambulance KPIs exist", "Ambulance event/disposition data", "Data linkage model", "high"), - ParameterSpec("G06", "data_observability_hospital", "data", "Visibility and linkage of ED/admission outcomes to upstream access.", "0-1 index", 0.60, 0.00, 0.95, "source-informed prior", "Hospital data collections exist", "ED/NMDS governed identifier linkage", "Linked outcomes model", "high"), - ParameterSpec("G07", "primary_kpi_salience", "accountability", "Top-tier visibility of primary care access and outcomes.", "0-1 index", 0.42, 0.00, 0.95, "source-informed prior", "Primary care target implementation pending", "Target reporting, consequences, balancing measures", "KPI salience evaluation", "high"), - ParameterSpec("G08", "ambulance_kpi_salience", "accountability", "Top-tier visibility of ambulance response, non-conveyance and offload outcomes.", "0-1 index", 0.45, 0.00, 0.95, "source-informed prior", "Ambulance KPIs reported but not top-tier equivalent", "KPIs and governance escalation", "KPI salience evaluation", "medium"), - ParameterSpec("G09", "hospital_salience", "political economy", "Political/managerial salience of hospital failure relative to upstream failure.", "0-1 index", 0.88, 0.00, 0.95, "strategic hypothesis", "Hospitals highly visible", "Media/budget/escalation patterns", "Political-economy analysis", "medium"), - ParameterSpec("G10", "equity_program_strength", "equity", "Strength of outreach, kaupapa Māori/Pacific/community programmes protecting equity.", "0-1 index", 0.46, 0.00, 0.95, "placeholder prior", "Needs equity review", "Programme funding, uptake, outcomes", "Equity impact evaluation", "high"), - ParameterSpec("G11", "te_tiriti_governance_strength", "equity/governance", "Strength of Te Tiriti and Māori data/governance arrangements in design and monitoring.", "0-1 index", 0.42, 0.00, 0.95, "placeholder prior", "Needs Māori governance review", "Governance terms, data sovereignty processes", "Qualitative/governance assessment", "high"), - ParameterSpec("G12", "consumer_trust", "legitimacy", "Trust that patients and communities have in the service model.", "0-1 index", 0.50, 0.00, 0.95, "placeholder prior", "Needs consumer validation", "Patient experience, trust, uptake", "Survey/interview model", "medium"), - ParameterSpec("G13", "stakeholder_alignment", "political economy", "Alignment among providers, PHOs, officials, funders and communities.", "0-1 index", 0.36, 0.00, 0.95, "placeholder prior", "Needs MCDA/stakeholder scoring", "Workshop scores, submissions, interviews", "Deliberative MCDA/qualitative analysis", "medium"), - ParameterSpec("G14", "narrative_coherence", "political economy", "Whether the reform can be explained without being misread as uncontrolled FFS or anti-equity.", "0-1 index", 0.46, 0.00, 0.95, "author judgement prior", "Publication strategy matters", "Stakeholder response, public feedback", "Qualitative/narrative evaluation", "medium"), - + ParameterSpec( + "G01", + "safety_governance", + "governance", + "Credentialing, prescribing, scope and clinical governance for claimable activity.", + "0-1 index", + 0.66, + 0.00, + 0.95, + "source-informed prior", + "Scope and ACC-style provider rules are relevant", + "Credentialing/audit/adverse events", + "Safety governance evaluation", + "high", + ), + ParameterSpec( + "G02", + "gaming_controls", + "governance", + "Audit, anomaly detection, coding rules and balancing measures.", + "0-1 index", + 0.54, + 0.00, + 0.95, + "placeholder prior", + "Needs claims/data architecture", + "Claims patterns, outliers, re-presentations", + "Outlier/fraud/waste model", + "high", + ), + ParameterSpec( + "G03", + "audit_intensity", + "governance", + "Operational intensity and credibility of claims audit.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "placeholder prior", + "Needs audit design", + "Audit rates, recovery, quality flags", + "Audit-effect model", + "medium", + ), + ParameterSpec( + "G04", + "data_observability_primary", + "data", + "Visibility of appointment, encounter and outcome data.", + "0-1 index", + 0.44, + 0.00, + 0.95, + "source-informed prior", + "NPCD implementation pending", + "NPCD completeness/timeliness", + "Data completeness model", + "high", + ), + ParameterSpec( + "G05", + "data_observability_ambulance", + "data", + "Visibility of ambulance demand, conveyance and alternative disposition data.", + "0-1 index", + 0.48, + 0.00, + 0.95, + "source-informed prior", + "Ambulance KPIs exist", + "Ambulance event/disposition data", + "Data linkage model", + "high", + ), + ParameterSpec( + "G06", + "data_observability_hospital", + "data", + "Visibility and linkage of ED/admission outcomes to upstream access.", + "0-1 index", + 0.60, + 0.00, + 0.95, + "source-informed prior", + "Hospital data collections exist", + "ED/NMDS governed identifier linkage", + "Linked outcomes model", + "high", + ), + ParameterSpec( + "G07", + "primary_kpi_salience", + "accountability", + "Top-tier visibility of primary care access and outcomes.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "source-informed prior", + "Primary care target implementation pending", + "Target reporting, consequences, balancing measures", + "KPI salience evaluation", + "high", + ), + ParameterSpec( + "G08", + "ambulance_kpi_salience", + "accountability", + "Top-tier visibility of ambulance response, non-conveyance and offload outcomes.", + "0-1 index", + 0.45, + 0.00, + 0.95, + "source-informed prior", + "Ambulance KPIs reported but not top-tier equivalent", + "KPIs and governance escalation", + "KPI salience evaluation", + "medium", + ), + ParameterSpec( + "G09", + "hospital_salience", + "political economy", + "Political/managerial salience of hospital failure relative to upstream failure.", + "0-1 index", + 0.88, + 0.00, + 0.95, + "strategic hypothesis", + "Hospitals highly visible", + "Media/budget/escalation patterns", + "Political-economy analysis", + "medium", + ), + ParameterSpec( + "G10", + "equity_program_strength", + "equity", + "Strength of outreach, kaupapa Māori/Pacific/community programmes protecting equity.", + "0-1 index", + 0.46, + 0.00, + 0.95, + "placeholder prior", + "Needs equity review", + "Programme funding, uptake, outcomes", + "Equity impact evaluation", + "high", + ), + ParameterSpec( + "G11", + "te_tiriti_governance_strength", + "equity/governance", + "Strength of Te Tiriti and Māori data/governance arrangements in design and monitoring.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "placeholder prior", + "Needs Māori governance review", + "Governance terms, data sovereignty processes", + "Qualitative/governance assessment", + "high", + ), + ParameterSpec( + "G12", + "consumer_trust", + "legitimacy", + "Trust that patients and communities have in the service model.", + "0-1 index", + 0.50, + 0.00, + 0.95, + "placeholder prior", + "Needs consumer validation", + "Patient experience, trust, uptake", + "Survey/interview model", + "medium", + ), + ParameterSpec( + "G13", + "stakeholder_alignment", + "political economy", + "Alignment among providers, PHOs, officials, funders and communities.", + "0-1 index", + 0.36, + 0.00, + 0.95, + "placeholder prior", + "Needs MCDA/stakeholder scoring", + "Workshop scores, submissions, interviews", + "Deliberative MCDA/qualitative analysis", + "medium", + ), + ParameterSpec( + "G14", + "narrative_coherence", + "political economy", + "Whether the reform can be explained without being misread as uncontrolled FFS or anti-equity.", + "0-1 index", + 0.46, + 0.00, + 0.95, + "author judgement prior", + "Publication strategy matters", + "Stakeholder response, public feedback", + "Qualitative/narrative evaluation", + "medium", + ), # Hospital, ambulance, urgent care and cost - ParameterSpec("H01", "baseline_hospital_pressure", "hospital", "Baseline relative hospital pressure in the absence of additional upstream deflection.", "0-1 index", 0.72, 0.00, 0.95, "source-informed prior", "Hospital pressure is central context", "ED/admission/bed/deficit data", "Baseline calibration", "high"), - ParameterSpec("H02", "ed_conversion_rate", "hospital", "Rate at which unmet primary care need converts to ED presentation.", "0-1 index", 0.32, 0.01, 0.90, "placeholder prior", "Needs governed record linkage", "Unmet care/waiting time to ED", "Hazard/transition model", "high"), - ParameterSpec("H03", "admission_conversion_rate", "hospital", "Rate at which ED/ambulance/unmet need converts to hospital admission.", "0-1 index", 0.18, 0.01, 0.70, "placeholder prior", "Needs ED/NMDS linkage", "ED disposition, admissions, diagnosis", "Transition model", "high"), - ParameterSpec("H04", "ambulance_conveyance_default", "ambulance", "Default tendency for ambulance pathways to convey to ED when alternatives are weak.", "0-1 index", 0.72, 0.05, 0.95, "placeholder prior", "Needs ambulance disposition data", "Conveyance, treat-and-refer, destination", "Multinomial disposition model", "high"), - ParameterSpec("H05", "ambulance_deflection_rate", "ambulance", "Effectiveness of funded alternative disposition/hear-and-treat/treat-and-refer pathways.", "0-1 index", 0.22, 0.00, 0.90, "placeholder prior", "Needs pilots/ambulance data", "Non-conveyance safety, re-presentation", "Pathway evaluation", "high"), - ParameterSpec("H06", "urgent_care_effectiveness", "urgent care", "Effectiveness of urgent and after-hours care in substituting for ED without unsafe delay.", "0-1 index", 0.42, 0.00, 0.95, "source-informed prior", "Urgent care policy is current comparator", "Urgent visits, ED substitution, fees, outcomes", "Synthetic control/DID/pilot evaluation", "high"), - ParameterSpec("H07", "telehealth_substitution_rate", "digital", "Share of telehealth activity that substitutes for, rather than adds to, in-person/local supply.", "0-1 index", 0.42, 0.00, 0.95, "placeholder prior", "Needs mode/outcomes data", "Telehealth visits, follow-up, re-presentation", "Substitution/complementarity model", "medium"), - ParameterSpec("H08", "hospital_cost_per_event_index", "cost", "Relative public cost of ED/admission events versus primary care contacts.", "0-1 index", 0.82, 0.05, 0.95, "placeholder prior", "Needs cost weights", "ED/inpatient cost weights", "Costing model", "high"), - ParameterSpec("H09", "primary_contact_cost_index", "cost", "Relative public cost of claimable primary medical contact.", "0-1 index", 0.28, 0.05, 0.70, "placeholder prior", "Needs item schedule/costing", "Service cost and schedule price", "Activity costing", "high"), - ParameterSpec("H10", "ambulance_event_cost_index", "cost", "Relative public cost of ambulance event and alternative disposition pathways.", "0-1 index", 0.46, 0.05, 0.85, "placeholder prior", "Needs ambulance costing", "Call/response/conveyance pathway cost", "Pathway costing", "medium"), - + ParameterSpec( + "H01", + "baseline_hospital_pressure", + "hospital", + "Baseline relative hospital pressure in the absence of additional upstream deflection.", + "0-1 index", + 0.72, + 0.00, + 0.95, + "source-informed prior", + "Hospital pressure is central context", + "ED/admission/bed/deficit data", + "Baseline calibration", + "high", + ), + ParameterSpec( + "H02", + "ed_conversion_rate", + "hospital", + "Rate at which unmet primary care need converts to ED presentation.", + "0-1 index", + 0.32, + 0.01, + 0.90, + "placeholder prior", + "Needs governed record linkage", + "Unmet care/waiting time to ED", + "Hazard/transition model", + "high", + ), + ParameterSpec( + "H03", + "admission_conversion_rate", + "hospital", + "Rate at which ED/ambulance/unmet need converts to hospital admission.", + "0-1 index", + 0.18, + 0.01, + 0.70, + "placeholder prior", + "Needs ED/NMDS linkage", + "ED disposition, admissions, diagnosis", + "Transition model", + "high", + ), + ParameterSpec( + "H04", + "ambulance_conveyance_default", + "ambulance", + "Default tendency for ambulance pathways to convey to ED when alternatives are weak.", + "0-1 index", + 0.72, + 0.05, + 0.95, + "placeholder prior", + "Needs ambulance disposition data", + "Conveyance, treat-and-refer, destination", + "Multinomial disposition model", + "high", + ), + ParameterSpec( + "H05", + "ambulance_deflection_rate", + "ambulance", + "Effectiveness of funded alternative disposition/hear-and-treat/treat-and-refer pathways.", + "0-1 index", + 0.22, + 0.00, + 0.90, + "placeholder prior", + "Needs pilots/ambulance data", + "Non-conveyance safety, re-presentation", + "Pathway evaluation", + "high", + ), + ParameterSpec( + "H06", + "urgent_care_effectiveness", + "urgent care", + "Effectiveness of urgent and after-hours care in substituting for ED without unsafe delay.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "source-informed prior", + "Urgent care policy is current comparator", + "Urgent visits, ED substitution, fees, outcomes", + "Synthetic control/DID/pilot evaluation", + "high", + ), + ParameterSpec( + "H07", + "telehealth_substitution_rate", + "digital", + "Share of telehealth activity that substitutes for, rather than adds to, in-person/local supply.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "placeholder prior", + "Needs mode/outcomes data", + "Telehealth visits, follow-up, re-presentation", + "Substitution/complementarity model", + "medium", + ), + ParameterSpec( + "H08", + "hospital_cost_per_event_index", + "cost", + "Relative public cost of ED/admission events versus primary care contacts.", + "0-1 index", + 0.82, + 0.05, + 0.95, + "placeholder prior", + "Needs cost weights", + "ED/inpatient cost weights", + "Costing model", + "high", + ), + ParameterSpec( + "H09", + "primary_contact_cost_index", + "cost", + "Relative public cost of claimable primary medical contact.", + "0-1 index", + 0.28, + 0.05, + 0.70, + "placeholder prior", + "Needs item schedule/costing", + "Service cost and schedule price", + "Activity costing", + "high", + ), + ParameterSpec( + "H10", + "ambulance_event_cost_index", + "cost", + "Relative public cost of ambulance event and alternative disposition pathways.", + "0-1 index", + 0.46, + 0.05, + 0.85, + "placeholder prior", + "Needs ambulance costing", + "Call/response/conveyance pathway cost", + "Pathway costing", + "medium", + ), # Risks and implementation - ParameterSpec("R01", "cherry_picking_risk", "risk", "Risk that providers select easy/profitable contacts while hard-to-reach patients remain under-served.", "0-1 index", 0.58, 0.00, 0.95, "stakeholder-informed hypothesis", "Place-based accountability needed", "Patient mix, complexity, outreach, non-attenders", "Equity/selection model", "high"), - ParameterSpec("R02", "low_value_activity_risk", "risk", "Risk of claimable low-value or unnecessary contacts under fee-for-service.", "0-1 index", 0.45, 0.00, 0.95, "theory prior", "FFS can incentivise volume", "Claims outliers, coding, outcomes", "Audit/utilisation model", "high"), - ParameterSpec("R03", "fiscal_leakage_risk", "risk", "Risk that funding leaks to volume or margin without improving access/equity/outcomes.", "0-1 index", 0.42, 0.00, 0.95, "theory prior", "Demand-led models require controls", "Public spend, outcomes, claim patterns", "Value-for-money model", "high"), - ParameterSpec("R04", "provider_moral_hazard_risk", "risk", "Risk that providers alter coding/contact patterns to maximise payment.", "0-1 index", 0.38, 0.00, 0.95, "theory prior", "Payment incentives create coding risk", "Billing patterns and audit outcomes", "Anomaly detection", "medium"), - ParameterSpec("R05", "cream_skimming_penalty", "risk/equity", "Penalty for reform options that expand easy supply but leave complex demand unmet.", "0-1 index", 0.50, 0.00, 0.95, "policy hypothesis", "Place accountability guardrail", "Patient mix, enrolment/open books, complexity", "Selection/equity analysis", "high"), - ParameterSpec("R06", "political_contestation", "implementation", "Likelihood that the reform is framed as ideological, anti-equity, anti-GP or anti-PHO.", "0-1 index", 0.58, 0.00, 0.95, "author judgement prior", "Email trail and sector politics", "Stakeholder responses, media framing", "Political-economy analysis", "medium"), - ParameterSpec("R07", "implementation_complexity", "implementation", "Operational complexity of designing, paying, auditing and governing the architecture.", "0-1 index", 0.60, 0.00, 0.95, "policy judgement prior", "Multi-actor reform complexity", "Implementation plan, cost, timeline", "Implementation risk assessment", "medium"), + ParameterSpec( + "R01", + "cherry_picking_risk", + "risk", + "Risk that providers select easy/profitable contacts while hard-to-reach patients remain under-served.", + "0-1 index", + 0.58, + 0.00, + 0.95, + "stakeholder-informed hypothesis", + "Place-based accountability needed", + "Patient mix, complexity, outreach, non-attenders", + "Equity/selection model", + "high", + ), + ParameterSpec( + "R02", + "low_value_activity_risk", + "risk", + "Risk of claimable low-value or unnecessary contacts under fee-for-service.", + "0-1 index", + 0.45, + 0.00, + 0.95, + "theory prior", + "FFS can incentivise volume", + "Claims outliers, coding, outcomes", + "Audit/utilisation model", + "high", + ), + ParameterSpec( + "R03", + "fiscal_leakage_risk", + "risk", + "Risk that funding leaks to volume or margin without improving access/equity/outcomes.", + "0-1 index", + 0.42, + 0.00, + 0.95, + "theory prior", + "Demand-led models require controls", + "Public spend, outcomes, claim patterns", + "Value-for-money model", + "high", + ), + ParameterSpec( + "R04", + "provider_moral_hazard_risk", + "risk", + "Risk that providers alter coding/contact patterns to maximise payment.", + "0-1 index", + 0.38, + 0.00, + 0.95, + "theory prior", + "Payment incentives create coding risk", + "Billing patterns and audit outcomes", + "Anomaly detection", + "medium", + ), + ParameterSpec( + "R05", + "cream_skimming_penalty", + "risk/equity", + "Penalty for reform options that expand easy supply but leave complex demand unmet.", + "0-1 index", + 0.50, + 0.00, + 0.95, + "policy hypothesis", + "Place accountability guardrail", + "Patient mix, enrolment/open books, complexity", + "Selection/equity analysis", + "high", + ), + ParameterSpec( + "R06", + "political_contestation", + "implementation", + "Likelihood that the reform is framed as ideological, anti-equity, anti-GP or anti-PHO.", + "0-1 index", + 0.58, + 0.00, + 0.95, + "author judgement prior", + "Email trail and sector politics", + "Stakeholder responses, media framing", + "Political-economy analysis", + "medium", + ), + ParameterSpec( + "R07", + "implementation_complexity", + "implementation", + "Operational complexity of designing, paying, auditing and governing the architecture.", + "0-1 index", + 0.60, + 0.00, + 0.95, + "policy judgement prior", + "Multi-actor reform complexity", + "Implementation plan, cost, timeline", + "Implementation risk assessment", + "medium", + ), ) # Fast name lookup. @@ -153,83 +1129,315 @@ class ScenarioSpec: DATA_INPUT_SPECS: tuple[DataInputSpec, ...] = ( - DataInputSpec("I01", "ncpd_appointments", "person-practice-month", "governed health identifier, practice_id, booked_date, seen_date, mode, urgency, provider_type, outcome", "Estimate access, wait time, demand, provider-scope activity and unmet need proxies", "National Primary Care Dataset", "Health NZ data access framework / research agreement", "High"), - DataInputSpec("I02", "primary_encounters", "person-practice-encounter", "governed health identifier, date, reason/contact_type, provider_type, mode, diagnosis/proxy, outcome", "Estimate contact demand, provider type substitution, follow-up and safety", "NPCD / practice management systems", "Health NZ/research agreement/practice collaboration", "High"), - DataInputSpec("I03", "capitation_payment_flows", "practice/PHO-month", "practice_id, PHO_id, capitation stream, rate, enrolments, top-ups, pass-through", "Estimate capitation adequacy, PHO pass-through and baseline viability", "MoH/HNZ/PHO Services Agreement/OIA/PHO collaboration", "OIA + data-sharing", "High"), - DataInputSpec("I04", "fee_schedule_and_copayments", "practice/service", "contact_type, public subsidy, patient fee, fee caps, CSC/VLCA status", "Estimate price elasticity, equity burden and item price adequacy", "Practice fees, HNZ, sector surveys", "Survey/OIA/practice sample", "High"), - DataInputSpec("I05", "acc_claims_and_payments", "claim-provider-month", "claim_id, provider_id/practice_id, service type, payment route, cost, date, outcome", "Estimate ACC stabilisation and cross-funder substitution", "ACC claims/provider payments", "ACC data request / ethics if identifiable", "High"), - DataInputSpec("I06", "ambulance_events", "event", "governed health identifier if available, time, acuity, response, disposition, conveyance, handover delay, funding source", "Estimate conveyance/default, deflection and ambulance-hospital interface", "Ambulance providers/HNZ/ACC", "Data-sharing/OIA/ethics", "High"), - DataInputSpec("I07", "ed_and_hospital_linkage", "person-event", "governed health identifier, ED arrival, triage, diagnosis, disposition, admission, LOS, cost weight", "Estimate ED/admission conversion and hospital pressure", "NNPAC/NMDS/HNZ", "Health data access/ethics", "High"), - DataInputSpec("I08", "workforce_and_scope", "provider-practice-month", "provider_id, profession, FTE, scope, prescribing authority, sessions, vacancy", "Estimate capacity, productivity, substitution and exit/entry", "Workforce data, practices, regulatory bodies", "Data-sharing/survey", "High"), - DataInputSpec("I09", "practice_market_entry_exit", "practice-month", "practice_id, opening/closure, enrolment status, open books, new entrant, ownership", "Estimate market entry response, closed books and cherry-picking", "HNZ primary care data, sector survey", "Data-sharing/OIA/survey", "Medium"), - DataInputSpec("I10", "equity_and_consumer_experience", "person/group", "ethnicity, NZDep, rurality, age, sex, multimorbidity, unmet need, trust/experience", "Estimate equity modifiers, price response and trust legitimacy", "NZ Health Survey/NES/NPCD/community survey", "Public aggregate evidence + governed linkage + survey", "High"), - DataInputSpec("I11", "urgent_after_hours_activity", "service-month", "service type, in-person/digital, fees, wait, outcome, ED substitution, rurality", "Estimate urgent-care effectiveness and opportunity cost", "HNZ urgent care programme/providers", "OIA/data-sharing", "High"), - DataInputSpec("I12", "policy_and_budget_flows", "appropriation/year", "Vote, appropriation, baseline, transfer, spend, initiative, service class", "Estimate budget tightness, hospital salience and fiscal exposure", "Treasury/MoH/HNZ budget documents", "Public/OIA", "Medium"), + DataInputSpec( + "I01", + "ncpd_appointments", + "person-practice-month", + "governed health identifier, practice_id, booked_date, seen_date, mode, urgency, provider_type, outcome", + "Estimate access, wait time, demand, provider-scope activity and unmet need proxies", + "National Primary Care Dataset", + "Health NZ data access framework / research agreement", + "High", + ), + DataInputSpec( + "I02", + "primary_encounters", + "person-practice-encounter", + "governed health identifier, date, reason/contact_type, provider_type, mode, diagnosis/proxy, outcome", + "Estimate contact demand, provider type substitution, follow-up and safety", + "NPCD / practice management systems", + "Health NZ/research agreement/practice collaboration", + "High", + ), + DataInputSpec( + "I03", + "capitation_payment_flows", + "practice/PHO-month", + "practice_id, PHO_id, capitation stream, rate, enrolments, top-ups, pass-through", + "Estimate capitation adequacy, PHO pass-through and baseline viability", + "MoH/HNZ/PHO Services Agreement/OIA/PHO collaboration", + "OIA + data-sharing", + "High", + ), + DataInputSpec( + "I04", + "fee_schedule_and_copayments", + "practice/service", + "contact_type, public subsidy, patient fee, fee caps, CSC/VLCA status", + "Estimate price elasticity, equity burden and item price adequacy", + "Practice fees, HNZ, sector surveys", + "Survey/OIA/practice sample", + "High", + ), + DataInputSpec( + "I05", + "acc_claims_and_payments", + "claim-provider-month", + "claim_id, provider_id/practice_id, service type, payment route, cost, date, outcome", + "Estimate ACC stabilisation and cross-funder substitution", + "ACC claims/provider payments", + "ACC data request / ethics if identifiable", + "High", + ), + DataInputSpec( + "I06", + "ambulance_events", + "event", + "governed health identifier if available, time, acuity, response, disposition, conveyance, handover delay, funding source", + "Estimate conveyance/default, deflection and ambulance-hospital interface", + "Ambulance providers/HNZ/ACC", + "Data-sharing/OIA/ethics", + "High", + ), + DataInputSpec( + "I07", + "ed_and_hospital_linkage", + "person-event", + "governed health identifier, ED arrival, triage, diagnosis, disposition, admission, LOS, cost weight", + "Estimate ED/admission conversion and hospital pressure", + "NNPAC/NMDS/HNZ", + "Health data access/ethics", + "High", + ), + DataInputSpec( + "I08", + "workforce_and_scope", + "provider-practice-month", + "provider_id, profession, FTE, scope, prescribing authority, sessions, vacancy", + "Estimate capacity, productivity, substitution and exit/entry", + "Workforce data, practices, regulatory bodies", + "Data-sharing/survey", + "High", + ), + DataInputSpec( + "I09", + "practice_market_entry_exit", + "practice-month", + "practice_id, opening/closure, enrolment status, open books, new entrant, ownership", + "Estimate market entry response, closed books and cherry-picking", + "HNZ primary care data, sector survey", + "Data-sharing/OIA/survey", + "Medium", + ), + DataInputSpec( + "I10", + "equity_and_consumer_experience", + "person/group", + "ethnicity, NZDep, rurality, age, sex, multimorbidity, unmet need, trust/experience", + "Estimate equity modifiers, price response and trust legitimacy", + "NZ Health Survey/NES/NPCD/community survey", + "Public aggregate evidence + governed linkage + survey", + "High", + ), + DataInputSpec( + "I11", + "urgent_after_hours_activity", + "service-month", + "service type, in-person/digital, fees, wait, outcome, ED substitution, rurality", + "Estimate urgent-care effectiveness and opportunity cost", + "HNZ urgent care programme/providers", + "OIA/data-sharing", + "High", + ), + DataInputSpec( + "I12", + "policy_and_budget_flows", + "appropriation/year", + "Vote, appropriation, baseline, transfer, spend, initiative, service class", + "Estimate budget tightness, hospital salience and fiscal exposure", + "Treasury/MoH/HNZ budget documents", + "Public/OIA", + "Medium", + ), ) SCENARIOS: tuple[ScenarioSpec, ...] = ( - ScenarioSpec("F0", "Current reform pathway", "Capitation reweighting, access target, digital/urgent care and PHO accountability, without uncapped primary medical FFS.", { - "capitation_weighting_adequacy": 0.72, "primary_kpi_salience": 0.62, "data_observability_primary": 0.62, - "urgent_care_effectiveness": 0.58, "scheduled_medical_benefit_strength": 0.12, "global_cap_constraint": 0.70, - "item_rules_strength": 0.50, "narrative_coherence": 0.55, - }), - ScenarioSpec("F1", "Capitation reweighting only", "Formula improves allocation but marginal activity remains weakly funded.", { - "capitation_weighting_adequacy": 0.78, "scheduled_medical_benefit_strength": 0.10, "activity_signal_strength": 0.22, - "global_cap_constraint": 0.78, "data_observability_primary": 0.50, - }), - ScenarioSpec("F2", "Uncapped scheduled medical FFS", "Eligible primary medical activity becomes demand-led through scheduled benefits, but place accountability remains weak.", { - "scheduled_medical_benefit_strength": 0.78, "scheduled_benefit_price_adequacy": 0.72, "activity_signal_strength": 0.78, - "global_cap_constraint": 0.15, "direct_claiming_strength": 0.72, "place_based_accountability_strength": 0.38, - "item_rules_strength": 0.70, "gaming_controls": 0.66, "audit_intensity": 0.62, - "cherry_picking_risk": 0.64, "fiscal_leakage_risk": 0.50, - }), - ScenarioSpec("F3", "Uncapped medical FFS + place accountability", "Demand-led eligible medical activity plus capitation and explicit place/population responsibility.", { - "scheduled_medical_benefit_strength": 0.76, "scheduled_benefit_price_adequacy": 0.72, "activity_signal_strength": 0.76, - "global_cap_constraint": 0.15, "direct_claiming_strength": 0.70, "place_based_accountability_strength": 0.78, - "capitation_weighting_adequacy": 0.78, "item_rules_strength": 0.76, "gaming_controls": 0.74, - "audit_intensity": 0.70, "cherry_picking_risk": 0.35, "cream_skimming_penalty": 0.30, - "copayment_protection_strength": 0.70, "equity_program_strength": 0.70, - }), - ScenarioSpec("F4", "Full hybrid upstream architecture", "Capitation + uncapped scheduled primary medical FFS + place accountability + urgent/ambulance alternatives + scope-enabled supply + strong data/audit/KPIs.", { - "capitation_weighting_adequacy": 0.82, "scheduled_medical_benefit_strength": 0.78, "scheduled_benefit_price_adequacy": 0.74, - "activity_signal_strength": 0.78, "global_cap_constraint": 0.10, "direct_claiming_strength": 0.76, - "place_based_accountability_strength": 0.82, "scope_substitution_rate": 0.62, "gp_capacity_index": 0.58, - "nurse_np_capacity_index": 0.66, "pharmacist_capacity_index": 0.58, "allied_health_capacity_index": 0.54, - "paramedic_alt_capacity_index": 0.62, "rural_loading_response": 0.62, "local_inperson_constraint": 0.35, - "urgent_care_effectiveness": 0.72, "ambulance_deflection_rate": 0.65, "ambulance_conveyance_default": 0.45, - "data_observability_primary": 0.85, "data_observability_ambulance": 0.78, "data_observability_hospital": 0.82, - "primary_kpi_salience": 0.82, "ambulance_kpi_salience": 0.76, "safety_governance": 0.86, - "gaming_controls": 0.82, "audit_intensity": 0.78, "copayment_protection_strength": 0.76, - "equity_program_strength": 0.78, "te_tiriti_governance_strength": 0.76, "stakeholder_alignment": 0.68, - "narrative_coherence": 0.72, "cherry_picking_risk": 0.26, "low_value_activity_risk": 0.28, - "fiscal_leakage_risk": 0.28, "implementation_complexity": 0.72, - }), - ScenarioSpec("F5", "Uncapped weak-control model", "Activity becomes demand-led but controls, place accountability and equity protections are weak.", { - "scheduled_medical_benefit_strength": 0.82, "scheduled_benefit_price_adequacy": 0.78, "activity_signal_strength": 0.82, - "global_cap_constraint": 0.08, "item_rules_strength": 0.25, "gaming_controls": 0.22, "audit_intensity": 0.18, - "place_based_accountability_strength": 0.20, "copayment_protection_strength": 0.30, "equity_program_strength": 0.28, - "cherry_picking_risk": 0.78, "low_value_activity_risk": 0.78, "fiscal_leakage_risk": 0.76, - "provider_moral_hazard_risk": 0.70, "cream_skimming_penalty": 0.72, - }), - ScenarioSpec("F6", "ACC activity constraint shock", "ACC activity payments are constrained without compensating Health NZ-funded upstream supply.", { - "acc_activity_strength": 0.25, "acc_constraint_intensity": 0.70, "activity_signal_strength": 0.12, - "global_cap_constraint": 0.82, "budget_tightness": 0.86, "hospital_salience": 0.90, - }), - ScenarioSpec("F7", "Ambulance and urgent alternatives only", "Urgent/ambulance alternatives are strengthened but the primary medical marginal payment signal remains weak.", { - "urgent_care_effectiveness": 0.74, "ambulance_deflection_rate": 0.72, "ambulance_kpi_salience": 0.72, - "data_observability_ambulance": 0.76, "scheduled_medical_benefit_strength": 0.12, "global_cap_constraint": 0.74, - }), - ScenarioSpec("F8", "Scope-enabled supply only", "Broader providers can generate some activity, but payment architecture and place accountability are not fully reformed.", { - "scope_substitution_rate": 0.68, "nurse_np_capacity_index": 0.68, "pharmacist_capacity_index": 0.62, - "allied_health_capacity_index": 0.62, "safety_governance": 0.82, "scheduled_medical_benefit_strength": 0.22, - "activity_signal_strength": 0.35, "global_cap_constraint": 0.68, - }), - ScenarioSpec("F9", "Place-based commissioning only", "Population accountability and outreach improve, but marginal activity funding is not materially uncapped.", { - "place_based_accountability_strength": 0.78, "equity_program_strength": 0.74, "te_tiriti_governance_strength": 0.72, - "cherry_picking_risk": 0.28, "cream_skimming_penalty": 0.28, "scheduled_medical_benefit_strength": 0.12, - "global_cap_constraint": 0.74, - }), + ScenarioSpec( + "F0", + "Current reform pathway", + "Capitation reweighting, access target, digital/urgent care and PHO accountability, without uncapped primary medical FFS.", + { + "capitation_weighting_adequacy": 0.72, + "primary_kpi_salience": 0.62, + "data_observability_primary": 0.62, + "urgent_care_effectiveness": 0.58, + "scheduled_medical_benefit_strength": 0.12, + "global_cap_constraint": 0.70, + "item_rules_strength": 0.50, + "narrative_coherence": 0.55, + }, + ), + ScenarioSpec( + "F1", + "Capitation reweighting only", + "Formula improves allocation but marginal activity remains weakly funded.", + { + "capitation_weighting_adequacy": 0.78, + "scheduled_medical_benefit_strength": 0.10, + "activity_signal_strength": 0.22, + "global_cap_constraint": 0.78, + "data_observability_primary": 0.50, + }, + ), + ScenarioSpec( + "F2", + "Uncapped scheduled medical FFS", + "Eligible primary medical activity becomes demand-led through scheduled benefits, but place accountability remains weak.", + { + "scheduled_medical_benefit_strength": 0.78, + "scheduled_benefit_price_adequacy": 0.72, + "activity_signal_strength": 0.78, + "global_cap_constraint": 0.15, + "direct_claiming_strength": 0.72, + "place_based_accountability_strength": 0.38, + "item_rules_strength": 0.70, + "gaming_controls": 0.66, + "audit_intensity": 0.62, + "cherry_picking_risk": 0.64, + "fiscal_leakage_risk": 0.50, + }, + ), + ScenarioSpec( + "F3", + "Uncapped medical FFS + place accountability", + "Demand-led eligible medical activity plus capitation and explicit place/population responsibility.", + { + "scheduled_medical_benefit_strength": 0.76, + "scheduled_benefit_price_adequacy": 0.72, + "activity_signal_strength": 0.76, + "global_cap_constraint": 0.15, + "direct_claiming_strength": 0.70, + "place_based_accountability_strength": 0.78, + "capitation_weighting_adequacy": 0.78, + "item_rules_strength": 0.76, + "gaming_controls": 0.74, + "audit_intensity": 0.70, + "cherry_picking_risk": 0.35, + "cream_skimming_penalty": 0.30, + "copayment_protection_strength": 0.70, + "equity_program_strength": 0.70, + }, + ), + ScenarioSpec( + "F4", + "Full hybrid upstream architecture", + "Capitation + uncapped scheduled primary medical FFS + place accountability + urgent/ambulance alternatives + scope-enabled supply + strong data/audit/KPIs.", + { + "capitation_weighting_adequacy": 0.82, + "scheduled_medical_benefit_strength": 0.78, + "scheduled_benefit_price_adequacy": 0.74, + "activity_signal_strength": 0.78, + "global_cap_constraint": 0.10, + "direct_claiming_strength": 0.76, + "place_based_accountability_strength": 0.82, + "scope_substitution_rate": 0.62, + "gp_capacity_index": 0.58, + "nurse_np_capacity_index": 0.66, + "pharmacist_capacity_index": 0.58, + "allied_health_capacity_index": 0.54, + "paramedic_alt_capacity_index": 0.62, + "rural_loading_response": 0.62, + "local_inperson_constraint": 0.35, + "urgent_care_effectiveness": 0.72, + "ambulance_deflection_rate": 0.65, + "ambulance_conveyance_default": 0.45, + "data_observability_primary": 0.85, + "data_observability_ambulance": 0.78, + "data_observability_hospital": 0.82, + "primary_kpi_salience": 0.82, + "ambulance_kpi_salience": 0.76, + "safety_governance": 0.86, + "gaming_controls": 0.82, + "audit_intensity": 0.78, + "copayment_protection_strength": 0.76, + "equity_program_strength": 0.78, + "te_tiriti_governance_strength": 0.76, + "stakeholder_alignment": 0.68, + "narrative_coherence": 0.72, + "cherry_picking_risk": 0.26, + "low_value_activity_risk": 0.28, + "fiscal_leakage_risk": 0.28, + "implementation_complexity": 0.72, + }, + ), + ScenarioSpec( + "F5", + "Uncapped weak-control model", + "Activity becomes demand-led but controls, place accountability and equity protections are weak.", + { + "scheduled_medical_benefit_strength": 0.82, + "scheduled_benefit_price_adequacy": 0.78, + "activity_signal_strength": 0.82, + "global_cap_constraint": 0.08, + "item_rules_strength": 0.25, + "gaming_controls": 0.22, + "audit_intensity": 0.18, + "place_based_accountability_strength": 0.20, + "copayment_protection_strength": 0.30, + "equity_program_strength": 0.28, + "cherry_picking_risk": 0.78, + "low_value_activity_risk": 0.78, + "fiscal_leakage_risk": 0.76, + "provider_moral_hazard_risk": 0.70, + "cream_skimming_penalty": 0.72, + }, + ), + ScenarioSpec( + "F6", + "ACC activity constraint shock", + "ACC activity payments are constrained without compensating Health NZ-funded upstream supply.", + { + "acc_activity_strength": 0.25, + "acc_constraint_intensity": 0.70, + "activity_signal_strength": 0.12, + "global_cap_constraint": 0.82, + "budget_tightness": 0.86, + "hospital_salience": 0.90, + }, + ), + ScenarioSpec( + "F7", + "Ambulance and urgent alternatives only", + "Urgent/ambulance alternatives are strengthened but the primary medical marginal payment signal remains weak.", + { + "urgent_care_effectiveness": 0.74, + "ambulance_deflection_rate": 0.72, + "ambulance_kpi_salience": 0.72, + "data_observability_ambulance": 0.76, + "scheduled_medical_benefit_strength": 0.12, + "global_cap_constraint": 0.74, + }, + ), + ScenarioSpec( + "F8", + "Scope-enabled supply only", + "Broader providers can generate some activity, but payment architecture and place accountability are not fully reformed.", + { + "scope_substitution_rate": 0.68, + "nurse_np_capacity_index": 0.68, + "pharmacist_capacity_index": 0.62, + "allied_health_capacity_index": 0.62, + "safety_governance": 0.82, + "scheduled_medical_benefit_strength": 0.22, + "activity_signal_strength": 0.35, + "global_cap_constraint": 0.68, + }, + ), + ScenarioSpec( + "F9", + "Place-based commissioning only", + "Population accountability and outreach improve, but marginal activity funding is not materially uncapped.", + { + "place_based_accountability_strength": 0.78, + "equity_program_strength": 0.74, + "te_tiriti_governance_strength": 0.72, + "cherry_picking_risk": 0.28, + "cream_skimming_penalty": 0.28, + "scheduled_medical_benefit_strength": 0.12, + "global_cap_constraint": 0.74, + }, + ), ) @@ -264,24 +1472,148 @@ def scenario_parameters(scenario_id: str) -> dict[str, float]: def compute_architecture_indices(params: Mapping[str, float]) -> dict[str, float]: # Demand burden and equity burden - demand_burden = clamp(0.35 + 0.18*params["chronic_need_index"] + 0.18*params["rurality_demand_modifier"] + 0.18*params["deprivation_demand_modifier"] + 0.16*params["multimorbidity_demand_modifier"]) - copay_burden = clamp(params["patient_copayment_level"] * (1 - 0.70*params["copayment_protection_strength"]) * (0.85 + 0.45*params["price_elasticity_high_need"])) - scope_capacity = clamp(0.25*params["gp_capacity_index"] + 0.22*params["nurse_np_capacity_index"] + 0.16*params["pharmacist_capacity_index"] + 0.14*params["allied_health_capacity_index"] + 0.13*params["paramedic_alt_capacity_index"] + 0.10*params["scope_substitution_rate"]) - marginal_payment = clamp(0.35*params["scheduled_medical_benefit_strength"] + 0.25*params["scheduled_benefit_price_adequacy"] + 0.25*params["activity_signal_strength"] + 0.15*params["acc_activity_strength"] - 0.25*params["global_cap_constraint"]) - administration = clamp(0.40*params["direct_claiming_strength"] + 0.25*params["item_rules_strength"] + 0.20*params["data_observability_primary"] + 0.15*(1 - params["pho_transaction_cost"])) - place = clamp(0.45*params["place_based_accountability_strength"] + 0.25*params["equity_program_strength"] + 0.15*params["te_tiriti_governance_strength"] + 0.15*params["copayment_protection_strength"]) - governance = clamp(0.28*params["safety_governance"] + 0.24*params["gaming_controls"] + 0.20*params["audit_intensity"] + 0.14*params["data_observability_primary"] + 0.14*params["item_rules_strength"]) - urgent_ambulance = clamp(0.35*params["urgent_care_effectiveness"] + 0.30*params["ambulance_deflection_rate"] + 0.15*(1 - params["ambulance_conveyance_default"]) + 0.10*params["ambulance_kpi_salience"] + 0.10*params["data_observability_ambulance"]) - kpi_data = clamp(0.28*params["primary_kpi_salience"] + 0.22*params["ambulance_kpi_salience"] + 0.24*params["data_observability_primary"] + 0.14*params["data_observability_hospital"] + 0.12*params["data_observability_ambulance"]) - supply_generation = clamp(0.32*marginal_payment + 0.24*scope_capacity + 0.14*administration + 0.12*params["rural_loading_response"] + 0.10*params["market_entry_response"] + 0.08*params["capitation_base_strength"] - 0.14*params["local_inperson_constraint"] - 0.08*params["workforce_exit_rate"]) - access = clamp(0.20 + 0.55*supply_generation + 0.15*urgent_ambulance + 0.12*params["telehealth_acceptability"] - 0.22*copay_burden - 0.10*demand_burden) - equity_legitimacy = clamp(0.34*place + 0.22*params["copayment_protection_strength"] + 0.18*params["consumer_trust"] + 0.16*params["equity_program_strength"] + 0.10*params["te_tiriti_governance_strength"] - 0.18*params["cherry_picking_risk"] - 0.12*copay_burden) - hospital_deflection = clamp(0.34*access + 0.25*urgent_ambulance + 0.16*kpi_data + 0.12*place + 0.13*scope_capacity - 0.22*demand_burden) - gaming_risk = clamp(0.22*params["low_value_activity_risk"] + 0.20*params["provider_moral_hazard_risk"] + 0.18*params["fiscal_leakage_risk"] + 0.18*params["cherry_picking_risk"] + 0.12*(1-governance) + 0.10*params["scheduled_medical_benefit_strength"] - 0.16*params["item_rules_strength"]) - fiscal_risk = clamp(0.24*params["fiscal_leakage_risk"] + 0.18*params["scheduled_medical_benefit_strength"] + 0.14*(1 - params["global_cap_constraint"]) + 0.16*gaming_risk + 0.12*params["implementation_complexity"] + 0.10*params["hospital_cost_per_event_index"] - 0.18*hospital_deflection - 0.10*governance) - hospital_pressure = clamp(0.16 + 0.45*params["baseline_hospital_pressure"] + 0.26*(1 - hospital_deflection) + 0.14*demand_burden + 0.10*params["hospital_salience"] - 0.20*access - 0.12*urgent_ambulance) - implementation_feasibility = clamp(0.40*params["stakeholder_alignment"] + 0.25*params["narrative_coherence"] + 0.20*params["data_observability_primary"] + 0.15*params["safety_governance"] - 0.22*params["implementation_complexity"] - 0.18*params["political_contestation"]) - viability = clamp(0.18 + 0.16*supply_generation + 0.16*access + 0.14*equity_legitimacy + 0.12*governance + 0.16*hospital_deflection + 0.10*implementation_feasibility + 0.08*(1 - fiscal_risk) + 0.08*(1 - gaming_risk)) + demand_burden = clamp( + 0.35 + + 0.18 * params["chronic_need_index"] + + 0.18 * params["rurality_demand_modifier"] + + 0.18 * params["deprivation_demand_modifier"] + + 0.16 * params["multimorbidity_demand_modifier"] + ) + copay_burden = clamp( + params["patient_copayment_level"] + * (1 - 0.70 * params["copayment_protection_strength"]) + * (0.85 + 0.45 * params["price_elasticity_high_need"]) + ) + scope_capacity = clamp( + 0.25 * params["gp_capacity_index"] + + 0.22 * params["nurse_np_capacity_index"] + + 0.16 * params["pharmacist_capacity_index"] + + 0.14 * params["allied_health_capacity_index"] + + 0.13 * params["paramedic_alt_capacity_index"] + + 0.10 * params["scope_substitution_rate"] + ) + marginal_payment = clamp( + 0.35 * params["scheduled_medical_benefit_strength"] + + 0.25 * params["scheduled_benefit_price_adequacy"] + + 0.25 * params["activity_signal_strength"] + + 0.15 * params["acc_activity_strength"] + - 0.25 * params["global_cap_constraint"] + ) + administration = clamp( + 0.40 * params["direct_claiming_strength"] + + 0.25 * params["item_rules_strength"] + + 0.20 * params["data_observability_primary"] + + 0.15 * (1 - params["pho_transaction_cost"]) + ) + place = clamp( + 0.45 * params["place_based_accountability_strength"] + + 0.25 * params["equity_program_strength"] + + 0.15 * params["te_tiriti_governance_strength"] + + 0.15 * params["copayment_protection_strength"] + ) + governance = clamp( + 0.28 * params["safety_governance"] + + 0.24 * params["gaming_controls"] + + 0.20 * params["audit_intensity"] + + 0.14 * params["data_observability_primary"] + + 0.14 * params["item_rules_strength"] + ) + urgent_ambulance = clamp( + 0.35 * params["urgent_care_effectiveness"] + + 0.30 * params["ambulance_deflection_rate"] + + 0.15 * (1 - params["ambulance_conveyance_default"]) + + 0.10 * params["ambulance_kpi_salience"] + + 0.10 * params["data_observability_ambulance"] + ) + kpi_data = clamp( + 0.28 * params["primary_kpi_salience"] + + 0.22 * params["ambulance_kpi_salience"] + + 0.24 * params["data_observability_primary"] + + 0.14 * params["data_observability_hospital"] + + 0.12 * params["data_observability_ambulance"] + ) + supply_generation = clamp( + 0.32 * marginal_payment + + 0.24 * scope_capacity + + 0.14 * administration + + 0.12 * params["rural_loading_response"] + + 0.10 * params["market_entry_response"] + + 0.08 * params["capitation_base_strength"] + - 0.14 * params["local_inperson_constraint"] + - 0.08 * params["workforce_exit_rate"] + ) + access = clamp( + 0.20 + + 0.55 * supply_generation + + 0.15 * urgent_ambulance + + 0.12 * params["telehealth_acceptability"] + - 0.22 * copay_burden + - 0.10 * demand_burden + ) + equity_legitimacy = clamp( + 0.34 * place + + 0.22 * params["copayment_protection_strength"] + + 0.18 * params["consumer_trust"] + + 0.16 * params["equity_program_strength"] + + 0.10 * params["te_tiriti_governance_strength"] + - 0.18 * params["cherry_picking_risk"] + - 0.12 * copay_burden + ) + hospital_deflection = clamp( + 0.34 * access + + 0.25 * urgent_ambulance + + 0.16 * kpi_data + + 0.12 * place + + 0.13 * scope_capacity + - 0.22 * demand_burden + ) + gaming_risk = clamp( + 0.22 * params["low_value_activity_risk"] + + 0.20 * params["provider_moral_hazard_risk"] + + 0.18 * params["fiscal_leakage_risk"] + + 0.18 * params["cherry_picking_risk"] + + 0.12 * (1 - governance) + + 0.10 * params["scheduled_medical_benefit_strength"] + - 0.16 * params["item_rules_strength"] + ) + fiscal_risk = clamp( + 0.24 * params["fiscal_leakage_risk"] + + 0.18 * params["scheduled_medical_benefit_strength"] + + 0.14 * (1 - params["global_cap_constraint"]) + + 0.16 * gaming_risk + + 0.12 * params["implementation_complexity"] + + 0.10 * params["hospital_cost_per_event_index"] + - 0.18 * hospital_deflection + - 0.10 * governance + ) + hospital_pressure = clamp( + 0.16 + + 0.45 * params["baseline_hospital_pressure"] + + 0.26 * (1 - hospital_deflection) + + 0.14 * demand_burden + + 0.10 * params["hospital_salience"] + - 0.20 * access + - 0.12 * urgent_ambulance + ) + implementation_feasibility = clamp( + 0.40 * params["stakeholder_alignment"] + + 0.25 * params["narrative_coherence"] + + 0.20 * params["data_observability_primary"] + + 0.15 * params["safety_governance"] + - 0.22 * params["implementation_complexity"] + - 0.18 * params["political_contestation"] + ) + viability = clamp( + 0.18 + + 0.16 * supply_generation + + 0.16 * access + + 0.14 * equity_legitimacy + + 0.12 * governance + + 0.16 * hospital_deflection + + 0.10 * implementation_feasibility + + 0.08 * (1 - fiscal_risk) + + 0.08 * (1 - gaming_risk) + ) return { "demand_burden": demand_burden, "copayment_burden": copay_burden, @@ -307,40 +1639,78 @@ def compute_architecture_indices(params: Mapping[str, float]) -> dict[str, float def run_monthly_simulation(params: Mapping[str, float], months: int = 60) -> pd.DataFrame: idx = compute_architecture_indices(params) rows = [] - unmet = 55.0 + 30.0*idx["demand_burden"] - 25.0*idx["access_index"] - capacity_stock = 50.0 + 38.0*idx["supply_generation"] + unmet = 55.0 + 30.0 * idx["demand_burden"] - 25.0 * idx["access_index"] + capacity_stock = 50.0 + 38.0 * idx["supply_generation"] for month in range(1, months + 1): seasonal = 1.0 + 0.045 * math.sin(2 * math.pi * month / 12) # Capacity increases gradually under stronger supply settings and declines under exit/tightness. - capacity_stock = max(5.0, capacity_stock + 1.1*idx["supply_generation"] - 0.65*params["workforce_exit_rate"] - 0.38*params["budget_tightness"] + 0.42*params["market_entry_response"]) - effective_access = clamp(idx["access_index"] + 0.003*(capacity_stock-50) - 0.08*idx["copayment_burden"]) - monthly_need = params["base_need_per_1000"] * seasonal * (0.80 + 0.44*idx["demand_burden"]) - primary_contacts = max(0.0, monthly_need * effective_access * (0.90 + 0.16*idx["supply_generation"])) + capacity_stock = max( + 5.0, + capacity_stock + + 1.1 * idx["supply_generation"] + - 0.65 * params["workforce_exit_rate"] + - 0.38 * params["budget_tightness"] + + 0.42 * params["market_entry_response"], + ) + effective_access = clamp(idx["access_index"] + 0.003 * (capacity_stock - 50) - 0.08 * idx["copayment_burden"]) + monthly_need = params["base_need_per_1000"] * seasonal * (0.80 + 0.44 * idx["demand_burden"]) + primary_contacts = max(0.0, monthly_need * effective_access * (0.90 + 0.16 * idx["supply_generation"])) # Unmet need persists and grows under access/cost constraints; place/governance reduces persistence. - unmet = max(0.0, params["unmet_need_persistence"] * unmet + monthly_need * (1-effective_access) * 0.46 + 18*idx["copayment_burden"] + 9*params["global_cap_constraint"] - 16*idx["place_accountability"] - 10*idx["urgent_ambulance_deflection"]) - ed_events = 58 * seasonal + 0.78 * unmet * params["ed_conversion_rate"] * (1.18 - 0.45*idx["urgent_ambulance_deflection"]) - admissions = 13 * seasonal + ed_events * params["admission_conversion_rate"] * (0.75 + 0.60*params["delay_complexity_growth"]) - ambulance = 21 * seasonal + 0.55 * unmet * params["ambulance_conveyance_default"] * (1.05 - 0.55*params["ambulance_deflection_rate"]) - hospital_pressure = clamp(0.18 + 0.0038*ed_events + 0.010*admissions + 0.0038*ambulance + 0.20*params["hospital_salience"] - 0.20*idx["hospital_deflection"]) - primary_public_cost = primary_contacts * (55 + 105*params["scheduled_benefit_price_adequacy"] + 36*params["capitation_base_strength"]) - ed_hosp_cost = ed_events * (430 + 990*params["hospital_cost_per_event_index"]) + admissions * (2900 + 4800*params["hospital_cost_per_event_index"]) - ambulance_cost = ambulance * (420 + 720*params["ambulance_event_cost_index"]) - leakage_cost = 1000 * (idx["gaming_risk"] + idx["fiscal_risk"]) * max(0.0, params["scheduled_medical_benefit_strength"] - params["gaming_controls"]) + unmet = max( + 0.0, + params["unmet_need_persistence"] * unmet + + monthly_need * (1 - effective_access) * 0.46 + + 18 * idx["copayment_burden"] + + 9 * params["global_cap_constraint"] + - 16 * idx["place_accountability"] + - 10 * idx["urgent_ambulance_deflection"], + ) + ed_events = 58 * seasonal + 0.78 * unmet * params["ed_conversion_rate"] * ( + 1.18 - 0.45 * idx["urgent_ambulance_deflection"] + ) + admissions = 13 * seasonal + ed_events * params["admission_conversion_rate"] * ( + 0.75 + 0.60 * params["delay_complexity_growth"] + ) + ambulance = 21 * seasonal + 0.55 * unmet * params["ambulance_conveyance_default"] * ( + 1.05 - 0.55 * params["ambulance_deflection_rate"] + ) + hospital_pressure = clamp( + 0.18 + + 0.0038 * ed_events + + 0.010 * admissions + + 0.0038 * ambulance + + 0.20 * params["hospital_salience"] + - 0.20 * idx["hospital_deflection"] + ) + primary_public_cost = primary_contacts * ( + 55 + 105 * params["scheduled_benefit_price_adequacy"] + 36 * params["capitation_base_strength"] + ) + ed_hosp_cost = ed_events * (430 + 990 * params["hospital_cost_per_event_index"]) + admissions * ( + 2900 + 4800 * params["hospital_cost_per_event_index"] + ) + ambulance_cost = ambulance * (420 + 720 * params["ambulance_event_cost_index"]) + leakage_cost = ( + 1000 + * (idx["gaming_risk"] + idx["fiscal_risk"]) + * max(0.0, params["scheduled_medical_benefit_strength"] - params["gaming_controls"]) + ) public_cost_index = (primary_public_cost + ed_hosp_cost + ambulance_cost + leakage_cost) / 100000.0 - rows.append({ - "month": month, - "primary_contacts_per_1000": primary_contacts, - "effective_access_index": effective_access, - "unmet_need_index": unmet, - "ed_events_per_100k": ed_events, - "admissions_per_100k": admissions, - "ambulance_conveyances_per_100k": ambulance, - "hospital_pressure_index": hospital_pressure, - "public_cost_index": public_cost_index, - "capacity_stock_index": capacity_stock, - **{k: v for k, v in idx.items() if k not in {"access_index"}}, - "access_index": idx["access_index"], - }) + rows.append( + { + "month": month, + "primary_contacts_per_1000": primary_contacts, + "effective_access_index": effective_access, + "unmet_need_index": unmet, + "ed_events_per_100k": ed_events, + "admissions_per_100k": admissions, + "ambulance_conveyances_per_100k": ambulance, + "hospital_pressure_index": hospital_pressure, + "public_cost_index": public_cost_index, + "capacity_stock_index": capacity_stock, + **{k: v for k, v in idx.items() if k not in {"access_index"}}, + "access_index": idx["access_index"], + } + ) return pd.DataFrame(rows) @@ -355,29 +1725,33 @@ def run_all_scenarios(months: int = 60) -> tuple[pd.DataFrame, pd.DataFrame]: monthly_frames.append(monthly) idx = compute_architecture_indices(params) final = monthly.iloc[-12:].mean(numeric_only=True) - summary_rows.append({ - "scenario_id": spec.scenario_id, - "scenario_name": spec.name, - "description": spec.description, - "hybrid_viability_score": round(100*idx["hybrid_viability"], 2), - "access_score": round(100*idx["access_index"], 2), - "supply_generation_score": round(100*idx["supply_generation"], 2), - "equity_legitimacy_score": round(100*idx["equity_legitimacy"], 2), - "governance_resilience_score": round(100*idx["governance_resilience"], 2), - "hospital_deflection_score": round(100*idx["hospital_deflection"], 2), - "fiscal_risk_score": round(100*idx["fiscal_risk"], 2), - "gaming_risk_score": round(100*idx["gaming_risk"], 2), - "hospital_pressure_score": round(100*idx["hospital_pressure"], 2), - "mean_last12_primary_contacts_per_1000": round(float(final["primary_contacts_per_1000"]), 2), - "mean_last12_unmet_need_index": round(float(final["unmet_need_index"]), 2), - "mean_last12_ed_events_per_100k": round(float(final["ed_events_per_100k"]), 2), - "mean_last12_admissions_per_100k": round(float(final["admissions_per_100k"]), 2), - "mean_last12_ambulance_conveyances_per_100k": round(float(final["ambulance_conveyances_per_100k"]), 2), - "mean_last12_hospital_pressure_index": round(float(final["hospital_pressure_index"]), 3), - "mean_last12_public_cost_index": round(float(final["public_cost_index"]), 2), - }) + summary_rows.append( + { + "scenario_id": spec.scenario_id, + "scenario_name": spec.name, + "description": spec.description, + "hybrid_viability_score": round(100 * idx["hybrid_viability"], 2), + "access_score": round(100 * idx["access_index"], 2), + "supply_generation_score": round(100 * idx["supply_generation"], 2), + "equity_legitimacy_score": round(100 * idx["equity_legitimacy"], 2), + "governance_resilience_score": round(100 * idx["governance_resilience"], 2), + "hospital_deflection_score": round(100 * idx["hospital_deflection"], 2), + "fiscal_risk_score": round(100 * idx["fiscal_risk"], 2), + "gaming_risk_score": round(100 * idx["gaming_risk"], 2), + "hospital_pressure_score": round(100 * idx["hospital_pressure"], 2), + "mean_last12_primary_contacts_per_1000": round(float(final["primary_contacts_per_1000"]), 2), + "mean_last12_unmet_need_index": round(float(final["unmet_need_index"]), 2), + "mean_last12_ed_events_per_100k": round(float(final["ed_events_per_100k"]), 2), + "mean_last12_admissions_per_100k": round(float(final["admissions_per_100k"]), 2), + "mean_last12_ambulance_conveyances_per_100k": round(float(final["ambulance_conveyances_per_100k"]), 2), + "mean_last12_hospital_pressure_index": round(float(final["hospital_pressure_index"]), 3), + "mean_last12_public_cost_index": round(float(final["public_cost_index"]), 2), + } + ) summary = pd.DataFrame(summary_rows) - summary["rank_by_hybrid_viability"] = summary["hybrid_viability_score"].rank(ascending=False, method="min").astype(int) + summary["rank_by_hybrid_viability"] = ( + summary["hybrid_viability_score"].rank(ascending=False, method="min").astype(int) + ) return pd.concat(monthly_frames, ignore_index=True), summary.sort_values("rank_by_hybrid_viability") @@ -389,20 +1763,22 @@ def sensitivity_analysis(base_scenario_id: str = "F4", delta: float = 0.08) -> p p = spec.name for direction, label in [(-1, "down"), (1, "up")]: params = base_params.copy() - params[p] = max(spec.lower_bound, min(spec.upper_bound, params[p] + direction*delta)) + params[p] = max(spec.lower_bound, min(spec.upper_bound, params[p] + direction * delta)) idx = compute_architecture_indices(params) - rows.append({ - "base_scenario_id": base_scenario_id, - "parameter": p, - "domain": spec.domain, - "direction": label, - "baseline_value": base_params[p], - "perturbed_value": params[p], - "hybrid_viability_change": 100*(idx["hybrid_viability"] - base_idx["hybrid_viability"]), - "hospital_pressure_change": 100*(idx["hospital_pressure"] - base_idx["hospital_pressure"]), - "access_change": 100*(idx["access_index"] - base_idx["access_index"]), - "fiscal_risk_change": 100*(idx["fiscal_risk"] - base_idx["fiscal_risk"]), - }) + rows.append( + { + "base_scenario_id": base_scenario_id, + "parameter": p, + "domain": spec.domain, + "direction": label, + "baseline_value": base_params[p], + "perturbed_value": params[p], + "hybrid_viability_change": 100 * (idx["hybrid_viability"] - base_idx["hybrid_viability"]), + "hospital_pressure_change": 100 * (idx["hospital_pressure"] - base_idx["hospital_pressure"]), + "access_change": 100 * (idx["access_index"] - base_idx["access_index"]), + "fiscal_risk_change": 100 * (idx["fiscal_risk"] - base_idx["fiscal_risk"]), + } + ) out = pd.DataFrame(rows) # Absolute largest direction per parameter for compact tornado plots. out["abs_viability_change"] = out["hybrid_viability_change"].abs() @@ -426,18 +1802,30 @@ def calibration_target_matrix() -> pd.DataFrame: for target, ids in mapping.items(): for pid in ids: p = by_id[pid] - rows.append({ - "calibration_target": target, - "parameter_id": p.parameter_id, - "parameter": p.name, - "domain": p.domain, - "priority": p.priority, - "real_data_needed": p.real_data_needed, - "estimation_strategy": p.estimation_strategy, - }) + rows.append( + { + "calibration_target": target, + "parameter_id": p.parameter_id, + "parameter": p.name, + "domain": p.domain, + "priority": p.priority, + "real_data_needed": p.real_data_needed, + "estimation_strategy": p.estimation_strategy, + } + ) return pd.DataFrame(rows) if __name__ == "__main__": monthly, summary = run_all_scenarios() - print(summary[["scenario_id", "scenario_name", "hybrid_viability_score", "hospital_pressure_score", "rank_by_hybrid_viability"]].to_string(index=False)) + print( + summary[ + [ + "scenario_id", + "scenario_name", + "hybrid_viability_score", + "hospital_pressure_score", + "rank_by_hybrid_viability", + ] + ].to_string(index=False) + ) diff --git a/models/primarycare_model/game.py b/models/primarycare_model/game.py index 56677c8..8c96407 100644 --- a/models/primarycare_model/game.py +++ b/models/primarycare_model/game.py @@ -80,11 +80,11 @@ def calculate_payoff(params: PolicyParameters) -> Payoff: funder = ( -params.upstream_public_cost - -params.hospital_cost - -params.hospital_political_penalty * params.hospital_pressure - -params.copayment_penalty * params.copayment_burden - -params.equity_penalty * params.equity_gap - -params.safety_penalty * params.safety_failure + - params.hospital_cost + - params.hospital_political_penalty * params.hospital_pressure + - params.copayment_penalty * params.copayment_burden + - params.equity_penalty * params.equity_gap + - params.safety_penalty * params.safety_failure + params.avoidance_benefit ) provider = ( diff --git a/models/primarycare_model/gnn_pathways.py b/models/primarycare_model/gnn_pathways.py index 35bed25..7284481 100644 --- a/models/primarycare_model/gnn_pathways.py +++ b/models/primarycare_model/gnn_pathways.py @@ -10,6 +10,7 @@ try: # pragma: no cover - optional accelerator dependency import jax import jax.numpy as jnp + HAS_JAX = True except ModuleNotFoundError: # pragma: no cover - lean runtime fallback jax = None @@ -73,9 +74,7 @@ def from_simulation( ) -> ReferralGraph: """Construct a synthetic referral graph from simulation settings.""" if not HAS_JAX: - raise ModuleNotFoundError( - "Install jax to construct a synthetic referral graph from simulation settings." - ) + raise ModuleNotFoundError("Install jax to construct a synthetic referral graph from simulation settings.") del config key_practice, key_cohort, key_edges, key_weights = jax.random.split(rng_key, 4) practice_features = jax.random.uniform(key_practice, (n_practices, 5)) diff --git a/models/primarycare_model/hybrid_model.py b/models/primarycare_model/hybrid_model.py index adc2026..03271b8 100644 --- a/models/primarycare_model/hybrid_model.py +++ b/models/primarycare_model/hybrid_model.py @@ -157,9 +157,7 @@ def interaction_penalty(scenario: Scenario, metrics: Mapping[str, float]) -> flo # Telehealth substitution risk appears when telehealth scale is not integrated with local in-person support. telehealth_gap = max( 0.0, - scenario.telehealth_scale - - 0.55 * scenario.telehealth_integration - - 0.45 * scenario.local_inperson_loading, + scenario.telehealth_scale - 0.55 * scenario.telehealth_integration - 0.45 * scenario.local_inperson_loading, ) # Hospital rescue persists when hospital pressure is visible but upstream KPIs/data are not. @@ -252,7 +250,9 @@ def hybrid_outcome(scenario: Scenario) -> HybridOutcome: elif scenario.scenario_id == "S2": interpretation = "Materially improves supply and hospital-pressure logic, but still needs stronger ambulance, equity, data and governance architecture." elif scenario.scenario_id == "S4": - interpretation = "Shows why benefits cannot be loose: access rises, but fiscal/gaming/equity risks weaken viability." + interpretation = ( + "Shows why benefits cannot be loose: access rises, but fiscal/gaming/equity risks weaken viability." + ) elif scenario.scenario_id == "S1": interpretation = "Improves allocation inside capitation but leaves the marginal supply and hospital-rescue games largely intact." else: @@ -338,7 +338,9 @@ def summarise_hybrid_uncertainty(draws: pd.DataFrame) -> pd.DataFrame: summary = grouped.agg(["mean", "std", lambda s: s.quantile(0.05), lambda s: s.quantile(0.95)]) summary.columns = [f"{metric}_{stat if isinstance(stat, str) else 'p'}" for metric, stat in summary.columns] # Rename lambda-generated fields cleanly. - summary = summary.rename(columns={col: col.replace("_", "_p05").replace("_", "_p95") for col in summary.columns}) + summary = summary.rename( + columns={col: col.replace("_", "_p05").replace("_", "_p95") for col in summary.columns} + ) return summary.reset_index() diff --git a/models/primarycare_model/ipc.py b/models/primarycare_model/ipc.py index 61bf9ed..b1c738a 100644 --- a/models/primarycare_model/ipc.py +++ b/models/primarycare_model/ipc.py @@ -2,6 +2,7 @@ PyArrow IPC Streaming Server/Client for ABM Runtime -> Streamlit UI. Zero-copy Arrow RecordBatch streaming, bypassing disk operations. """ + from __future__ import annotations import io @@ -25,6 +26,7 @@ class ArrowMemoryChannel: """In-memory Arrow IPC channel for same-process streaming.""" + def __init__(self) -> None: self._buffer: io.BytesIO = io.BytesIO() self._writer: ipc.RecordBatchStreamWriter | None = None @@ -33,12 +35,14 @@ def __init__(self) -> None: def open(self, schema: pa.Schema) -> None: with self._lock: - self._buffer.seek(0); self._buffer.truncate(0) + self._buffer.seek(0) + self._buffer.truncate(0) self._writer = ipc.new_stream(self._buffer, schema, options=IPC_WRITE_OPTIONS) self._finished = False def write_batch(self, batch: pa.RecordBatch) -> int: - if self._writer is None: raise RuntimeError("Not opened.") + if self._writer is None: + raise RuntimeError("Not opened.") with self._lock: pos = self._buffer.tell() self._writer.write_batch(batch) @@ -50,7 +54,8 @@ def write_table(self, table: pa.Table) -> int: def close(self) -> None: with self._lock: if self._writer is not None: - self._writer.close(); self._writer = None + self._writer.close() + self._writer = None self._finished = True def read_all(self) -> pa.Table: @@ -65,13 +70,14 @@ def read_all(self) -> pa.Table: return pa.Table.from_batches([], schema=TELEMETRY_ARROW_SCHEMA) @property - def is_finished(self) -> bool: return self._finished + def is_finished(self) -> bool: + return self._finished class ArrowStreamServer: """TCP socket server streaming Arrow RecordBatches.""" - def __init__(self, host=_DEFAULT_HOST, port=_DEFAULT_PORT, - schema=TELEMETRY_ARROW_SCHEMA): + + def __init__(self, host=_DEFAULT_HOST, port=_DEFAULT_PORT, schema=TELEMETRY_ARROW_SCHEMA): self.host, self.port, self.schema = host, port, schema self._server: socket.socket | None = None self._client: socket.socket | None = None @@ -82,11 +88,13 @@ def start(self) -> None: self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self._server.bind((self.host, self.port)) - self._server.listen(1); self._server.settimeout(10.0) + self._server.listen(1) + self._server.settimeout(10.0) self._running = True def accept(self) -> None: - if self._server is None: raise RuntimeError("Not started.") + if self._server is None: + raise RuntimeError("Not started.") try: c, addr = self._server.accept() self._client = c @@ -94,13 +102,15 @@ def accept(self) -> None: pass # Non-blocking accept loop; caller may retry later. def stream_batch(self, batch: pa.RecordBatch) -> int: - if self._client is None: raise RuntimeError("No client.") + if self._client is None: + raise RuntimeError("No client.") buf = io.BytesIO() with ipc.new_stream(buf, batch.schema, options=IPC_WRITE_OPTIONS) as writer: writer.write_batch(batch) payload = buf.getvalue() header = _FRAME_HEADER.pack(len(payload)) - with self._lock: self._client.sendall(header + payload) + with self._lock: + self._client.sendall(header + payload) return len(header) + len(payload) def stream_table(self, table: pa.Table) -> int: @@ -110,7 +120,8 @@ def stop(self) -> None: self._running = False for s in (self._client, self._server): if s is not None: - try: s.close() + try: + s.close() except OSError: pass # Socket may already be closed during shutdown. self._client = self._server = None @@ -118,6 +129,7 @@ def stop(self) -> None: class ArrowStreamClient: """TCP socket client receiving Arrow RecordBatches.""" + def __init__(self, host=_DEFAULT_HOST, port=_DEFAULT_PORT): self.host, self.port = host, port self._socket: socket.socket | None = None @@ -128,34 +140,46 @@ def connect(self) -> None: self._socket.connect((self.host, self.port)) def receive_batches(self) -> Generator[pa.RecordBatch, None, None]: - if self._socket is None: raise RuntimeError("Not connected.") + if self._socket is None: + raise RuntimeError("Not connected.") while True: try: hdr = self._recv_exact(_FRAME_HEADER.size) - if not hdr: break + if not hdr: + break plen = _FRAME_HEADER.unpack(hdr)[0] payload = self._recv_exact(plen) - if not payload: break + if not payload: + break buf = io.BytesIO(payload) try: with ipc.open_stream(buf) as reader: - for batch in reader: yield batch - except pa.ArrowInvalid: continue - except (ConnectionError, OSError): break + for batch in reader: + yield batch + except pa.ArrowInvalid: + continue + except (ConnectionError, OSError): + break def _recv_exact(self, size: int) -> bytes | None: - if self._socket is None: return None + if self._socket is None: + return None chunks, remaining = [], size while remaining > 0: - try: chunk = self._socket.recv(remaining) - except (ConnectionError, OSError): return None - if not chunk: return None - chunks.append(chunk); remaining -= len(chunk) + try: + chunk = self._socket.recv(remaining) + except (ConnectionError, OSError): + return None + if not chunk: + return None + chunks.append(chunk) + remaining -= len(chunk) return b"".join(chunks) def close(self) -> None: if self._socket is not None: - try: self._socket.close() + try: + self._socket.close() except OSError: pass # Socket may already be closed by the peer. self._socket = None @@ -165,26 +189,44 @@ def stream_abm_run_inmemory(run_fn, schema=TELEMETRY_ARROW_SCHEMA) -> ArrowMemor """Run ABM and stream telemetry via in-memory Arrow IPC.""" channel = ArrowMemoryChannel() channel.open(schema) + def writer(tel): arrays = [pa.array([tel.get(f.name)], type=f.type) for f in schema] channel.write_batch(pa.record_batch(arrays, schema=schema)) - try: run_fn(writer) - finally: channel.close() + + try: + run_fn(writer) + finally: + channel.close() return channel if __name__ == "__main__": logging.basicConfig(level=logging.INFO) + def _demo(w): for t in range(5): - w({"timestamp": time.time_ns()//1000, "run_id":"demo", - "scenario_name":"test", "tick":t, "month":t, - "patient_count":1000, "provider_count":50, - "total_visits":300, "capitation_flow":50000.0, - "ffs_flow":15000.0, "total_funding_flow":65000.0, - "avg_wait_days":3.5, "unmet_demand":10, - "cpu_usage_pct":45.0, "memory_mb":256.0}) + w( + { + "timestamp": time.time_ns() // 1000, + "run_id": "demo", + "scenario_name": "test", + "tick": t, + "month": t, + "patient_count": 1000, + "provider_count": 50, + "total_visits": 300, + "capitation_flow": 50000.0, + "ffs_flow": 15000.0, + "total_funding_flow": 65000.0, + "avg_wait_days": 3.5, + "unmet_demand": 10, + "cpu_usage_pct": 45.0, + "memory_mb": 256.0, + } + ) time.sleep(0.05) + ch = stream_abm_run_inmemory(_demo) tab = ch.read_all() print(f"IPC test: {tab.num_rows} rows received - PASSED") diff --git a/models/primarycare_model/jax_mc.py b/models/primarycare_model/jax_mc.py index 91f5c441..292fa97 100644 --- a/models/primarycare_model/jax_mc.py +++ b/models/primarycare_model/jax_mc.py @@ -187,7 +187,9 @@ def to_arrow(self) -> pa.Table: } ) - arrays = [pa.array([row[field.name] for row in rows], type=field.type) for field in MONTHLY_METRICS_ARROW_SCHEMA] + arrays = [ + pa.array([row[field.name] for row in rows], type=field.type) for field in MONTHLY_METRICS_ARROW_SCHEMA + ] return pa.Table.from_arrays(arrays, schema=MONTHLY_METRICS_ARROW_SCHEMA) def to_monthly_metrics_list(self, batch_idx: int = 0) -> list[MonthlyMetrics]: @@ -344,7 +346,9 @@ def run_mc_sweep( params_batch, seeds = generate_sweep_params(config, scenario, batch_size) start = time.perf_counter() - trajectories = np.stack([_numpy_trajectory(params_batch[idx], n_steps, int(seeds[idx])) for idx in range(batch_size)]) + trajectories = np.stack( + [_numpy_trajectory(params_batch[idx], n_steps, int(seeds[idx])) for idx in range(batch_size)] + ) wall_time_s = time.perf_counter() - start return MCSweepResult( trajectories=trajectories, diff --git a/models/primarycare_model/mcda.py b/models/primarycare_model/mcda.py index ff9585d..d2b84f7 100644 --- a/models/primarycare_model/mcda.py +++ b/models/primarycare_model/mcda.py @@ -14,6 +14,7 @@ class Criterion: default_weight: float related_games: str + @dataclass(frozen=True) class PolicyOption: option_id: str @@ -24,6 +25,7 @@ class PolicyOption: equity_risk_penalty: float fiscal_gaming_penalty: float + @dataclass(frozen=True) class WeightSet: weight_set_id: str @@ -31,6 +33,7 @@ class WeightSet: description: str weights: Mapping[str, float] + @dataclass(frozen=True) class GamePosition: game_id: str @@ -45,158 +48,677 @@ class GamePosition: confidence: float rationale: str + CRITERIA = ( - Criterion('C1','Access and supply generation','Whether the option increases safe upstream primary, urgent and ambulance capacity.',14,'G3, G4, G8'), - Criterion('C2','Hospital deflection','Whether the option reduces avoidable ED, ambulance conveyance and hospital admission flow.',14,'G1, G2, G7'), - Criterion('C3','Equity and Te Tiriti legitimacy','Whether access expands without worsening inequity, trust, Maori/Pacific provider legitimacy or co-payment barriers.',14,'G10, G12'), - Criterion('C4','Rural and in-person resilience','Whether the option protects local in-person capacity rather than substituting telehealth only.',9,'G7, G8, G9'), - Criterion('C5','Fiscal sustainability','Whether the model is affordable, controllable and transparent to central fiscal decision-makers.',12,'G1, G2, G6, G10'), - Criterion('C6','Gaming and low-value activity risk','Whether it avoids avoidable churn, opportunistic claiming and provider-induced demand.',10,'G3, G5, G10, G13'), - Criterion('C7','Administrative simplicity and market entry','Whether it lowers transaction costs and barriers to market entry or expansion.',9,'G5, G8, G14'), - Criterion('C8','Governance and clinical safety','Whether scope, prescribing, referral, audit, clinical governance and accountability controls are strong.',10,'G8, G11, G14'), - Criterion('C9','Political feasibility','Whether the option can survive institutional contestation and be framed coherently.',5,'G5, G12, G13'), - Criterion('C10','Data and accountability readiness','Whether outcomes, contacts and downstream flows can be measured and reported at top-tier level.',8,'G11, G14'), + Criterion( + "C1", + "Access and supply generation", + "Whether the option increases safe upstream primary, urgent and ambulance capacity.", + 14, + "G3, G4, G8", + ), + Criterion( + "C2", + "Hospital deflection", + "Whether the option reduces avoidable ED, ambulance conveyance and hospital admission flow.", + 14, + "G1, G2, G7", + ), + Criterion( + "C3", + "Equity and Te Tiriti legitimacy", + "Whether access expands without worsening inequity, trust, Maori/Pacific provider legitimacy or co-payment barriers.", + 14, + "G10, G12", + ), + Criterion( + "C4", + "Rural and in-person resilience", + "Whether the option protects local in-person capacity rather than substituting telehealth only.", + 9, + "G7, G8, G9", + ), + Criterion( + "C5", + "Fiscal sustainability", + "Whether the model is affordable, controllable and transparent to central fiscal decision-makers.", + 12, + "G1, G2, G6, G10", + ), + Criterion( + "C6", + "Gaming and low-value activity risk", + "Whether it avoids avoidable churn, opportunistic claiming and provider-induced demand.", + 10, + "G3, G5, G10, G13", + ), + Criterion( + "C7", + "Administrative simplicity and market entry", + "Whether it lowers transaction costs and barriers to market entry or expansion.", + 9, + "G5, G8, G14", + ), + Criterion( + "C8", + "Governance and clinical safety", + "Whether scope, prescribing, referral, audit, clinical governance and accountability controls are strong.", + 10, + "G8, G11, G14", + ), + Criterion( + "C9", + "Political feasibility", + "Whether the option can survive institutional contestation and be framed coherently.", + 5, + "G5, G12, G13", + ), + Criterion( + "C10", + "Data and accountability readiness", + "Whether outcomes, contacts and downstream flows can be measured and reported at top-tier level.", + 8, + "G11, G14", + ), ) POLICY_OPTIONS = ( - PolicyOption('O0','Status quo tight control','S0','Existing dominant capitation/PHO/contracting architecture with constrained upstream expansion.',1.5,1.5,1.0), - PolicyOption('O1','Capitation reweighting only','S1','Improve allocation of capitation without changing supply architecture.',1.3,0.7,0.8), - PolicyOption('O2','Capitation reweighting plus access target','S1+target','Add top-level access target to reweighted capitation.',1.5,0.7,1.4), - PolicyOption('O3','Primary Care Benefits Schedule','S2','Defined contact-type benefits, demand-driven within rules, retaining capitation for continuity.',2.4,1.0,2.2), - PolicyOption('O4','Benefits schedule plus scope-enabled eligibility','S2+scope','Allow eligible activity by GPs, NPs, pharmacists, allied health, paramedics and other accredited providers within scope.',3.0,1.2,2.6), - PolicyOption('O5','Full upstream access architecture','S3','Benefits schedule plus scope governance, equity protections, direct/optional claiming, ambulance alternatives, PHO-function reform, data, KPIs and audit.',2.75,0.7,2.3), - PolicyOption('O6','Loose demand-driven benefits with weak controls','S4','High activity funding with weak scope, audit, equity, data and co-payment controls.',4.5,4.0,3.8333333333333), - PolicyOption('O7','ACC/ambulance alternatives strengthened only','partial','Strengthen ambulance alternatives and ACC/prehospital funding, without primary care architecture reform.',2.0,1.2,1.2), - PolicyOption('O8','PHO reform/direct claims only','partial','Reduce PHO payment intermediation and allow direct rules-based claiming, without comprehensive benefits/scope reform.',2.9,1.5,2.3), - PolicyOption('O9','Hospital investment priority only','hospital','Prioritise hospital capacity and acute rescue while leaving upstream access constrained.',1.75,1.5,1.0), + PolicyOption( + "O0", + "Status quo tight control", + "S0", + "Existing dominant capitation/PHO/contracting architecture with constrained upstream expansion.", + 1.5, + 1.5, + 1.0, + ), + PolicyOption( + "O1", + "Capitation reweighting only", + "S1", + "Improve allocation of capitation without changing supply architecture.", + 1.3, + 0.7, + 0.8, + ), + PolicyOption( + "O2", + "Capitation reweighting plus access target", + "S1+target", + "Add top-level access target to reweighted capitation.", + 1.5, + 0.7, + 1.4, + ), + PolicyOption( + "O3", + "Primary Care Benefits Schedule", + "S2", + "Defined contact-type benefits, demand-driven within rules, retaining capitation for continuity.", + 2.4, + 1.0, + 2.2, + ), + PolicyOption( + "O4", + "Benefits schedule plus scope-enabled eligibility", + "S2+scope", + "Allow eligible activity by GPs, NPs, pharmacists, allied health, paramedics and other accredited providers within scope.", + 3.0, + 1.2, + 2.6, + ), + PolicyOption( + "O5", + "Full upstream access architecture", + "S3", + "Benefits schedule plus scope governance, equity protections, direct/optional claiming, ambulance alternatives, PHO-function reform, data, KPIs and audit.", + 2.75, + 0.7, + 2.3, + ), + PolicyOption( + "O6", + "Loose demand-driven benefits with weak controls", + "S4", + "High activity funding with weak scope, audit, equity, data and co-payment controls.", + 4.5, + 4.0, + 3.8333333333333, + ), + PolicyOption( + "O7", + "ACC/ambulance alternatives strengthened only", + "partial", + "Strengthen ambulance alternatives and ACC/prehospital funding, without primary care architecture reform.", + 2.0, + 1.2, + 1.2, + ), + PolicyOption( + "O8", + "PHO reform/direct claims only", + "partial", + "Reduce PHO payment intermediation and allow direct rules-based claiming, without comprehensive benefits/scope reform.", + 2.9, + 1.5, + 2.3, + ), + PolicyOption( + "O9", + "Hospital investment priority only", + "hospital", + "Prioritise hospital capacity and acute rescue while leaving upstream access constrained.", + 1.75, + 1.5, + 1.0, + ), ) # option, criterion, raw score -2/+2, confidence, rationale POLICY_SCORE_ROWS = ( # O0 - ('O0','C1',-1.2,0.85,'Constrained marginal supply and market entry.'),('O0','C2',-1.2,0.80,'Unmet need remains channelled into hospitals.'),('O0','C3',-0.8,0.70,'Equity barriers persist through waiting, co-payment and access friction.'),('O0','C4',-0.8,0.70,'Rural local supply remains fragile.'),('O0','C5',0.4,0.60,'Short-term fiscal containment, but downstream hospital risk.'),('O0','C6',0.5,0.75,'Low direct claiming risk, but hidden under-service risk.'),('O0','C7',-0.8,0.65,'PHO/intermediated pathways remain complex.'),('O0','C8',0.1,0.60,'Existing governance familiar but incomplete for upstream outcomes.'),('O0','C9',0.7,0.70,'Politically familiar.'),('O0','C10',-0.5,0.65,'Upstream unmet need remains poorly visible.'), + ("O0", "C1", -1.2, 0.85, "Constrained marginal supply and market entry."), + ("O0", "C2", -1.2, 0.80, "Unmet need remains channelled into hospitals."), + ("O0", "C3", -0.8, 0.70, "Equity barriers persist through waiting, co-payment and access friction."), + ("O0", "C4", -0.8, 0.70, "Rural local supply remains fragile."), + ("O0", "C5", 0.4, 0.60, "Short-term fiscal containment, but downstream hospital risk."), + ("O0", "C6", 0.5, 0.75, "Low direct claiming risk, but hidden under-service risk."), + ("O0", "C7", -0.8, 0.65, "PHO/intermediated pathways remain complex."), + ("O0", "C8", 0.1, 0.60, "Existing governance familiar but incomplete for upstream outcomes."), + ("O0", "C9", 0.7, 0.70, "Politically familiar."), + ("O0", "C10", -0.5, 0.65, "Upstream unmet need remains poorly visible."), # O1 - ('O1','C1',-0.1,0.80,'Better allocation but weak marginal supply signal.'),('O1','C2',-0.2,0.75,'Some access improvement but hospital-pressure mechanism remains.'),('O1','C3',0.6,0.80,'Better need weighting.'),('O1','C4',0.2,0.65,'Rurality weighting helps but does not guarantee local capacity.'),('O1','C5',0.2,0.70,'Contained and predictable.'),('O1','C6',0.4,0.75,'Low activity gaming risk.'),('O1','C7',-0.1,0.65,'Administrative structure mostly unchanged.'),('O1','C8',0.2,0.65,'Familiar governance.'),('O1','C9',0.3,0.70,'Politically safer than structural reform.'),('O1','C10',0.2,0.65,'Some measurement improvement only.'), + ("O1", "C1", -0.1, 0.80, "Better allocation but weak marginal supply signal."), + ("O1", "C2", -0.2, 0.75, "Some access improvement but hospital-pressure mechanism remains."), + ("O1", "C3", 0.6, 0.80, "Better need weighting."), + ("O1", "C4", 0.2, 0.65, "Rurality weighting helps but does not guarantee local capacity."), + ("O1", "C5", 0.2, 0.70, "Contained and predictable."), + ("O1", "C6", 0.4, 0.75, "Low activity gaming risk."), + ("O1", "C7", -0.1, 0.65, "Administrative structure mostly unchanged."), + ("O1", "C8", 0.2, 0.65, "Familiar governance."), + ("O1", "C9", 0.3, 0.70, "Politically safer than structural reform."), + ("O1", "C10", 0.2, 0.65, "Some measurement improvement only."), # O2 - ('O2','C1',0.0,0.75,'Target may increase attention but not necessarily capacity.'),('O2','C2',0.1,0.70,'Potential deflection if target influences funding.'),('O2','C3',0.6,0.75,'Better allocation and visibility.'),('O2','C4',0.2,0.60,'May not protect in-person rural supply.'),('O2','C5',0.1,0.65,'Target can create pressure without matched funding.'),('O2','C6',0.4,0.70,'Low activity gaming risk.'),('O2','C7',0.0,0.60,'Market entry mostly unchanged.'),('O2','C8',0.3,0.65,'Accountability improves.'),('O2','C9',0.4,0.70,'Politically feasible.'),('O2','C10',0.6,0.75,'Access target and dataset improve observability.'), + ("O2", "C1", 0.0, 0.75, "Target may increase attention but not necessarily capacity."), + ("O2", "C2", 0.1, 0.70, "Potential deflection if target influences funding."), + ("O2", "C3", 0.6, 0.75, "Better allocation and visibility."), + ("O2", "C4", 0.2, 0.60, "May not protect in-person rural supply."), + ("O2", "C5", 0.1, 0.65, "Target can create pressure without matched funding."), + ("O2", "C6", 0.4, 0.70, "Low activity gaming risk."), + ("O2", "C7", 0.0, 0.60, "Market entry mostly unchanged."), + ("O2", "C8", 0.3, 0.65, "Accountability improves."), + ("O2", "C9", 0.4, 0.70, "Politically feasible."), + ("O2", "C10", 0.6, 0.75, "Access target and dataset improve observability."), # O3 - ('O3','C1',1.45,0.70,'Adds marginal payment for defined contacts.'),('O3','C2',1.1,0.65,'More upstream activity may reduce avoidable hospital flow.'),('O3','C3',0.4,0.55,'Equity depends on co-payment design.'),('O3','C4',0.6,0.55,'Can include rural/in-person loadings.'),('O3','C5',-0.2,0.50,'Demand-driven exposure needs fiscal controls.'),('O3','C6',-0.1,0.50,'Gaming risk manageable if contact types are defined.'),('O3','C7',0.8,0.65,'Direct benefits improve portability.'),('O3','C8',0.4,0.55,'Needs scope and audit framework.'),('O3','C9',-0.1,0.50,'Contestable but patient-access framing helps.'),('O3','C10',0.8,0.65,'Claims platform improves observability.'), + ("O3", "C1", 1.45, 0.70, "Adds marginal payment for defined contacts."), + ("O3", "C2", 1.1, 0.65, "More upstream activity may reduce avoidable hospital flow."), + ("O3", "C3", 0.4, 0.55, "Equity depends on co-payment design."), + ("O3", "C4", 0.6, 0.55, "Can include rural/in-person loadings."), + ("O3", "C5", -0.2, 0.50, "Demand-driven exposure needs fiscal controls."), + ("O3", "C6", -0.1, 0.50, "Gaming risk manageable if contact types are defined."), + ("O3", "C7", 0.8, 0.65, "Direct benefits improve portability."), + ("O3", "C8", 0.4, 0.55, "Needs scope and audit framework."), + ("O3", "C9", -0.1, 0.50, "Contestable but patient-access framing helps."), + ("O3", "C10", 0.8, 0.65, "Claims platform improves observability."), # O4 - ('O4','C1',1.9,0.65,'Scope-enabled claiming expands supply.'),('O4','C2',1.3,0.60,'More upstream access may deflect hospital flow.'),('O4','C3',0.5,0.55,'Potential equity gain if high-need providers are included.'),('O4','C4',1.0,0.60,'Can improve rural supply if local non-GP providers claim.'),('O4','C5',-0.4,0.45,'More providers increase fiscal exposure.'),('O4','C6',-0.3,0.45,'Low-value activity risk rises without tight governance.'),('O4','C7',1.3,0.60,'Major market-entry gain.'),('O4','C8',0.2,0.50,'Clinical safety depends on scope rules and audit.'),('O4','C9',-0.4,0.45,'Professional contestation likely.'),('O4','C10',0.7,0.60,'Claiming data improves visibility.'), + ("O4", "C1", 1.9, 0.65, "Scope-enabled claiming expands supply."), + ("O4", "C2", 1.3, 0.60, "More upstream access may deflect hospital flow."), + ("O4", "C3", 0.5, 0.55, "Potential equity gain if high-need providers are included."), + ("O4", "C4", 1.0, 0.60, "Can improve rural supply if local non-GP providers claim."), + ("O4", "C5", -0.4, 0.45, "More providers increase fiscal exposure."), + ("O4", "C6", -0.3, 0.45, "Low-value activity risk rises without tight governance."), + ("O4", "C7", 1.3, 0.60, "Major market-entry gain."), + ("O4", "C8", 0.2, 0.50, "Clinical safety depends on scope rules and audit."), + ("O4", "C9", -0.4, 0.45, "Professional contestation likely."), + ("O4", "C10", 0.7, 0.60, "Claiming data improves visibility."), # O5 - ('O5','C1',1.9,0.70,'Strong supply architecture with governance.'),('O5','C2',1.7,0.65,'Best whole-system hospital-deflection logic.'),('O5','C3',1.5,0.60,'Equity protections and retained relational functions.'),('O5','C4',1.5,0.65,'Rural/in-person loading and ambulance alternatives.'),('O5','C5',0.5,0.50,'Demand-driven but controlled by rules and audit.'),('O5','C6',0.8,0.55,'Controls reduce low-value activity risk.'),('O5','C7',1.3,0.65,'Direct/optional claims reduce friction.'),('O5','C8',1.3,0.60,'Governance designed into architecture.'),('O5','C9',0.0,0.45,'Complex but coalition-building possible.'),('O5','C10',1.6,0.70,'Data visibility and top-tier KPIs.'), + ("O5", "C1", 1.9, 0.70, "Strong supply architecture with governance."), + ("O5", "C2", 1.7, 0.65, "Best whole-system hospital-deflection logic."), + ("O5", "C3", 1.5, 0.60, "Equity protections and retained relational functions."), + ("O5", "C4", 1.5, 0.65, "Rural/in-person loading and ambulance alternatives."), + ("O5", "C5", 0.5, 0.50, "Demand-driven but controlled by rules and audit."), + ("O5", "C6", 0.8, 0.55, "Controls reduce low-value activity risk."), + ("O5", "C7", 1.3, 0.65, "Direct/optional claims reduce friction."), + ("O5", "C8", 1.3, 0.60, "Governance designed into architecture."), + ("O5", "C9", 0.0, 0.45, "Complex but coalition-building possible."), + ("O5", "C10", 1.6, 0.70, "Data visibility and top-tier KPIs."), # O6 - ('O6','C1',1.4,0.65,'Access expands.'),('O6','C2',0.8,0.55,'Some deflection but poorly controlled.'),('O6','C3',-0.8,0.50,'Co-payment and provider-induced patterns may worsen inequity.'),('O6','C4',0.4,0.45,'May not protect rural in-person care.'),('O6','C5',-1.5,0.65,'High fiscal exposure.'),('O6','C6',-1.6,0.70,'High gaming/low-value risk.'),('O6','C7',1.0,0.55,'Entry improves.'),('O6','C8',-1.2,0.60,'Weak governance.'),('O6','C9',-0.8,0.55,'Politically vulnerable.'),('O6','C10',0.3,0.50,'More claims data but weak accountability.'), + ("O6", "C1", 1.4, 0.65, "Access expands."), + ("O6", "C2", 0.8, 0.55, "Some deflection but poorly controlled."), + ("O6", "C3", -0.8, 0.50, "Co-payment and provider-induced patterns may worsen inequity."), + ("O6", "C4", 0.4, 0.45, "May not protect rural in-person care."), + ("O6", "C5", -1.5, 0.65, "High fiscal exposure."), + ("O6", "C6", -1.6, 0.70, "High gaming/low-value risk."), + ("O6", "C7", 1.0, 0.55, "Entry improves."), + ("O6", "C8", -1.2, 0.60, "Weak governance."), + ("O6", "C9", -0.8, 0.55, "Politically vulnerable."), + ("O6", "C10", 0.3, 0.50, "More claims data but weak accountability."), # O7 - ('O7','C1',0.5,0.60,'Improves urgent/prehospital supply but primary care remains constrained.'),('O7','C2',0.7,0.65,'Ambulance alternatives can reduce ED flow.'),('O7','C3',0.3,0.55,'Equity depends on access pathway design.'),('O7','C4',0.5,0.60,'Useful rural lever.'),('O7','C5',0.0,0.50,'May save hospital costs but needs funding.'),('O7','C6',0.2,0.55,'Moderate risk if protocols tight.'),('O7','C7',0.2,0.50,'Does not solve PHO/primary care entry.'),('O7','C8',0.5,0.60,'Paramedic protocols can be governed.'),('O7','C9',0.3,0.55,'Usually more feasible than PHO/GP funding reform.'),('O7','C10',0.6,0.60,'Ambulance data already relatively mature.'), + ("O7", "C1", 0.5, 0.60, "Improves urgent/prehospital supply but primary care remains constrained."), + ("O7", "C2", 0.7, 0.65, "Ambulance alternatives can reduce ED flow."), + ("O7", "C3", 0.3, 0.55, "Equity depends on access pathway design."), + ("O7", "C4", 0.5, 0.60, "Useful rural lever."), + ("O7", "C5", 0.0, 0.50, "May save hospital costs but needs funding."), + ("O7", "C6", 0.2, 0.55, "Moderate risk if protocols tight."), + ("O7", "C7", 0.2, 0.50, "Does not solve PHO/primary care entry."), + ("O7", "C8", 0.5, 0.60, "Paramedic protocols can be governed."), + ("O7", "C9", 0.3, 0.55, "Usually more feasible than PHO/GP funding reform."), + ("O7", "C10", 0.6, 0.60, "Ambulance data already relatively mature."), # O8 - ('O8','C1',0.4,0.55,'Direct claiming may enable entry but no contact benefits.'),('O8','C2',0.2,0.50,'Indirect hospital effect only.'),('O8','C3',0.0,0.45,'Equity depends on replacement functions.'),('O8','C4',0.2,0.45,'Potential rural entry but not guaranteed.'),('O8','C5',0.2,0.50,'Simplification may reduce costs.'),('O8','C6',0.0,0.45,'Claiming risk depends on rules.'),('O8','C7',1.5,0.60,'Strongest market-entry simplification.'),('O8','C8',0.3,0.50,'Needs replacement accountability.'),('O8','C9',-0.8,0.45,'Institutional contestation likely.'),('O8','C10',0.3,0.55,'Direct claims can improve data if designed well.'), + ("O8", "C1", 0.4, 0.55, "Direct claiming may enable entry but no contact benefits."), + ("O8", "C2", 0.2, 0.50, "Indirect hospital effect only."), + ("O8", "C3", 0.0, 0.45, "Equity depends on replacement functions."), + ("O8", "C4", 0.2, 0.45, "Potential rural entry but not guaranteed."), + ("O8", "C5", 0.2, 0.50, "Simplification may reduce costs."), + ("O8", "C6", 0.0, 0.45, "Claiming risk depends on rules."), + ("O8", "C7", 1.5, 0.60, "Strongest market-entry simplification."), + ("O8", "C8", 0.3, 0.50, "Needs replacement accountability."), + ("O8", "C9", -0.8, 0.45, "Institutional contestation likely."), + ("O8", "C10", 0.3, 0.55, "Direct claims can improve data if designed well."), # O9 - ('O9','C1',-0.8,0.80,'Upstream supply remains constrained.'),('O9','C2',-0.4,0.75,'May treat hospital pressure after the fact.'),('O9','C3',-0.2,0.65,'Does not address upstream access equity.'),('O9','C4',-0.3,0.60,'Rural upstream access may remain weak.'),('O9','C5',-0.8,0.65,'High-cost sector absorbs growth.'),('O9','C6',0.7,0.70,'Low claiming-gaming risk, but high system opportunity cost.'),('O9','C7',0.5,0.65,'Administratively familiar.'),('O9','C8',0.5,0.70,'Hospital governance strong.'),('O9','C9',0.6,0.75,'Politically familiar rescue model.'),('O9','C10',0.2,0.65,'Hospital data visible, upstream data still incomplete.'), + ("O9", "C1", -0.8, 0.80, "Upstream supply remains constrained."), + ("O9", "C2", -0.4, 0.75, "May treat hospital pressure after the fact."), + ("O9", "C3", -0.2, 0.65, "Does not address upstream access equity."), + ("O9", "C4", -0.3, 0.60, "Rural upstream access may remain weak."), + ("O9", "C5", -0.8, 0.65, "High-cost sector absorbs growth."), + ("O9", "C6", 0.7, 0.70, "Low claiming-gaming risk, but high system opportunity cost."), + ("O9", "C7", 0.5, 0.65, "Administratively familiar."), + ("O9", "C8", 0.5, 0.70, "Hospital governance strong."), + ("O9", "C9", 0.6, 0.75, "Politically familiar rescue model."), + ("O9", "C10", 0.2, 0.65, "Hospital data visible, upstream data still incomplete."), ) WEIGHT_SETS = ( - WeightSet('W0','Balanced policy','Default weights for whole-system decision-making.', {c.criterion_id:c.default_weight for c in CRITERIA}), - WeightSet('W1','Equity and Te Tiriti focused','Prioritises equity, trust, access and rural resilience.', {'C1':14,'C2':10,'C3':25,'C4':12,'C5':8,'C6':6,'C7':5,'C8':9,'C9':4,'C10':6}), - WeightSet('W2','Fiscal-control focused','Prioritises fiscal sustainability, gaming risk and governance.', {'C1':10,'C2':12,'C3':10,'C4':5,'C5':25,'C6':15,'C7':5,'C8':10,'C9':4,'C10':4}), - WeightSet('W3','Rural access focused','Prioritises local in-person resilience and access.', {'C1':18,'C2':12,'C3':15,'C4':20,'C5':8,'C6':6,'C7':6,'C8':8,'C9':3,'C10':8}), - WeightSet('W4','Hospital-pressure focused','Prioritises hospital deflection, upstream access and data visibility.', {'C1':15,'C2':25,'C3':10,'C4':8,'C5':12,'C6':8,'C7':5,'C8':9,'C9':3,'C10':10}), - WeightSet('W5','Market-entry focused','Prioritises administrative simplicity, entry, access and governance.', {'C1':15,'C2':10,'C3':10,'C4':8,'C5':8,'C6':8,'C7':20,'C8':9,'C9':5,'C10':7}), + WeightSet( + "W0", + "Balanced policy", + "Default weights for whole-system decision-making.", + {c.criterion_id: c.default_weight for c in CRITERIA}, + ), + WeightSet( + "W1", + "Equity and Te Tiriti focused", + "Prioritises equity, trust, access and rural resilience.", + {"C1": 14, "C2": 10, "C3": 25, "C4": 12, "C5": 8, "C6": 6, "C7": 5, "C8": 9, "C9": 4, "C10": 6}, + ), + WeightSet( + "W2", + "Fiscal-control focused", + "Prioritises fiscal sustainability, gaming risk and governance.", + {"C1": 10, "C2": 12, "C3": 10, "C4": 5, "C5": 25, "C6": 15, "C7": 5, "C8": 10, "C9": 4, "C10": 4}, + ), + WeightSet( + "W3", + "Rural access focused", + "Prioritises local in-person resilience and access.", + {"C1": 18, "C2": 12, "C3": 15, "C4": 20, "C5": 8, "C6": 6, "C7": 6, "C8": 8, "C9": 3, "C10": 8}, + ), + WeightSet( + "W4", + "Hospital-pressure focused", + "Prioritises hospital deflection, upstream access and data visibility.", + {"C1": 15, "C2": 25, "C3": 10, "C4": 8, "C5": 12, "C6": 8, "C7": 5, "C8": 9, "C9": 3, "C10": 10}, + ), + WeightSet( + "W5", + "Market-entry focused", + "Prioritises administrative simplicity, entry, access and governance.", + {"C1": 15, "C2": 10, "C3": 10, "C4": 8, "C5": 8, "C6": 8, "C7": 20, "C8": 9, "C9": 5, "C10": 7}, + ), ) GAME_POSITIONS = ( - GamePosition('G1','Hospital-salience budget game','hospital-rescue equilibrium','upstream-salience equilibrium',5,5,4,3,3,4,'Hospital pressure is visible, urgent and fundable; upstream failure is dispersed.'), - GamePosition('G2','Health NZ internal allocation game','hospital-operations dominance','balanced internal accountability',4,5,3,3,3,3,'Hospital operational risk may dominate internal allocation attention.'), - GamePosition('G3','Capitation marginal-supply game','marginal-rationing equilibrium','marginal-expansion equilibrium',5,4,4,4,3,4,'Weak marginal contact payment can constrain additional clinically necessary work.'), - GamePosition('G4','Consumer access pathway game','delay/pay/ED substitution','early-access equilibrium',4,4,5,3,3,3,'Consumers route around delays via co-payment, telehealth, ambulance or ED.'), - GamePosition('G5','PHO intermediation game','intermediated-gatekeeping equilibrium','optional value-adding support',4,3,3,3,4,2.5,'PHO functions may add value, but payment intermediation may add friction.'), - GamePosition('G6','ACC/Health NZ cross-funder game','cross-subsidy opacity','whole-system funding visibility',4,4,3,3,3,2.5,'ACC activity funding may stabilise supply; isolating it may shift pressure elsewhere.'), - GamePosition('G7','Ambulance conveyance game','ED-conveyance default','safe alternative-disposition equilibrium',4,5,4,4,3,3.5,'Ambulance alternatives need funding, governance and follow-up.'), - GamePosition('G8','Scope-of-practice supply game','professional-bottleneck equilibrium','scope-enabled supply equilibrium',5,4,4,4,4,3,'Funding eligibility may be narrower than safe clinical scope.'), - GamePosition('G9','Telehealth/local-supply game','telehealth-substitution/fragmentation','integrated hybrid-access equilibrium',3,3,4,3,3,3,'Telehealth extends access but may erode local supply if poorly integrated.'), - GamePosition('G10','Co-payment calibration game','price-rationing equity failure','calibrated co-payment equilibrium',4,4,5,3,4,3,'Co-payment can signal demand or deter necessary care.'), - GamePosition('G11','KPI salience game','hospital-target dominance','upstream target salience',5,5,4,4,2,4,'Top-tier KPIs determine what becomes managed.'), - GamePosition('G12','Equity and trust game','transactional-access without trust','benefits plus equity-function equilibrium',5,3,5,3,4,3,'Access benefits do not replace relational and kaupapa Maori/Pacific/locality functions.'), - GamePosition('G13','Political economy game','institutional-defence equilibrium','access-architecture coalition',3,3,3,2,5,3,'Reform framing determines coalition formation and resistance.'), - GamePosition('G14','Data observability game','hidden-unmet-need equilibrium','observable upstream-flow equilibrium',5,5,4,4,2,4,'Unobserved upstream need remains less fundable than visible hospital pressure.'), + GamePosition( + "G1", + "Hospital-salience budget game", + "hospital-rescue equilibrium", + "upstream-salience equilibrium", + 5, + 5, + 4, + 3, + 3, + 4, + "Hospital pressure is visible, urgent and fundable; upstream failure is dispersed.", + ), + GamePosition( + "G2", + "Health NZ internal allocation game", + "hospital-operations dominance", + "balanced internal accountability", + 4, + 5, + 3, + 3, + 3, + 3, + "Hospital operational risk may dominate internal allocation attention.", + ), + GamePosition( + "G3", + "Capitation marginal-supply game", + "marginal-rationing equilibrium", + "marginal-expansion equilibrium", + 5, + 4, + 4, + 4, + 3, + 4, + "Weak marginal contact payment can constrain additional clinically necessary work.", + ), + GamePosition( + "G4", + "Consumer access pathway game", + "delay/pay/ED substitution", + "early-access equilibrium", + 4, + 4, + 5, + 3, + 3, + 3, + "Consumers route around delays via co-payment, telehealth, ambulance or ED.", + ), + GamePosition( + "G5", + "PHO intermediation game", + "intermediated-gatekeeping equilibrium", + "optional value-adding support", + 4, + 3, + 3, + 3, + 4, + 2.5, + "PHO functions may add value, but payment intermediation may add friction.", + ), + GamePosition( + "G6", + "ACC/Health NZ cross-funder game", + "cross-subsidy opacity", + "whole-system funding visibility", + 4, + 4, + 3, + 3, + 3, + 2.5, + "ACC activity funding may stabilise supply; isolating it may shift pressure elsewhere.", + ), + GamePosition( + "G7", + "Ambulance conveyance game", + "ED-conveyance default", + "safe alternative-disposition equilibrium", + 4, + 5, + 4, + 4, + 3, + 3.5, + "Ambulance alternatives need funding, governance and follow-up.", + ), + GamePosition( + "G8", + "Scope-of-practice supply game", + "professional-bottleneck equilibrium", + "scope-enabled supply equilibrium", + 5, + 4, + 4, + 4, + 4, + 3, + "Funding eligibility may be narrower than safe clinical scope.", + ), + GamePosition( + "G9", + "Telehealth/local-supply game", + "telehealth-substitution/fragmentation", + "integrated hybrid-access equilibrium", + 3, + 3, + 4, + 3, + 3, + 3, + "Telehealth extends access but may erode local supply if poorly integrated.", + ), + GamePosition( + "G10", + "Co-payment calibration game", + "price-rationing equity failure", + "calibrated co-payment equilibrium", + 4, + 4, + 5, + 3, + 4, + 3, + "Co-payment can signal demand or deter necessary care.", + ), + GamePosition( + "G11", + "KPI salience game", + "hospital-target dominance", + "upstream target salience", + 5, + 5, + 4, + 4, + 2, + 4, + "Top-tier KPIs determine what becomes managed.", + ), + GamePosition( + "G12", + "Equity and trust game", + "transactional-access without trust", + "benefits plus equity-function equilibrium", + 5, + 3, + 5, + 3, + 4, + 3, + "Access benefits do not replace relational and kaupapa Maori/Pacific/locality functions.", + ), + GamePosition( + "G13", + "Political economy game", + "institutional-defence equilibrium", + "access-architecture coalition", + 3, + 3, + 3, + 2, + 5, + 3, + "Reform framing determines coalition formation and resistance.", + ), + GamePosition( + "G14", + "Data observability game", + "hidden-unmet-need equilibrium", + "observable upstream-flow equilibrium", + 5, + 5, + 4, + 4, + 2, + 4, + "Unobserved upstream need remains less fundable than visible hospital pressure.", + ), ) -def normalise_weights(weights: Mapping[str,float]) -> dict[str,float]: + +def normalise_weights(weights: Mapping[str, float]) -> dict[str, float]: total = float(sum(weights.values())) - if total <= 0: raise ValueError('weights must sum to a positive value') - return {k:100*float(v)/total for k,v in weights.items()} + if total <= 0: + raise ValueError("weights must sum to a positive value") + return {k: 100 * float(v) / total for k, v in weights.items()} + def confidence_adjusted_criterion_score(raw_score: float, confidence: float) -> float: - if raw_score < -2 or raw_score > 2: raise ValueError('raw_score must be between -2 and +2') + if raw_score < -2 or raw_score > 2: + raise ValueError("raw_score must be between -2 and +2") confidence = max(0.0, min(1.0, confidence)) - return 50.0 + (raw_score/2.0)*50.0*confidence + return 50.0 + (raw_score / 2.0) * 50.0 * confidence + + +def criteria_frame(): + return pd.DataFrame([asdict(c) for c in CRITERIA]) + + +def policy_options_frame(): + return pd.DataFrame([asdict(o) for o in POLICY_OPTIONS]) + + +def policy_scores_frame(): + return pd.DataFrame( + [ + { + "option_id": o, + "criterion_id": c, + "raw_score_minus2_to_plus2": raw, + "confidence_0_to_1": conf, + "rationale": rat, + } + for o, c, raw, conf, rat in POLICY_SCORE_ROWS + ] + ) + -def criteria_frame(): return pd.DataFrame([asdict(c) for c in CRITERIA]) -def policy_options_frame(): return pd.DataFrame([asdict(o) for o in POLICY_OPTIONS]) -def policy_scores_frame(): return pd.DataFrame([{'option_id':o,'criterion_id':c,'raw_score_minus2_to_plus2':raw,'confidence_0_to_1':conf,'rationale':rat} for o,c,raw,conf,rat in POLICY_SCORE_ROWS]) def weight_sets_frame(): - rows=[] + rows = [] for ws in WEIGHT_SETS: - row={'weight_set_id':ws.weight_set_id,'weight_set':ws.weight_set,'description':ws.description}; row.update(normalise_weights(ws.weights)); rows.append(row) + row = {"weight_set_id": ws.weight_set_id, "weight_set": ws.weight_set, "description": ws.description} + row.update(normalise_weights(ws.weights)) + rows.append(row) return pd.DataFrame(rows) + def game_priority_score(g: GamePosition) -> float: - raw = 0.28*g.harm_if_unresolved + 0.24*g.hospital_growth_driver + 0.20*g.equity_relevance + 0.15*g.reform_tractability + 0.13*g.confidence - 0.12*g.reform_risk - return max(0.0, min(100.0, raw/5.0*100.0)) + raw = ( + 0.28 * g.harm_if_unresolved + + 0.24 * g.hospital_growth_driver + + 0.20 * g.equity_relevance + + 0.15 * g.reform_tractability + + 0.13 * g.confidence + - 0.12 * g.reform_risk + ) + return max(0.0, min(100.0, raw / 5.0 * 100.0)) + def game_positions_frame(): - rows=[] + rows = [] for g in GAME_POSITIONS: - row=asdict(g); row['priority_score_0_to_100']=round(game_priority_score(g),2); rows.append(row) + row = asdict(g) + row["priority_score_0_to_100"] = round(game_priority_score(g), 2) + rows.append(row) return pd.DataFrame(rows) -def option_score(option_id: str, weights: Mapping[str,float]) -> dict[str,object]: - weights=normalise_weights(weights) - score_rows=policy_scores_frame() - option_rows=score_rows[score_rows['option_id']==option_id] - options={o.option_id:o for o in POLICY_OPTIONS} - if option_id not in options: raise KeyError(option_id) - if len(option_rows)!=len(CRITERIA): raise ValueError(f'option {option_id} has incomplete scoring rows') - weighted_total=0.0; raw_weighted_total=0.0; contributions={} + +def option_score(option_id: str, weights: Mapping[str, float]) -> dict[str, object]: + weights = normalise_weights(weights) + score_rows = policy_scores_frame() + option_rows = score_rows[score_rows["option_id"] == option_id] + options = {o.option_id: o for o in POLICY_OPTIONS} + if option_id not in options: + raise KeyError(option_id) + if len(option_rows) != len(CRITERIA): + raise ValueError(f"option {option_id} has incomplete scoring rows") + weighted_total = 0.0 + raw_weighted_total = 0.0 + contributions = {} for _, row in option_rows.iterrows(): - cid=str(row['criterion_id']); raw=float(row['raw_score_minus2_to_plus2']); conf=float(row['confidence_0_to_1']) - adjusted=confidence_adjusted_criterion_score(raw, conf) - contribution=weights[cid]*adjusted/100.0 + cid = str(row["criterion_id"]) + raw = float(row["raw_score_minus2_to_plus2"]) + conf = float(row["confidence_0_to_1"]) + adjusted = confidence_adjusted_criterion_score(raw, conf) + contribution = weights[cid] * adjusted / 100.0 weighted_total += contribution - raw_weighted_total += weights[cid]*raw/100.0 - contributions[f'contribution_{cid}']=round(contribution,3) - option=options[option_id] - risk_penalty=2.0*option.implementation_risk+2.0*option.equity_risk_penalty+1.5*option.fiscal_gaming_penalty - risk_adjusted_score=max(0.0,min(100.0,weighted_total-risk_penalty)) - return {'option_id':option.option_id,'option':option.option,'mapped_scenario':option.mapped_scenario,'weighted_total_before_penalty':round(weighted_total,2),'risk_penalty':round(risk_penalty,2),'risk_adjusted_score':round(risk_adjusted_score,2),'raw_weighted_direction_minus2_to_plus2':round(raw_weighted_total,3),**contributions} + raw_weighted_total += weights[cid] * raw / 100.0 + contributions[f"contribution_{cid}"] = round(contribution, 3) + option = options[option_id] + risk_penalty = ( + 2.0 * option.implementation_risk + 2.0 * option.equity_risk_penalty + 1.5 * option.fiscal_gaming_penalty + ) + risk_adjusted_score = max(0.0, min(100.0, weighted_total - risk_penalty)) + return { + "option_id": option.option_id, + "option": option.option, + "mapped_scenario": option.mapped_scenario, + "weighted_total_before_penalty": round(weighted_total, 2), + "risk_penalty": round(risk_penalty, 2), + "risk_adjusted_score": round(risk_adjusted_score, 2), + "raw_weighted_direction_minus2_to_plus2": round(raw_weighted_total, 3), + **contributions, + } + def run_mcda(weight_set=None): if weight_set is None: - weights={c.criterion_id:c.default_weight for c in CRITERIA}; weight_set_id='W0'; weight_set_name='Balanced policy' + weights = {c.criterion_id: c.default_weight for c in CRITERIA} + weight_set_id = "W0" + weight_set_name = "Balanced policy" elif isinstance(weight_set, WeightSet): - weights=weight_set.weights; weight_set_id=weight_set.weight_set_id; weight_set_name=weight_set.weight_set + weights = weight_set.weights + weight_set_id = weight_set.weight_set_id + weight_set_name = weight_set.weight_set else: - weights=weight_set; weight_set_id='custom'; weight_set_name='Custom weights' - rows=[] + weights = weight_set + weight_set_id = "custom" + weight_set_name = "Custom weights" + rows = [] for option in POLICY_OPTIONS: - row=option_score(option.option_id, weights); row['weight_set_id']=weight_set_id; row['weight_set']=weight_set_name; rows.append(row) - df=pd.DataFrame(rows); df['rank']=df['risk_adjusted_score'].rank(ascending=False, method='min').astype(int) - return df.sort_values(['rank','risk_adjusted_score'], ascending=[True,False]).reset_index(drop=True) + row = option_score(option.option_id, weights) + row["weight_set_id"] = weight_set_id + row["weight_set"] = weight_set_name + rows.append(row) + df = pd.DataFrame(rows) + df["rank"] = df["risk_adjusted_score"].rank(ascending=False, method="min").astype(int) + return df.sort_values(["rank", "risk_adjusted_score"], ascending=[True, False]).reset_index(drop=True) + + +def run_all_weight_sets(): + return pd.concat([run_mcda(ws) for ws in WEIGHT_SETS], ignore_index=True) + -def run_all_weight_sets(): return pd.concat([run_mcda(ws) for ws in WEIGHT_SETS], ignore_index=True) def score_template_rows(): - rows=[] + rows = [] for opt in POLICY_OPTIONS: for c in CRITERIA: - rows.append({'option_id':opt.option_id,'option':opt.option,'criterion_id':c.criterion_id,'criterion':c.criterion,'raw_score_minus2_to_plus2':'','confidence_0_to_1':'','rationale':''}) + rows.append( + { + "option_id": opt.option_id, + "option": opt.option, + "criterion_id": c.criterion_id, + "criterion": c.criterion, + "raw_score_minus2_to_plus2": "", + "confidence_0_to_1": "", + "rationale": "", + } + ) return pd.DataFrame(rows) + + def game_position_template_rows(): - df=game_positions_frame().copy() - for col in ['harm_if_unresolved','hospital_growth_driver','equity_relevance','reform_tractability','reform_risk','confidence']: - df[col]='' - df['priority_score_0_to_100']='' + df = game_positions_frame().copy() + for col in [ + "harm_if_unresolved", + "hospital_growth_driver", + "equity_relevance", + "reform_tractability", + "reform_risk", + "confidence", + ]: + df[col] = "" + df["priority_score_0_to_100"] = "" return df -if __name__ == '__main__': +if __name__ == "__main__": print(run_mcda().to_string(index=False)) diff --git a/models/primarycare_model/nash_opt.py b/models/primarycare_model/nash_opt.py index a373006..8f5c539 100644 --- a/models/primarycare_model/nash_opt.py +++ b/models/primarycare_model/nash_opt.py @@ -2,6 +2,7 @@ Nash equilibrium optimisation engine for funding model games. Best-response dynamics tracing for 2-player funding model choices. """ + from __future__ import annotations from dataclasses import dataclass, field @@ -13,39 +14,42 @@ @dataclass class PayoffMatrix: """2x2 payoff matrix for two players (funding model game).""" + row_label: str = "Player 0" col_label: str = "Player 1" strategy_labels: tuple[str, str] = ("Capitation", "FFS") - player0: np.ndarray = field(default_factory=lambda: np.array([[0.6, 0.3],[0.4, 0.5]])) - player1: np.ndarray = field(default_factory=lambda: np.array([[0.6, 0.4],[0.3, 0.5]])) + player0: np.ndarray = field(default_factory=lambda: np.array([[0.6, 0.3], [0.4, 0.5]])) + player1: np.ndarray = field(default_factory=lambda: np.array([[0.6, 0.4], [0.3, 0.5]])) @classmethod def cooperative(cls) -> PayoffMatrix: return cls( - player0=np.array([[0.8, 0.2],[0.2, 0.6]]), - player1=np.array([[0.8, 0.2],[0.2, 0.6]]), + player0=np.array([[0.8, 0.2], [0.2, 0.6]]), + player1=np.array([[0.8, 0.2], [0.2, 0.6]]), ) @classmethod def competitive(cls) -> PayoffMatrix: return cls( - player0=np.array([[0.5, 0.8],[0.3, 0.4]]), - player1=np.array([[0.4, 0.3],[0.8, 0.5]]), + player0=np.array([[0.5, 0.8], [0.3, 0.4]]), + player1=np.array([[0.4, 0.3], [0.8, 0.5]]), ) @classmethod def clinical_utility(cls) -> PayoffMatrix: return cls( - row_label="Ministry", col_label="Providers", + row_label="Ministry", + col_label="Providers", strategy_labels=("Capitation", "FFS"), - player0=np.array([[0.7, 0.3],[0.4, 0.6]]), - player1=np.array([[0.6, 0.5],[0.3, 0.7]]), + player0=np.array([[0.7, 0.3], [0.4, 0.6]]), + player1=np.array([[0.6, 0.5], [0.3, 0.7]]), ) @dataclass class NashTrace: """Records best-response dynamics trace.""" + strategies: list[np.ndarray] = field(default_factory=list) payoffs: list[tuple[float, float]] = field(default_factory=list) converged: bool = False @@ -54,16 +58,18 @@ class NashTrace: def to_dataframe(self) -> pd.DataFrame: rows = [] for i, (s, (p0, p1)) in enumerate(zip(self.strategies, self.payoffs)): - rows.append({ - "iteration": i, - "p0_strategy_capitation": float(s[0]), - "p0_strategy_ffs": float(1.0 - s[0]), - "p1_strategy_capitation": float(s[1]), - "p1_strategy_ffs": float(1.0 - s[1]), - "p0_payoff": float(p0), - "p1_payoff": float(p1), - "total_welfare": float(p0 + p1), - }) + rows.append( + { + "iteration": i, + "p0_strategy_capitation": float(s[0]), + "p0_strategy_ffs": float(1.0 - s[0]), + "p1_strategy_capitation": float(s[1]), + "p1_strategy_ffs": float(1.0 - s[1]), + "p0_payoff": float(p0), + "p1_payoff": float(p1), + "total_welfare": float(p0 + p1), + } + ) return pd.DataFrame(rows) @property @@ -108,10 +114,12 @@ def nash_best_response_dynamics( br0 = best_response(payoff_matrix.player0, p1_strat) br1 = best_response(payoff_matrix.player1, p0_strat) - s_new = np.array([ - (1 - learning_rate) * s[0] + learning_rate * br0[0], - (1 - learning_rate) * s[1] + learning_rate * br1[0], - ]) + s_new = np.array( + [ + (1 - learning_rate) * s[0] + learning_rate * br0[0], + (1 - learning_rate) * s[1] + learning_rate * br1[0], + ] + ) diff = np.max(np.abs(s_new - s)) s = s_new @@ -122,10 +130,12 @@ def nash_best_response_dynamics( p0_strat = np.array([s[0], 1.0 - s[0]]) p1_strat = np.array([s[1], 1.0 - s[1]]) trace.strategies.append(s.copy()) - trace.payoffs.append(( - float(p0_strat @ payoff_matrix.player0 @ p1_strat), - float(p1_strat @ payoff_matrix.player1 @ p0_strat), - )) + trace.payoffs.append( + ( + float(p0_strat @ payoff_matrix.player0 @ p1_strat), + float(p1_strat @ payoff_matrix.player1 @ p0_strat), + ) + ) break trace.num_iterations = min(i + 1, max_iterations) @@ -137,22 +147,28 @@ def compute_payoff_landscape(payoff_matrix: PayoffMatrix, grid: int = 40) -> pd. xs = np.linspace(0, 1, grid) ys = np.linspace(0, 1, grid) X, Y = np.meshgrid(xs, ys) - Z0 = np.zeros_like(X); Z1 = np.zeros_like(X) + Z0 = np.zeros_like(X) + Z1 = np.zeros_like(X) for i in range(grid): for j in range(grid): - p0_s = np.array([X[i,j], 1.0-X[i,j]]) - p1_s = np.array([Y[i,j], 1.0-Y[i,j]]) - Z0[i,j] = float(p0_s @ payoff_matrix.player0 @ p1_s) - Z1[i,j] = float(p1_s @ payoff_matrix.player1 @ p0_s) - return pd.DataFrame({ - "p0_capitation": X.ravel(), "p1_capitation": Y.ravel(), - "p0_payoff": Z0.ravel(), "p1_payoff": Z1.ravel(), - "total_welfare": (Z0+Z1).ravel(), - }) - - -def run_nash_with_multiple_starts(payoff_matrix: PayoffMatrix, num_starts: int = 5, - max_iterations: int = 100) -> list[NashTrace]: + p0_s = np.array([X[i, j], 1.0 - X[i, j]]) + p1_s = np.array([Y[i, j], 1.0 - Y[i, j]]) + Z0[i, j] = float(p0_s @ payoff_matrix.player0 @ p1_s) + Z1[i, j] = float(p1_s @ payoff_matrix.player1 @ p0_s) + return pd.DataFrame( + { + "p0_capitation": X.ravel(), + "p1_capitation": Y.ravel(), + "p0_payoff": Z0.ravel(), + "p1_payoff": Z1.ravel(), + "total_welfare": (Z0 + Z1).ravel(), + } + ) + + +def run_nash_with_multiple_starts( + payoff_matrix: PayoffMatrix, num_starts: int = 5, max_iterations: int = 100 +) -> list[NashTrace]: """Run best-response dynamics from multiple starting points.""" traces = [] rng = np.random.default_rng(42) diff --git a/models/primarycare_model/pages/2_kairos_abm_playback.py b/models/primarycare_model/pages/2_kairos_abm_playback.py index 54542b7..5eba5e2 100644 --- a/models/primarycare_model/pages/2_kairos_abm_playback.py +++ b/models/primarycare_model/pages/2_kairos_abm_playback.py @@ -2,6 +2,7 @@ Streamlit: kairos ABM Playback - Real-time practice status grid/network viz. Shows patient flow, provider queues, and funding model status transitions. """ + import time import matplotlib.pyplot as plt @@ -58,7 +59,6 @@ def render_page() -> None: st.session_state.abm_month = 0 st.session_state.abm_running = False - if st.session_state.abm_running and st.session_state.abm_month < num_months: sim = ABMSimulation(params) result = sim.run() @@ -78,17 +78,29 @@ def render_page() -> None: fig1 = go.Figure() for col in ["total_demand_contacts", "resolved_contacts", "unresolved_contacts"]: if col in history_df.columns: - fig1.add_trace(go.Scatter(x=history_df["month"], y=history_df[col], - mode="lines+markers", name=col.replace("_"," ").title())) - fig1.update_layout(height=300, margin=dict(l=20,r=20,t=20,b=20)) + fig1.add_trace( + go.Scatter( + x=history_df["month"], + y=history_df[col], + mode="lines+markers", + name=col.replace("_", " ").title(), + ) + ) + fig1.update_layout(height=300, margin=dict(l=20, r=20, t=20, b=20)) flow_chart.plotly_chart(fig1, use_container_width=True, key=f"f{month_idx}") - avail = [c for c in ["access_rate","provider_utilisation","equity_gap_index"] if c in history_df.columns] + avail = [c for c in ["access_rate", "provider_utilisation", "equity_gap_index"] if c in history_df.columns] if avail: fig2 = go.Figure() for c in avail: - fig2.add_trace(go.Bar(x=[c.replace("_"," ").title()], y=[history_df[c].iloc[-1]], name=c.replace("_"," ").title())) - fig2.update_layout(height=250, margin=dict(l=20,r=20,t=20,b=20)) + fig2.add_trace( + go.Bar( + x=[c.replace("_", " ").title()], + y=[history_df[c].iloc[-1]], + name=c.replace("_", " ").title(), + ) + ) + fig2.update_layout(height=250, margin=dict(l=20, r=20, t=20, b=20)) prov_chart.plotly_chart(fig2, use_container_width=True, key=f"p{month_idx}") G = nx.Graph() @@ -102,23 +114,41 @@ def render_page() -> None: f"C{pat.patient_id}", f"P{sim.providers[i % len(sim.providers)].provider_id}", ) - fig3, ax3 = plt.subplots(figsize=(4,3)) - colors = ["red" if n[0]=="P" else "lightblue" for n in G.nodes()] - nx.draw(G, pos=nx.spring_layout(G, seed=42, k=0.5), ax=ax3, node_color=colors, node_size=50, with_labels=False) + fig3, ax3 = plt.subplots(figsize=(4, 3)) + colors = ["red" if n[0] == "P" else "lightblue" for n in G.nodes()] + nx.draw( + G, pos=nx.spring_layout(G, seed=42, k=0.5), ax=ax3, node_color=colors, node_size=50, with_labels=False + ) net_placeholder.pyplot(fig3) plt.close(fig3) - td = pd.DataFrame({"Model":["Capitation","FFS","Hybrid"],"Status":[1.0 if funding_model=="capitation" else 0.3, 1.0 if funding_model=="ffs" else 0.3, 1.0 if funding_model=="hybrid" else 0.3]}) - fig4 = px.bar(td, x="Model", y="Status", range_y=[0,1.2], color="Model", title=f"Active: {funding_model.title()}") - fig4.update_layout(height=200, showlegend=False, margin=dict(l=20,r=20,t=30,b=20)) + td = pd.DataFrame( + { + "Model": ["Capitation", "FFS", "Hybrid"], + "Status": [ + 1.0 if funding_model == "capitation" else 0.3, + 1.0 if funding_model == "ffs" else 0.3, + 1.0 if funding_model == "hybrid" else 0.3, + ], + } + ) + fig4 = px.bar( + td, x="Model", y="Status", range_y=[0, 1.2], color="Model", title=f"Active: {funding_model.title()}" + ) + fig4.update_layout(height=200, showlegend=False, margin=dict(l=20, r=20, t=30, b=20)) trans_placeholder.plotly_chart(fig4, use_container_width=True, key=f"t{month_idx}") - metrics_data = {"Access Rate":f"{row.get('access_rate',0):.3f}", "Hospital Pressure":f"{row.get('hospital_pressure_index',0):.3f}", "Equity Gap":f"{row.get('equity_gap_index',0):.3f}", "Fiscal Risk":f"{row.get('fiscal_risk_index',0):.3f}"} - metrics_placeholder.markdown("\n".join([f"**{k}:** {v}" for k,v in metrics_data.items()])) + metrics_data = { + "Access Rate": f"{row.get('access_rate', 0):.3f}", + "Hospital Pressure": f"{row.get('hospital_pressure_index', 0):.3f}", + "Equity Gap": f"{row.get('equity_gap_index', 0):.3f}", + "Fiscal Risk": f"{row.get('fiscal_risk_index', 0):.3f}", + } + metrics_placeholder.markdown("\n".join([f"**{k}:** {v}" for k, v in metrics_data.items()])) - progress_bar.progress((month_idx+1)/num_months) - status_text.text(f"Month {month_idx+1}/{num_months}") - time.sleep(speed/1000.0) + progress_bar.progress((month_idx + 1) / num_months) + status_text.text(f"Month {month_idx + 1}/{num_months}") + time.sleep(speed / 1000.0) st.session_state.abm_running = False progress_bar.empty() @@ -127,5 +157,6 @@ def render_page() -> None: else: st.info("Press **Play** to start") + if __name__ == "__main__": render_page() diff --git a/models/primarycare_model/pages/3_bass_diffusion.py b/models/primarycare_model/pages/3_bass_diffusion.py index a405512..24040eb 100644 --- a/models/primarycare_model/pages/3_bass_diffusion.py +++ b/models/primarycare_model/pages/3_bass_diffusion.py @@ -2,6 +2,7 @@ Streamlit page: Bass Diffusion Adoption Animation. Animated choropleth/line charts of adoption trajectories over 10-15 years. """ + import time import plotly.express as px @@ -39,7 +40,6 @@ def render_page() -> None: play = st.button(":arrow_forward: Animate", type="primary") reset = st.button(":stop_button: Reset") - params = BassDiffusionParams(p=p, q=q, M=M, T=T, num_regions=num_regions) result = simulate_bass(params) df = result.time_series @@ -63,37 +63,57 @@ def render_page() -> None: if play or "diff_playing" in st.session_state: st.session_state.diff_playing = True bar = st.progress(0) - for year in range(1, T+1): + for year in range(1, T + 1): sub = df[df["year"] <= year] fig1 = go.Figure() - fig1.add_trace(go.Scatter(x=sub["year"], y=sub["adoption_rate"], - mode="lines+markers", name="Adoption Rate", - line=dict(width=3, color="#147a4f"))) - fig1.add_trace(go.Scatter(x=sub["year"], y=sub["adopters"], - mode="lines+markers", name="Cumulative", - line=dict(width=2, color="#2E86AB"), yaxis="y2")) - fig1.update_layout(height=350, xaxis=dict(range=[0,T]), - yaxis=dict(range=[0,1.05], title="Rate", tickformat=".0%"), - yaxis2=dict(range=[0,M*1.05], title="Adopters", overlaying="y", side="right")) + fig1.add_trace( + go.Scatter( + x=sub["year"], + y=sub["adoption_rate"], + mode="lines+markers", + name="Adoption Rate", + line=dict(width=3, color="#147a4f"), + ) + ) + fig1.add_trace( + go.Scatter( + x=sub["year"], + y=sub["adopters"], + mode="lines+markers", + name="Cumulative", + line=dict(width=2, color="#2E86AB"), + yaxis="y2", + ) + ) + fig1.update_layout( + height=350, + xaxis=dict(range=[0, T]), + yaxis=dict(range=[0, 1.05], title="Rate", tickformat=".0%"), + yaxis2=dict(range=[0, M * 1.05], title="Adopters", overlaying="y", side="right"), + ) main_chart.plotly_chart(fig1, use_container_width=True, key=f"m{year}") - fig2 = px.bar(sub, x="year", y="new_adopters", - color_discrete_sequence=["#147a4f"]) + fig2 = px.bar(sub, x="year", y="new_adopters", color_discrete_sequence=["#147a4f"]) fig2.update_layout(height=250) new_chart.plotly_chart(fig2, use_container_width=True, key=f"n{year}") if result.region_time_series is not None: - rs = result.region_time_series[result.region_time_series["year"]==year] + rs = result.region_time_series[result.region_time_series["year"] == year] if not rs.empty: - fig3 = px.bar(rs.sort_values("adoption_rate"), - x="region", y="adoption_rate", color="adoption_rate", - color_continuous_scale="Greens", range_color=[0,1], - title=f"Year {year}") + fig3 = px.bar( + rs.sort_values("adoption_rate"), + x="region", + y="adoption_rate", + color="adoption_rate", + color_continuous_scale="Greens", + range_color=[0, 1], + title=f"Year {year}", + ) fig3.update_layout(height=350, xaxis_tickangle=-45) region_chart.plotly_chart(fig3, use_container_width=True, key=f"r{year}") - bar.progress(year/T) - time.sleep(speed*0.3) + bar.progress(year / T) + time.sleep(speed * 0.3) st.session_state.diff_playing = False bar.empty() st.success("Complete!") @@ -102,5 +122,6 @@ def render_page() -> None: st.session_state.diff_playing = False st.rerun() + if __name__ == "__main__": render_page() diff --git a/models/primarycare_model/pages/4_nash_convergence.py b/models/primarycare_model/pages/4_nash_convergence.py index 4fcdb6c..b0bcb8b 100644 --- a/models/primarycare_model/pages/4_nash_convergence.py +++ b/models/primarycare_model/pages/4_nash_convergence.py @@ -2,6 +2,7 @@ Streamlit page: Nash Equilibrium Convergence Trace. Animated gradient path of clinical utility optimisation. """ + import time import numpy as np @@ -35,7 +36,6 @@ def render_page() -> None: step_once = st.button(":footprints: Step Single") reset = st.button(":stop_button: Reset") - init_strat = np.array([init_cap_p0, init_cap_p1]) # Pre-compute landscape @@ -88,33 +88,57 @@ def render_page() -> None: st.session_state.nash_step = total_steps for si in range(steps_to_show): - sub = trace_df.iloc[:si+1] + sub = trace_df.iloc[: si + 1] # Contour + path fig1 = go.Figure() - z_pivot = landscape_df.pivot_table( - index="p1_capitation", columns="p0_capitation", values="total_welfare" + z_pivot = landscape_df.pivot_table(index="p1_capitation", columns="p0_capitation", values="total_welfare") + fig1.add_trace( + go.Contour( + z=z_pivot.values, + x=z_pivot.columns, + y=z_pivot.index, + colorscale="Viridis", + name="Welfare", + contours=dict(showlabels=True), + hovertemplate="P0: %{x:.2f}
P1: %{y:.2f}
Welfare: %{z:.2f}", + ) + ) + fig1.add_trace( + go.Scatter( + x=sub["p0_strategy_capitation"], + y=sub["p1_strategy_capitation"], + mode="lines+markers", + name="Dynamics Path", + line=dict(color="red", width=2), + marker=dict(size=5), + ) + ) + fig1.add_trace( + go.Scatter( + x=[sub["p0_strategy_capitation"].iloc[-1]], + y=[sub["p1_strategy_capitation"].iloc[-1]], + mode="markers", + name="Current", + marker=dict(color="red", size=12, symbol="star"), + ) + ) + fig1.update_layout( + height=400, + xaxis=dict(range=[0, 1], title="P0: Capitation Weight"), + yaxis=dict(range=[0, 1], title="P1: Capitation Weight"), ) - fig1.add_trace(go.Contour( - z=z_pivot.values, x=z_pivot.columns, y=z_pivot.index, - colorscale="Viridis", name="Welfare", contours=dict(showlabels=True), - hovertemplate="P0: %{x:.2f}
P1: %{y:.2f}
Welfare: %{z:.2f}" - )) - fig1.add_trace(go.Scatter(x=sub["p0_strategy_capitation"], y=sub["p1_strategy_capitation"], - mode="lines+markers", name="Dynamics Path", - line=dict(color="red", width=2), marker=dict(size=5))) - fig1.add_trace(go.Scatter(x=[sub["p0_strategy_capitation"].iloc[-1]], y=[sub["p1_strategy_capitation"].iloc[-1]], - mode="markers", name="Current", marker=dict(color="red", size=12, symbol="star"))) - fig1.update_layout(height=400, xaxis=dict(range=[0,1], title="P0: Capitation Weight"), - yaxis=dict(range=[0,1], title="P1: Capitation Weight")) path_chart.plotly_chart(fig1, use_container_width=True, key=f"path_{si}") # Convergence trace fig2 = go.Figure() for col in ["p0_payoff", "p1_payoff", "total_welfare"]: if col in sub.columns: - fig2.add_trace(go.Scatter(x=sub["iteration"], y=sub[col], - mode="lines+markers", name=col.replace("_"," ").title())) + fig2.add_trace( + go.Scatter( + x=sub["iteration"], y=sub[col], mode="lines+markers", name=col.replace("_", " ").title() + ) + ) fig2.update_layout(height=300, xaxis_title="Iteration", yaxis_title="Payoff") trace_chart.plotly_chart(fig2, use_container_width=True, key=f"trace_{si}") @@ -123,13 +147,12 @@ def render_page() -> None: for col in ["p0_strategy_capitation", "p1_strategy_capitation"]: if col in sub.columns: label = "P0 Capitation" if "p0" in col else "P1 Capitation" - fig3.add_trace(go.Scatter(x=sub["iteration"], y=sub[col], - mode="lines+markers", name=label)) + fig3.add_trace(go.Scatter(x=sub["iteration"], y=sub[col], mode="lines+markers", name=label)) fig3.update_layout(height=300, xaxis_title="Iteration", yaxis_title="Strategy Weight") strat_chart.plotly_chart(fig3, use_container_width=True, key=f"strat_{si}") if bar: - bar.progress((si+1)/total_steps) + bar.progress((si + 1) / total_steps) time.sleep(0.05) if bar: @@ -137,7 +160,9 @@ def render_page() -> None: nash_box.markdown(f"**Converged:** {trace.converged} ") nash_box.markdown(f"**Iterations:** {trace.num_iterations} ") if trace.final_strategies is not None: - nash_box.markdown(f"**Equilibrium:** P0={trace.final_strategies[0]:.3f}, P1={trace.final_strategies[1]:.3f} ") + nash_box.markdown( + f"**Equilibrium:** P0={trace.final_strategies[0]:.3f}, P1={trace.final_strategies[1]:.3f} " + ) if reset: st.session_state.pop("nash_trace", None) @@ -145,5 +170,6 @@ def render_page() -> None: st.session_state.pop("nash_step", None) st.rerun() + if __name__ == "__main__": render_page() diff --git a/models/primarycare_model/pages/5_monte_carlo_histogram.py b/models/primarycare_model/pages/5_monte_carlo_histogram.py index c2293c4..4f880b8 100644 --- a/models/primarycare_model/pages/5_monte_carlo_histogram.py +++ b/models/primarycare_model/pages/5_monte_carlo_histogram.py @@ -2,6 +2,7 @@ Streamlit page: Rolling Monte Carlo Histogram. Shows uncertainty limits narrowing dynamically as batched iterations run. """ + import time import numpy as np @@ -22,14 +23,19 @@ def render_page() -> None: num_batches = st.slider("Number of batches", 2, 50, 10, 1) pert_std = st.slider("Perturbation width", 0.01, 0.20, 0.08, 0.01, format="%.2f") seed = st.number_input("Random seed", 1, 999999, 42, 1) - target_metric = st.selectbox("Target metric", [ - "access_rate", "hospital_pressure_index", - "equity_gap_index", "fiscal_risk_index", - "unmet_need_index", "provider_utilisation", - ]) + target_metric = st.selectbox( + "Target metric", + [ + "access_rate", + "hospital_pressure_index", + "equity_gap_index", + "fiscal_risk_index", + "unmet_need_index", + "provider_utilisation", + ], + ) run_btn = st.button(":arrow_forward: Run MC Sweep", type="primary") - col1, col2 = st.columns([2, 1]) with col1: st.subheader("Rolling Histogram") @@ -43,15 +49,26 @@ def render_page() -> None: dist_chart = st.empty() if run_btn: - config = MCConfig(num_iterations=num_iterations, num_batches=num_batches, - perturbation_std=pert_std, seed=seed, - target_metrics=("access_rate","hospital_pressure_index","equity_gap_index","fiscal_risk_index","unmet_need_index","provider_utilisation")) + config = MCConfig( + num_iterations=num_iterations, + num_batches=num_batches, + perturbation_std=pert_std, + seed=seed, + target_metrics=( + "access_rate", + "hospital_pressure_index", + "equity_gap_index", + "fiscal_risk_index", + "unmet_need_index", + "provider_utilisation", + ), + ) all_metrics = {m: [] for m in config.target_metrics} bar = st.progress(0) status = st.empty() def cb(bi, ba): - for m,v in ba.items(): + for m, v in ba.items(): if m in all_metrics: all_metrics[m].append(v) @@ -59,41 +76,60 @@ def cb(bi, ba): result = run_monte_carlo(config=config, progress_callback=cb) for bi in range(num_batches): - current = (bi+1)*(num_iterations//num_batches) + current = (bi + 1) * (num_iterations // num_batches) vals = result.metrics.get(target_metric, np.array([]))[:current] if len(vals) > 0: fig1 = go.Figure() - fig1.add_trace(go.Histogram(x=vals, nbinsx=30, - name=target_metric.replace("_"," ").title(), marker_color="#147a4f")) - for p,c in [(np.percentile(vals,5),"red"), (np.percentile(vals,50),"blue"), (np.percentile(vals,95),"red")]: + fig1.add_trace( + go.Histogram( + x=vals, nbinsx=30, name=target_metric.replace("_", " ").title(), marker_color="#147a4f" + ) + ) + for p, c in [ + (np.percentile(vals, 5), "red"), + (np.percentile(vals, 50), "blue"), + (np.percentile(vals, 95), "red"), + ]: fig1.add_vline(x=p, line=dict(color=c, width=2)) fig1.update_layout(height=350, title=f"{current} iters") hist_chart.plotly_chart(fig1, use_container_width=True, key=f"h{bi}") - means, lo, hi = result.rolling_ci(target_metric, window=max(10,num_iterations//num_batches)) + means, lo, hi = result.rolling_ci(target_metric, window=max(10, num_iterations // num_batches)) if len(means) > 0: fig2 = go.Figure() fig2.add_trace(go.Scatter(x=list(range(len(means))), y=means, mode="lines", name="Mean")) - fig2.add_trace(go.Scatter(x=list(range(len(means))), y=lo, mode="lines", name="CI", line=dict(dash="dash"))) - fig2.add_trace(go.Scatter(x=list(range(len(means))), y=hi, mode="lines", fill="tonexty", - line=dict(dash="dash"), fillcolor="rgba(255,0,0,0.1)")) + fig2.add_trace( + go.Scatter(x=list(range(len(means))), y=lo, mode="lines", name="CI", line=dict(dash="dash")) + ) + fig2.add_trace( + go.Scatter( + x=list(range(len(means))), + y=hi, + mode="lines", + fill="tonexty", + line=dict(dash="dash"), + fillcolor="rgba(255,0,0,0.1)", + ) + ) fig2.update_layout(height=300, title="95% CI Convergence") ci_chart.plotly_chart(fig2, use_container_width=True, key=f"ci{bi}") stats_box.markdown(f"**Mean:** {np.mean(vals):.4f} ") stats_box.markdown(f"**Std:** {np.std(vals):.4f} ") - stats_box.markdown(f"**P5/P50/P95:** {np.percentile(vals,5):.4f}/{np.percentile(vals,50):.4f}/{np.percentile(vals,95):.4f} ") + stats_box.markdown( + f"**P5/P50/P95:** {np.percentile(vals, 5):.4f}/{np.percentile(vals, 50):.4f}/{np.percentile(vals, 95):.4f} " + ) fig3 = go.Figure() for m in list(config.target_metrics)[:4]: mv = result.metrics.get(m, np.array([]))[:current] if len(mv) > 0: - fig3.add_trace(go.Box(y=mv, name=m.replace("_"," ").title())) + fig3.add_trace(go.Box(y=mv, name=m.replace("_", " ").title())) fig3.update_layout(height=300) dist_chart.plotly_chart(fig3, use_container_width=True, key=f"d{bi}") - bar.progress((bi+1)/num_batches) - status.text(f"Batch {bi+1}/{num_batches}") + bar.progress((bi + 1) / num_batches) + status.text(f"Batch {bi + 1}/{num_batches}") time.sleep(0.1) bar.empty() @@ -103,5 +139,6 @@ def cb(bi, ba): else: st.info("Configure and press **Run MC Sweep**") + if __name__ == "__main__": render_page() diff --git a/models/primarycare_model/privacy.py b/models/primarycare_model/privacy.py index 7062623..d6199b6 100644 --- a/models/primarycare_model/privacy.py +++ b/models/primarycare_model/privacy.py @@ -1,4 +1,5 @@ """Privacy module.""" + from __future__ import annotations import numpy as np diff --git a/models/primarycare_model/runtime_lab.py b/models/primarycare_model/runtime_lab.py index a694d72..34e3d29 100644 --- a/models/primarycare_model/runtime_lab.py +++ b/models/primarycare_model/runtime_lab.py @@ -196,7 +196,12 @@ def calculate_indices(scenario: RuntimeScenario) -> dict[str, float]: fiscal_risk = clamp( 100 * strategic_response( - 0.24 * activity + 0.20 * gaming_risk / 100 + 0.16 * complexity + 0.12 * (1 - budget) - 0.18 * governance - 0.16 * hospital_deflection / 100, + 0.24 * activity + + 0.20 * gaming_risk / 100 + + 0.16 * complexity + + 0.12 * (1 - budget) + - 0.18 * governance + - 0.16 * hospital_deflection / 100, threshold=0.20, steepness=6.0, ) @@ -204,7 +209,12 @@ def calculate_indices(scenario: RuntimeScenario) -> dict[str, float]: hospital_pressure = clamp( 100 * strategic_response( - 0.34 * hospital_salience + 0.26 * (1 - hospital_deflection / 100) + 0.16 * complexity + 0.14 * budget - 0.20 * access / 100 - 0.12 * urgent, + 0.34 * hospital_salience + + 0.26 * (1 - hospital_deflection / 100) + + 0.16 * complexity + + 0.14 * budget + - 0.20 * access / 100 + - 0.12 * urgent, threshold=0.42, steepness=6.0, ) @@ -231,13 +241,21 @@ def calculate_indices(scenario: RuntimeScenario) -> dict[str, float]: } -def run_reference_calculation(months: int = MAX_MONTHS, scenarios: Iterable[RuntimeScenario] = SCENARIOS) -> pd.DataFrame: +def run_reference_calculation( + months: int = MAX_MONTHS, scenarios: Iterable[RuntimeScenario] = SCENARIOS +) -> pd.DataFrame: months = int(min(max(12, months), MAX_MONTHS)) rows: list[dict[str, object]] = [] for scenario in scenarios: idx = calculate_indices(scenario) last12_primary = 8.0 + 0.56 * idx["access_score"] + 0.10 * idx["supply_generation_score"] - unmet = max(0.0, 110 - 1.30 * idx["access_score"] - 0.45 * idx["hospital_deflection_score"] + 0.18 * scenario.budget_tightness) + unmet = max( + 0.0, + 110 + - 1.30 * idx["access_score"] + - 0.45 * idx["hospital_deflection_score"] + + 0.18 * scenario.budget_tightness, + ) ed_events = 48 + 0.36 * unmet + 0.18 * idx["hospital_pressure_score"] admissions = 9 + 0.18 * ed_events + 0.05 * scenario.complexity ambulance = 16 + 0.22 * unmet + 0.16 * scenario.hospital_salience - 0.18 * scenario.urgent_ambulance @@ -252,7 +270,9 @@ def run_reference_calculation(months: int = MAX_MONTHS, scenarios: Iterable[Runt "mean_last12_admissions_per_100k": round(admissions, 2), "mean_last12_ambulance_conveyances_per_100k": round(max(0.0, ambulance), 2), "mean_last12_hospital_pressure_index": round(idx["hospital_pressure_score"] / 100.0, 3), - "mean_last12_public_cost_index": round(1.2 + 0.012 * idx["fiscal_risk_score"] + 0.009 * idx["hospital_pressure_score"] + months / 240.0, 2), + "mean_last12_public_cost_index": round( + 1.2 + 0.012 * idx["fiscal_risk_score"] + 0.009 * idx["hospital_pressure_score"] + months / 240.0, 2 + ), "calculation_status": CLAIM_LABEL, } rows.append(row) @@ -265,12 +285,36 @@ def calculation_trace(scenario_id: str) -> pd.DataFrame: scenario = get_runtime_scenario(scenario_id) idx = calculate_indices(scenario) rows = [ - ("Supply generation", "threshold(activity + scope + urgent + place - budget) plus saturating capitation/place base", idx["supply_generation_score"]), - ("Access", "threshold(supply + urgent + equity + place + data - nonlinear co-payment burden)", idx["access_score"]), - ("Hospital deflection", "threshold(access + urgent + supply + data + place - complexity)", idx["hospital_deflection_score"]), - ("Gaming risk", "threshold(activity + scope + complexity - governance - data - place)", idx["gaming_risk_score"]), - ("Fiscal risk", "threshold(activity + gaming + complexity + exposure - governance - deflection)", idx["fiscal_risk_score"]), - ("Hybrid viability", "weighted supply, access, equity, governance, deflection and inverted risks", idx["hybrid_viability_score"]), + ( + "Supply generation", + "threshold(activity + scope + urgent + place - budget) plus saturating capitation/place base", + idx["supply_generation_score"], + ), + ( + "Access", + "threshold(supply + urgent + equity + place + data - nonlinear co-payment burden)", + idx["access_score"], + ), + ( + "Hospital deflection", + "threshold(access + urgent + supply + data + place - complexity)", + idx["hospital_deflection_score"], + ), + ( + "Gaming risk", + "threshold(activity + scope + complexity - governance - data - place)", + idx["gaming_risk_score"], + ), + ( + "Fiscal risk", + "threshold(activity + gaming + complexity + exposure - governance - deflection)", + idx["fiscal_risk_score"], + ), + ( + "Hybrid viability", + "weighted supply, access, equity, governance, deflection and inverted risks", + idx["hybrid_viability_score"], + ), ] return pd.DataFrame(rows, columns=["calculation", "formula_sketch", "index_value"]) @@ -328,11 +372,19 @@ def run_stock_flow_trace(scenario_id: str, months: int = 36) -> pd.DataFrame: for month in range(1, months + 1): seasonal = 1.0 + 0.05 * np.sin(2 * np.pi * month / 12.0) need = 55 * seasonal + 0.18 * unmet + 0.15 * scenario.complexity - capacity = max(1.0, capacity + 0.06 * idx["supply_generation_score"] + 0.04 * scenario.place_accountability - 0.05 * scenario.budget_tightness) + capacity = max( + 1.0, + capacity + + 0.06 * idx["supply_generation_score"] + + 0.04 * scenario.place_accountability + - 0.05 * scenario.budget_tightness, + ) served = min(need + 0.20 * unmet, capacity * (0.72 + idx["access_score"] / 250.0)) ambulance_resolved = min(need * (0.08 + scenario.urgent_ambulance / 400.0), 12 + capacity / 10.0) unmet = max(0.0, 0.70 * unmet + need - served - ambulance_resolved) - hospital_pressure = clamp(35 + 0.42 * unmet + 0.28 * scenario.hospital_salience - 0.32 * idx["hospital_deflection_score"]) + hospital_pressure = clamp( + 35 + 0.42 * unmet + 0.28 * scenario.hospital_salience - 0.32 * idx["hospital_deflection_score"] + ) fiscal_pressure = clamp(20 + 0.28 * idx["fiscal_risk_score"] + 0.12 * unmet + 0.08 * served) rows.append( { @@ -364,8 +416,17 @@ def run_agent_lens( rng = np.random.default_rng(seed) high_need = rng.beta(2.2, 3.4, population_size) rural = rng.random(population_size) < (0.08 + scenario.complexity / 260.0) - barrier = np.clip(0.45 * high_need + 0.30 * rural.astype(float) + scenario.copayment_burden / 180.0 - scenario.equity_protection / 240.0, 0, 1) - access_probability = np.clip(idx["access_score"] / 100.0 - 0.35 * barrier + scenario.place_accountability / 350.0, 0.05, 0.95) + barrier = np.clip( + 0.45 * high_need + + 0.30 * rural.astype(float) + + scenario.copayment_burden / 180.0 + - scenario.equity_protection / 240.0, + 0, + 1, + ) + access_probability = np.clip( + idx["access_score"] / 100.0 - 0.35 * barrier + scenario.place_accountability / 350.0, 0.05, 0.95 + ) contact_attempts = rng.random((months, population_size)) < np.clip(0.22 + 0.30 * high_need, 0.05, 0.85) successful = rng.random((months, population_size)) < access_probability served = contact_attempts & successful @@ -631,14 +692,16 @@ def run_tornado_sensitivity( low_h = lever_low.get(lever, {}).get("delta_hospital_pressure_score", 0.0) high_h = lever_high.get(lever, {}).get("delta_hospital_pressure_score", 0.0) total_abs = abs(low_v) + abs(high_v) + abs(low_h) + abs(high_h) - rows.append({ - "lever": lever, - "low_delta_viability": round(low_v, 2), - "high_delta_viability": round(high_v, 2), - "low_delta_hospital": round(low_h, 2), - "high_delta_hospital": round(high_h, 2), - "total_abs_impact": round(total_abs, 2), - }) + rows.append( + { + "lever": lever, + "low_delta_viability": round(low_v, 2), + "high_delta_viability": round(high_v, 2), + "low_delta_hospital": round(low_h, 2), + "high_delta_hospital": round(high_h, 2), + "total_abs_impact": round(total_abs, 2), + } + ) out = pd.DataFrame(rows) out = out.sort_values("total_abs_impact", ascending=False).reset_index(drop=True) @@ -696,15 +759,17 @@ def run_ensemble_mc( for draw in range(draws): perturbed = _scenario_with_perturbation(scenario, scenario_rng, sd) idx = calculate_indices(perturbed) - rows.append({ - "scenario_id": scenario.scenario_id, - "scenario_name": scenario.scenario_name, - "draw": draw + 1, - "hybrid_viability_score": idx["hybrid_viability_score"], - "access_score": idx["access_score"], - "hospital_pressure_score": idx["hospital_pressure_score"], - "gaming_risk_score": idx["gaming_risk_score"], - }) + rows.append( + { + "scenario_id": scenario.scenario_id, + "scenario_name": scenario.scenario_name, + "draw": draw + 1, + "hybrid_viability_score": idx["hybrid_viability_score"], + "access_score": idx["access_score"], + "hospital_pressure_score": idx["hospital_pressure_score"], + "gaming_risk_score": idx["gaming_risk_score"], + } + ) draw_frame = pd.DataFrame(rows) @@ -712,14 +777,16 @@ def run_ensemble_mc( for scenario_id in sorted(draw_frame["scenario_id"].unique()): subset = draw_frame[draw_frame["scenario_id"] == scenario_id] vals = subset["hybrid_viability_score"].astype(float) - summary_rows.append({ - "scenario_id": scenario_id, - "mean": round(float(vals.mean()), 2), - "p05": round(float(vals.quantile(0.05)), 2), - "p50": round(float(vals.quantile(0.50)), 2), - "p95": round(float(vals.quantile(0.95)), 2), - "spread": round(float(vals.quantile(0.95) - vals.quantile(0.05)), 2), - }) + summary_rows.append( + { + "scenario_id": scenario_id, + "mean": round(float(vals.mean()), 2), + "p05": round(float(vals.quantile(0.05)), 2), + "p50": round(float(vals.quantile(0.50)), 2), + "p95": round(float(vals.quantile(0.95)), 2), + "spread": round(float(vals.quantile(0.95) - vals.quantile(0.05)), 2), + } + ) return pd.DataFrame(summary_rows) @@ -746,19 +813,26 @@ def run_cohort_stratified( high_idx = calculate_indices(high_scenario) metrics = [ - "hybrid_viability_score", "access_score", "supply_generation_score", - "equity_legitimacy_score", "governance_resilience_score", - "hospital_deflection_score", "fiscal_risk_score", "gaming_risk_score", + "hybrid_viability_score", + "access_score", + "supply_generation_score", + "equity_legitimacy_score", + "governance_resilience_score", + "hospital_deflection_score", + "fiscal_risk_score", + "gaming_risk_score", "hospital_pressure_score", ] rows: list[dict[str, object]] = [] for metric in metrics: - rows.append({ - "metric": metric, - label_low: round(low_idx[metric], 2), - label_high: round(high_idx[metric], 2), - "delta": round(high_idx[metric] - low_idx[metric], 2), - }) + rows.append( + { + "metric": metric, + label_low: round(low_idx[metric], 2), + label_high: round(high_idx[metric], 2), + "delta": round(high_idx[metric] - low_idx[metric], 2), + } + ) return pd.DataFrame(rows) @@ -804,11 +878,14 @@ def run_variance_decomposition( vd = cs[cs["metric"] == "hybrid_viability_score"]["delta"].values subgroup_var = float(np.var([0.0, float(vd[0]) if len(vd) > 0 else 0.0])) total = total_var or 1.0 - return pd.DataFrame([ - ("Structural (parameter)", round(structural_var, 4), round(structural_var / total, 4)), - ("Subgroup (equity)", round(subgroup_var, 4), round(subgroup_var / total, 4)), - ("Stochastic (residual)", round(stochastic_var, 4), round(stochastic_var / total, 4)), - ], columns=["source", "variance", "proportion"]) + return pd.DataFrame( + [ + ("Structural (parameter)", round(structural_var, 4), round(structural_var / total, 4)), + ("Subgroup (equity)", round(subgroup_var, 4), round(subgroup_var / total, 4)), + ("Stochastic (residual)", round(stochastic_var, 4), round(stochastic_var / total, 4)), + ], + columns=["source", "variance", "proportion"], + ) def run_policy_shock_sequence( @@ -830,13 +907,21 @@ def run_policy_shock_sequence( shock_s = replace(scenario, **{shock_field: shock_val}) shock_id = f"{scenario_id}_shock_{shock_field}{shock_delta:+.0f}" SCENARIO_BY_ID[shock_id] = RuntimeScenario( - shock_id, shock_s.scenario_name, f"Shock: {shock_field} {shock_delta:+.0f}", - shock_s.activity_signal, shock_s.capitation, - shock_s.place_accountability, shock_s.scope_capacity, - shock_s.urgent_ambulance, shock_s.data_visibility, - shock_s.governance, shock_s.equity_protection, - shock_s.copayment_burden, shock_s.budget_tightness, - shock_s.hospital_salience, shock_s.complexity, + shock_id, + shock_s.scenario_name, + f"Shock: {shock_field} {shock_delta:+.0f}", + shock_s.activity_signal, + shock_s.capitation, + shock_s.place_accountability, + shock_s.scope_capacity, + shock_s.urgent_ambulance, + shock_s.data_visibility, + shock_s.governance, + shock_s.equity_protection, + shock_s.copayment_burden, + shock_s.budget_tightness, + shock_s.hospital_salience, + shock_s.complexity, ) try: base_trace = run_stock_flow_trace(scenario_id, months=total_months) @@ -844,14 +929,15 @@ def run_policy_shock_sequence( finally: SCENARIO_BY_ID.pop(shock_id, None) - comparison = base_trace[["month", "hospital_pressure", "fiscal_pressure", - "primary_capacity", "unmet_need"]].copy() - comparison = comparison.rename(columns={ - "hospital_pressure": "baseline_hospital_pressure", - "fiscal_pressure": "baseline_fiscal_pressure", - "primary_capacity": "baseline_capacity", - "unmet_need": "baseline_unmet", - }) + comparison = base_trace[["month", "hospital_pressure", "fiscal_pressure", "primary_capacity", "unmet_need"]].copy() + comparison = comparison.rename( + columns={ + "hospital_pressure": "baseline_hospital_pressure", + "fiscal_pressure": "baseline_fiscal_pressure", + "primary_capacity": "baseline_capacity", + "unmet_need": "baseline_unmet", + } + ) comparison["shock_hospital_pressure"] = shock_trace["hospital_pressure"] comparison["shock_fiscal_pressure"] = shock_trace["fiscal_pressure"] comparison["shock_capacity"] = shock_trace["primary_capacity"] @@ -869,11 +955,16 @@ def run_stress_test_scenarios( """ scenario = get_runtime_scenario(baseline_scenario_id) base_idx = calculate_indices(scenario) - metrics = ["hybrid_viability_score", "access_score", "supply_generation_score", - "equity_legitimacy_score", "hospital_pressure_score", "gaming_risk_score"] + metrics = [ + "hybrid_viability_score", + "access_score", + "supply_generation_score", + "equity_legitimacy_score", + "hospital_pressure_score", + "gaming_risk_score", + ] rows: list[dict[str, object]] = [ - {"stress_name": f"Baseline ({baseline_scenario_id})", - **{m: round(base_idx[m], 2) for m in metrics}} + {"stress_name": f"Baseline ({baseline_scenario_id})", **{m: round(base_idx[m], 2) for m in metrics}} ] stresses = { "High co-payment burden": ("copayment_burden", 90.0), @@ -886,9 +977,15 @@ def run_stress_test_scenarios( } for name, stress in stresses.items(): if stress is None: - s = replace(scenario, copayment_burden=90.0, governance=10.0, - complexity=90.0, scope_capacity=10.0, - budget_tightness=90.0, equity_protection=10.0) + s = replace( + scenario, + copayment_burden=90.0, + governance=10.0, + complexity=90.0, + scope_capacity=10.0, + budget_tightness=90.0, + equity_protection=10.0, + ) else: s = replace(scenario, **{stress[0]: stress[1]}) idx = calculate_indices(s) @@ -910,12 +1007,15 @@ def run_interaction_scan( for cx_label, cx_val in levels: s = replace(scenario, equity_protection=eq_val, complexity=cx_val) idx = calculate_indices(s) - rows.append({ - "equity_level": eq_label, "complexity_level": cx_label, - "hybrid_viability": round(idx["hybrid_viability_score"], 2), - "access_score": round(idx["access_score"], 2), - "hospital_pressure": round(idx["hospital_pressure_score"], 2), - }) + rows.append( + { + "equity_level": eq_label, + "complexity_level": cx_label, + "hybrid_viability": round(idx["hybrid_viability_score"], 2), + "access_score": round(idx["access_score"], 2), + "hospital_pressure": round(idx["hospital_pressure_score"], 2), + } + ) return pd.DataFrame(rows) @@ -939,12 +1039,15 @@ def run_regime_sweep( for vy in values: s = replace(scenario, **{param_x: float(vx), param_y: float(vy)}) idx = calculate_indices(s) - rows.append({ - param_x: vx, param_y: vy, - "hybrid_viability_score": idx["hybrid_viability_score"], - "gaming_risk_score": idx["gaming_risk_score"], - "hospital_pressure_score": idx["hospital_pressure_score"], - }) + rows.append( + { + param_x: vx, + param_y: vy, + "hybrid_viability_score": idx["hybrid_viability_score"], + "gaming_risk_score": idx["gaming_risk_score"], + "hospital_pressure_score": idx["hospital_pressure_score"], + } + ) return pd.DataFrame(rows) @@ -998,12 +1101,16 @@ def run_phase_portrait( da = (calculate_indices(a_plus)["hybrid_viability_score"] - hv) / delta_s b_plus = replace(scenario, **{param_a: float(va), param_b: float(min(100, vb + delta_s))}) db = (calculate_indices(b_plus)["hybrid_viability_score"] - hv) / delta_s - rows.append({ - param_a: va, param_b: vb, - "da": round(da, 3), "db": round(db, 3), - "magnitude": round((da**2 + db**2)**0.5, 3), - "hybrid_viability": round(hv, 1), - }) + rows.append( + { + param_a: va, + param_b: vb, + "da": round(da, 3), + "db": round(db, 3), + "magnitude": round((da**2 + db**2) ** 0.5, 3), + "hybrid_viability": round(hv, 1), + } + ) return pd.DataFrame(rows) @@ -1036,15 +1143,17 @@ def run_uncertainty_ribbon( for month in range(1, months + 1): hp = [float(t[t["month"] == month]["hospital_pressure"].values[0]) for t in all_traces] fp = [float(t[t["month"] == month]["fiscal_pressure"].values[0]) for t in all_traces] - rows.append({ - "month": month, - "hp_p05": round(float(np.percentile(hp, 5)), 2), - "hp_p50": round(float(np.percentile(hp, 50)), 2), - "hp_p95": round(float(np.percentile(hp, 95)), 2), - "fp_p05": round(float(np.percentile(fp, 5)), 2), - "fp_p50": round(float(np.percentile(fp, 50)), 2), - "fp_p95": round(float(np.percentile(fp, 95)), 2), - }) + rows.append( + { + "month": month, + "hp_p05": round(float(np.percentile(hp, 5)), 2), + "hp_p50": round(float(np.percentile(hp, 50)), 2), + "hp_p95": round(float(np.percentile(hp, 95)), 2), + "fp_p05": round(float(np.percentile(fp, 5)), 2), + "fp_p50": round(float(np.percentile(fp, 50)), 2), + "fp_p95": round(float(np.percentile(fp, 95)), 2), + } + ) return pd.DataFrame(rows) @@ -1115,8 +1224,14 @@ def calibrate_all_scenarios() -> pd.DataFrame: for sc in SCENARIOS: idx = calculate_indices(sc) cal = calibrate_to_public_benchmarks(idx) - rows.append({"scenario_id": sc.scenario_id, "scenario_name": sc.scenario_name, - "hybrid_viability_score": idx["hybrid_viability_score"], **cal}) + rows.append( + { + "scenario_id": sc.scenario_id, + "scenario_name": sc.scenario_name, + "hybrid_viability_score": idx["hybrid_viability_score"], + **cal, + } + ) return pd.DataFrame(rows).sort_values("hybrid_viability_score", ascending=False).reset_index(drop=True) @@ -1130,16 +1245,26 @@ def calibrate_all_scenarios() -> pd.DataFrame: # ── Score interpretation guide ──────────────────────────────────────── SCORE_GUIDE_ENTRIES = [ - ("hybrid_viability_score", "Hybrid Viability Index", "0\u2013100", - "Overall desirability: weighted supply, access, equity, governance, deflection + inverted risks.", - "Better", {"<30": "Fragile", "30\u201350": "Moderate", "50\u201370": "Strong", ">70": "Robust"}, - "0.24S + 0.18A + 0.18E + 0.16G + 0.14D + 0.06(100-F) + 0.04(100-R)", - "S=Supply, A=Access, E=Equity, G=Governance, D=Deflection, F=Fiscal Risk, R=Gaming Risk"), - ("access_score", "Access Index", "0\u2013100", - "Timely primary care access given supply, equity, data and copay barriers.", - "Better", {"<30": "Poor", "30\u201355": "Adequate", "55\u201375": "Good", ">75": "Strong"}, - "threshold(0.42S + 0.18U + 0.15E + 0.12P + 0.10D - nonlinear C)", - "S=Supply, U=Urgent, E=Equity, P=Place, D=Data, C=Copay burden"), + ( + "hybrid_viability_score", + "Hybrid Viability Index", + "0\u2013100", + "Overall desirability: weighted supply, access, equity, governance, deflection + inverted risks.", + "Better", + {"<30": "Fragile", "30\u201350": "Moderate", "50\u201370": "Strong", ">70": "Robust"}, + "0.24S + 0.18A + 0.18E + 0.16G + 0.14D + 0.06(100-F) + 0.04(100-R)", + "S=Supply, A=Access, E=Equity, G=Governance, D=Deflection, F=Fiscal Risk, R=Gaming Risk", + ), + ( + "access_score", + "Access Index", + "0\u2013100", + "Timely primary care access given supply, equity, data and copay barriers.", + "Better", + {"<30": "Poor", "30\u201355": "Adequate", "55\u201375": "Good", ">75": "Strong"}, + "threshold(0.42S + 0.18U + 0.15E + 0.12P + 0.10D - nonlinear C)", + "S=Supply, U=Urgent, E=Equity, P=Place, D=Data, C=Copay burden", + ), ] @@ -1148,9 +1273,17 @@ def build_score_guide_dataframe() -> pd.DataFrame: for entry in SCORE_GUIDE_ENTRIES: _key, label, rng, meaning, direction, thresholds, formula, components = entry thresh = "; ".join(f"{k}: {v}" for k, v in thresholds.items()) - rows.append({"Index": label, "Range": rng, "Meaning": meaning, - "Higher is": direction, "Thresholds": thresh, - "Formula": formula, "Components": components}) + rows.append( + { + "Index": label, + "Range": rng, + "Meaning": meaning, + "Higher is": direction, + "Thresholds": thresh, + "Formula": formula, + "Components": components, + } + ) return pd.DataFrame(rows) @@ -1186,8 +1319,14 @@ def run_voi_analysis( evpi = float(np.mean(pi)) - eu evpi_pct = (evpi / (eu or 1)) * 100 - key_params = ["activity_signal", "governance", "equity_protection", - "copayment_burden", "place_accountability", "complexity"] + key_params = [ + "activity_signal", + "governance", + "equity_protection", + "copayment_burden", + "place_accountability", + "complexity", + ] evppi: dict[str, float] = {} for param in key_params: deltas: list[float] = [] @@ -1195,18 +1334,22 @@ def run_voi_analysis( base = float(getattr(sc, param)) hi = replace(sc, **{param: clamp(base + sd * 100.0)}) lo = replace(sc, **{param: clamp(base - sd * 100.0)}) - deltas.append(abs(calculate_indices(hi)["hybrid_viability_score"] - - calculate_indices(lo)["hybrid_viability_score"])) + deltas.append( + abs(calculate_indices(hi)["hybrid_viability_score"] - calculate_indices(lo)["hybrid_viability_score"]) + ) evppi[param] = round(float(np.mean(deltas)), 3) top_p = max(evppi, key=evppi.get) if evppi else "none" top_v = evppi.get(top_p, 0.0) - return pd.DataFrame([ - ("Hybrid viability", round(evpi, 3), round(evpi_pct, 2), top_p, top_v), - ("Access", 0.0, 0.0, top_p, 0.0), - ("Hospital pressure", 0.0, 0.0, top_p, 0.0), - ], columns=["metric", "evpi", "evpi_pct", "top_evppi_param", "evppi_value"]) + return pd.DataFrame( + [ + ("Hybrid viability", round(evpi, 3), round(evpi_pct, 2), top_p, top_v), + ("Access", 0.0, 0.0, top_p, 0.0), + ("Hospital pressure", 0.0, 0.0, top_p, 0.0), + ], + columns=["metric", "evpi", "evpi_pct", "top_evppi_param", "evppi_value"], + ) # ── Distribution-based calibration (alternative to linear) ──────────── @@ -1214,10 +1357,10 @@ def run_voi_analysis( # NZ benchmark ranges expressed as (lower, upper, beta_alpha, beta_beta) # Beta parameters chosen to centre mass on published point estimates NZ_BENCHMARK_DISTRIBUTIONS = { - "gp_visits_per_1000": (3800, 4600, 8.0, 2.0), # mode ~4200 - "ed_per_100k": (280, 350, 6.0, 3.0), # mode ~310 - "admissions_per_100k": (85, 120, 5.0, 4.0), # mode ~98 - "spend_per_capita_nzd": (280, 420, 7.0, 3.0), # mode ~350 + "gp_visits_per_1000": (3800, 4600, 8.0, 2.0), # mode ~4200 + "ed_per_100k": (280, 350, 6.0, 3.0), # mode ~310 + "admissions_per_100k": (85, 120, 5.0, 4.0), # mode ~98 + "spend_per_capita_nzd": (280, 420, 7.0, 3.0), # mode ~350 "ambulance_conveyances_per_100k": (45, 75, 4.0, 3.0), # mode ~58 } @@ -1288,9 +1431,9 @@ def run_budget_impact( enrolled_population: int = 4500000, time_horizon_years: int = 5, discount_rate: float = 0.035, - diffusion_rate: float = 0.15, # Bass p (innovation) - imitation_rate: float = 0.40, # Bass q (imitation) - adopters_start: float = 0.05, # Initial adoption fraction + diffusion_rate: float = 0.15, # Bass p (innovation) + imitation_rate: float = 0.40, # Bass q (imitation) + adopters_start: float = 0.05, # Initial adoption fraction seed: int = 260526, ) -> pd.DataFrame: """Estimate budget impact of each scenario with Bass policy diffusion. @@ -1322,14 +1465,16 @@ def run_budget_impact( for t, adopt in zip(years, adoption, strict=True): undiscounted = enrolled_population * adopt * spend_per_cap discounted = undiscounted / ((1 + discount_rate) ** t) - rows.append({ - "scenario_id": sid, - "year": t, - "adoption_rate": round(adopt, 3), - "undiscounted_budget_nzd": round(undiscounted, 0), - "discounted_budget_nzd": round(discounted, 0), - "spend_per_capita_nzd": spend_per_cap, - }) + rows.append( + { + "scenario_id": sid, + "year": t, + "adoption_rate": round(adopt, 3), + "undiscounted_budget_nzd": round(undiscounted, 0), + "discounted_budget_nzd": round(discounted, 0), + "spend_per_capita_nzd": spend_per_cap, + } + ) df = pd.DataFrame(rows) @@ -1339,13 +1484,16 @@ def run_budget_impact( subset = df[df["scenario_id"] == sid] total_discounted = subset["discounted_budget_nzd"].sum() total_undiscounted = subset["undiscounted_budget_nzd"].sum() - total_rows.append({ - "scenario_id": sid, "year": "Total", - "adoption_rate": 1.0, - "undiscounted_budget_nzd": total_undiscounted, - "discounted_budget_nzd": total_discounted, - "spend_per_capita_nzd": float("nan"), - }) + total_rows.append( + { + "scenario_id": sid, + "year": "Total", + "adoption_rate": 1.0, + "undiscounted_budget_nzd": total_undiscounted, + "discounted_budget_nzd": total_discounted, + "spend_per_capita_nzd": float("nan"), + } + ) if total_rows: df = pd.concat([df, pd.DataFrame(total_rows)], ignore_index=True) @@ -1365,41 +1513,59 @@ def run_budget_impact( CANONICAL_DEFS = { "hybrid_viability_score": { - "label": "Hybrid Viability Index", "short": "Viability", "range": "0-100", + "label": "Hybrid Viability Index", + "short": "Viability", + "range": "0-100", "meaning": "Overall desirability combining supply, access, equity, governance, deflection and inverted risks.", "higher_is": "Better", "formula": "0.24*Supply + 0.18*Access + 0.18*Equity + 0.16*Governance + 0.14*Deflection + 0.06*(100-Fiscal) + 0.04*(100-Gaming)", - "used_in": ["Reference bar", "Reference scatter", "Heatmap", "Radar", "Educational chart"]}, + "used_in": ["Reference bar", "Reference scatter", "Heatmap", "Radar", "Educational chart"], + }, "access_score": { - "label": "Access Index", "short": "Access", "range": "0-100", + "label": "Access Index", + "short": "Access", + "range": "0-100", "meaning": "Timely primary care access given supply, equity, data and copay barriers.", "higher_is": "Better", "formula": "threshold(0.42*(Supply/100) + 0.18*Urgent + 0.15*Equity + 0.12*Place + 0.10*Data - nonlinear Copay)", - "used_in": ["Reference scatter", "Heatmap", "Radar", "Cohort comparison"]}, + "used_in": ["Reference scatter", "Heatmap", "Radar", "Cohort comparison"], + }, "supply_generation_score": { - "label": "Supply Generation Index", "short": "Supply", "range": "0-100", + "label": "Supply Generation Index", + "short": "Supply", + "range": "0-100", "meaning": "Ability to generate primary care supply under the payment architecture.", "higher_is": "Better", "formula": "threshold(Activity + Scope + Urgent + Place + Capitation - Budget) plus saturating continuity", - "used_in": ["Reference scatter", "Heatmap", "Radar"]}, + "used_in": ["Reference scatter", "Heatmap", "Radar"], + }, "equity_legitimacy_score": { - "label": "Equity Legitimacy Index", "short": "Equity", "range": "0-100", + "label": "Equity Legitimacy Index", + "short": "Equity", + "range": "0-100", "meaning": "Fairness of access and funding distribution across population groups.", "higher_is": "Better", "formula": "threshold(0.34*Equity + 0.24*Place + 0.16*Capitation + 0.14*Data - nonlinear Copay)", - "used_in": ["Heatmap", "Radar", "Cohort comparison"]}, + "used_in": ["Heatmap", "Radar", "Cohort comparison"], + }, "hospital_pressure_score": { - "label": "Hospital Pressure Index", "short": "Hospital pressure", "range": "0-100", + "label": "Hospital Pressure Index", + "short": "Hospital pressure", + "range": "0-100", "meaning": "Residual hospital demand pressure after upstream deflection.", "higher_is": "Worse when higher", "formula": "threshold(0.34*HospSal + 0.26*(1-Defl/100) + 0.16*Complexity + 0.14*Budget - 0.20*(Access/100) - 0.12*Urgent)", - "used_in": ["Reference scatter", "Heatmap", "Radar", "Stress tests", "Policy shock"]}, + "used_in": ["Reference scatter", "Heatmap", "Radar", "Stress tests", "Policy shock"], + }, "gaming_risk_score": { - "label": "Gaming Risk Index", "short": "Gaming risk", "range": "0-100", + "label": "Gaming Risk Index", + "short": "Gaming risk", + "range": "0-100", "meaning": "Risk of claim inflation, low-value care or fiscal leakage.", "higher_is": "Worse when higher", "formula": "threshold(0.36*Activity + 0.18*Scope + 0.18*Complexity - 0.30*Governance - 0.18*Data - 0.16*Place)", - "used_in": ["Heatmap", "Radar", "Stress tests", "Gaming-risk frontier"]}, + "used_in": ["Heatmap", "Radar", "Stress tests", "Gaming-risk frontier"], + }, } @@ -1410,14 +1576,24 @@ def build_evidence_table() -> pd.DataFrame: """Build the evidence/reference table from the CSL-JSON file.""" import json from pathlib import Path + ref_path = Path(__file__).resolve().parents[2] / "docs" / "references" / "gtpcnz-references-v1.8.5.json" if not ref_path.exists(): return pd.DataFrame(columns=["ID", "Type", "Title", "Publisher", "URL", "Note"]) refs = json.loads(ref_path.read_text(encoding="utf-8")) - return pd.DataFrame([{ - "ID": r.get("id",""), "Type": r.get("type",""), - "Title": r.get("title",""), "Publisher": r.get("publisher",""), - "URL": r.get("URL",""), "Note": r.get("note","")} for r in refs]) + return pd.DataFrame( + [ + { + "ID": r.get("id", ""), + "Type": r.get("type", ""), + "Title": r.get("title", ""), + "Publisher": r.get("publisher", ""), + "URL": r.get("URL", ""), + "Note": r.get("note", ""), + } + for r in refs + ] + ) # ── Clustering and animation infrastructure ────────────────────────── @@ -1432,6 +1608,7 @@ def run_outcome_clustering( from sklearn.cluster import KMeans from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler + rng = np.random.default_rng(seed) scenarios = [get_runtime_scenario(sid) for sid in scenario_ids] rows = [] @@ -1439,13 +1616,27 @@ def run_outcome_clustering( for _ in range(50): p = _scenario_with_perturbation(sc, rng, 0.08) idx = calculate_indices(p) - rows.append({"scenario_id": sc.scenario_id, - "activity_signal": p.activity_signal, "capitation": p.capitation, - "governance": p.governance, "equity_protection": p.equity_protection, - "copayment_burden": p.copayment_burden, "complexity": p.complexity, **idx}) + rows.append( + { + "scenario_id": sc.scenario_id, + "activity_signal": p.activity_signal, + "capitation": p.capitation, + "governance": p.governance, + "equity_protection": p.equity_protection, + "copayment_burden": p.copayment_burden, + "complexity": p.complexity, + **idx, + } + ) df = pd.DataFrame(rows) - mc = ["hybrid_viability_score", "access_score", "supply_generation_score", - "equity_legitimacy_score", "hospital_pressure_score", "gaming_risk_score"] + mc = [ + "hybrid_viability_score", + "access_score", + "supply_generation_score", + "equity_legitimacy_score", + "hospital_pressure_score", + "gaming_risk_score", + ] X = StandardScaler().fit_transform(df[mc]) km = KMeans(n_clusters=n_clusters, random_state=seed, n_init=10) df["cluster"] = km.fit_predict(X) @@ -1459,22 +1650,39 @@ def run_outcome_clustering( continue mc_val = int(sub["cluster"].mode().iloc[0]) top_idx = np.argsort(np.abs(km.cluster_centers_[mc_val]))[-3:][::-1] - summary.append({"scenario_id": sid, "cluster": mc_val, - "mean_viability": round(sub["hybrid_viability_score"].mean(), 1), - "top_metrics": ", ".join(mc[i] for i in top_idx)}) + summary.append( + { + "scenario_id": sid, + "cluster": mc_val, + "mean_viability": round(sub["hybrid_viability_score"].mean(), 1), + "top_metrics": ", ".join(mc[i] for i in top_idx), + } + ) return pd.DataFrame(summary) def run_composite_meta_analysis( - n_points: int = 36, seed: int = 260526, + n_points: int = 36, + seed: int = 260526, ) -> pd.DataFrame: """Sweep all 12 parameters and compute all indices.""" # seed argument retained for API compatibility with previous stochastic variants. rng = np.random.default_rng(seed) base = get_runtime_scenario("F4") - fields = ["activity_signal","capitation","place_accountability","scope_capacity", - "urgent_ambulance","data_visibility","governance","equity_protection", - "copayment_burden","budget_tightness","hospital_salience","complexity"] + fields = [ + "activity_signal", + "capitation", + "place_accountability", + "scope_capacity", + "urgent_ambulance", + "data_visibility", + "governance", + "equity_protection", + "copayment_burden", + "budget_tightness", + "hospital_salience", + "complexity", + ] rows = [] for _i in range(n_points): pd_ = {} @@ -1488,7 +1696,8 @@ def run_composite_meta_analysis( def create_animation_frames( param_x: str = "activity_signal", param_y: str = "governance", - steps: int = 10, scenario_id: str = "F4", + steps: int = 10, + scenario_id: str = "F4", ) -> pd.DataFrame: """Generate animation frames for a 2-parameter sweep.""" scenario = get_runtime_scenario(scenario_id) @@ -1498,9 +1707,15 @@ def create_animation_frames( for vy in values: s = replace(scenario, **{param_x: float(vx), param_y: float(vy)}) idx = calculate_indices(s) - rows.append({param_x: vx, param_y: vy, "frame": fi, - "hybrid_viability_score": idx["hybrid_viability_score"], - "hospital_pressure_score": idx["hospital_pressure_score"]}) + rows.append( + { + param_x: vx, + param_y: vy, + "frame": fi, + "hybrid_viability_score": idx["hybrid_viability_score"], + "hospital_pressure_score": idx["hospital_pressure_score"], + } + ) return pd.DataFrame(rows) @@ -1509,92 +1724,178 @@ def create_animation_frames( SUBSTACK_SERIES_URL = "https://rareinsights.substack.com" SUBSTACK_POSTS = { - "01": {"title": "Are we buying hospital growth by rationing cheaper care upstream?", - "url": "https://rareinsights.substack.com/p/are-we-buying-hospital-growth-by", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-01-are-we-buying-hospital-growth-by-rationing-cheaper-care-upstream-v1.8.1-applied.md", - "models": ["Reference scenarios", "F0-F9 comparison"]}, - "02": {"title": "Fee-for-service, capitation and blended funding", - "url": "https://rareinsights.substack.com/p/fee-for-service-capitation-and-blended-funding-the-plain-english-version", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-02-fee-for-service-capitation-and-blended-funding-the-plain-english-version-v1.8.1-applied.md", - "models": ["Funding model comparison", "Educational explainer"]}, - "03": {"title": "Marginal supply", - "url": "https://rareinsights.substack.com/p/marginal-supply-the-tiny-economic-idea-that-decides-whether-appointments-exist", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-03-marginal-supply-the-tiny-economic-idea-that-decides-whether-appointments-exist-v1.8.1-applied.md", - "models": ["Microeconomics lab 1", "Supply generation"]}, - "04": {"title": "Why formulas do not solve games", - "url": "https://rareinsights.substack.com/p/why-formulas-do-not-solve-games", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-04-why-formulas-do-not-solve-games-v1.8.1-applied.md", - "models": ["Game theory labs", "Gaming risk"]}, - "05": {"title": "Current reform pathway", - "url": "https://rareinsights.substack.com/p/the-current-reform-pathway-stronger-than-a-straw-man-but-maybe-still-incomplete", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-05-the-current-reform-pathway-stronger-than-a-straw-man-but-maybe-still-incomplete-v1.8.1-applied.md", - "models": ["Reference scenario F0"]}, - "06": {"title": "What I mean by uncapping primary care funding", - "url": "https://rareinsights.substack.com/p/what-i-mean-by-uncapping-primary-care-funding", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-06-what-i-mean-by-uncapping-primary-care-funding-v1.8.1-applied.md", - "models": ["Microeconomics lab 3", "Scheduled payment"]}, - "07": {"title": "The hospital salience game and the Health New Zealand allocation game", - "url": "https://rareinsights.substack.com/p/the-hospital-salience-game-and-the-health-new-zealand-allocation-game", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-07-the-hospital-salience-game-and-the-health-new-zealand-allocation-game-v1.8.1-applied.md", - "models": ["Hospital salience", "Allocation-priority explainer"]}, - "08": {"title": "The capitation marginal-supply game and the consumer access game", - "url": "https://rareinsights.substack.com/p/the-capitation-marginal-supply-game-and-the-consumer-access-game", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-08-the-capitation-marginal-supply-game-and-the-consumer-access-game-v1.8.1-applied.md", - "models": ["Microeconomics lab", "Marginal supply simulation"]}, - "09": {"title": "Primary Health Organisations: useful functions, payment friction and cherry-picking", - "url": "https://rareinsights.substack.com/p/primary-health-organisations-useful-functions-payment-friction-and-cherry-picking", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-09-primary-health-organisations-useful-functions-payment-friction-and-cherry-picking-v1.8.1-applied.md", - "models": ["Game theory lab", "Gaming-risk frontier"]}, - "10": {"title": "Accident Compensation Corporation, ambulance and urgent care: the hidden upstream system", - "url": "https://rareinsights.substack.com/p/accident-compensation-corporation-ambulance-and-urgent-care-the-hidden-upstream-system", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-10-accident-compensation-corporation-ambulance-and-urgent-care-the-hidden-upstream-system-v1.8.1-applied.md", - "models": ["Reference scenarios", "Pathway stress explanation"]}, - "11": {"title": "Who should be allowed to generate primary care supply?", - "url": "https://rareinsights.substack.com/p/who-should-be-allowed-to-generate-primary-care-supply", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-11-who-should-be-allowed-to-generate-primary-care-supply-v1.8.1-applied.md", - "models": ["Scope capacity", "Educational explainer"]}, - "12": {"title": "Telehealth is an extender, not a replacement for local supply", - "url": "https://rareinsights.substack.com/p/telehealth-is-an-extender-not-a-replacement-for-local-supply", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-12-telehealth-is-an-extender-not-a-replacement-for-local-supply-v1.8.1-applied.md", - "models": ["Local supply", "Digital extender explanation"]}, - "13": {"title": "Co-payments: demand signal or equity failure?", - "url": "https://rareinsights.substack.com/p/co-payments-demand-signal-or-equity-failure", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-13-co-payments-demand-signal-or-equity-failure-v1.8.1-applied.md", - "models": ["Co-payment barrier module", "Equity protection"]}, - "14": {"title": "The 19 games: a map of the New Zealand primary care funding problem", - "url": "https://rareinsights.substack.com/p/the-19-games-a-map-of-the-new-zealand-primary-care-funding-problem", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-14-the-19-games-a-map-of-the-new-zealand-primary-care-funding-problem-v1.8.1-applied.md", - "models": ["Game map navigator", "Model gap map"]}, - "15": {"title": "The hybrid game: why no single lever is enough", - "url": "https://rareinsights.substack.com/p/the-hybrid-game-why-no-single-lever-is-enough", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-15-the-hybrid-game-why-no-single-lever-is-enough-v1.8.1-applied.md", - "models": ["Scenario profile", "Hybrid viability decomposition"]}, - "16": {"title": "Composite modelling: what the demonstrative model adds, and what it does not", - "url": "https://rareinsights.substack.com/p/composite-modelling-what-the-demonstrative-model-adds-and-what-it-does-not", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-16-composite-modelling-what-the-demonstrative-model-adds-and-what-it-does-not-v1.8.1-applied.md", - "models": ["Live model lab", "Uncertainty and model gap map"]}, - "17": {"title": "Game-informed Multi-Criteria Decision Analysis: making disagreement useful", - "url": "https://rareinsights.substack.com/p/game-informed-multi-criteria-decision-analysis-making-disagreement-useful", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-17-game-informed-multi-criteria-decision-analysis-making-disagreement-useful-v1.8.1-applied.md", - "models": ["Value of information", "Decision support"]}, - "18": {"title": "Recommendations: a primary care system that can grow before hospitals have to", - "url": "https://rareinsights.substack.com/p/recommendations-a-primary-care-system-that-can-grow-before-hospitals-have-to", - "file": "docs/substack-ready/posts-v1.8.1-applied/post-18-recommendations-a-primary-care-system-that-can-grow-before-hospitals-have-to-v1.8.1-applied.md", - "models": ["Recommendations", "Release model card"]}, + "01": { + "title": "Are we buying hospital growth by rationing cheaper care upstream?", + "url": "https://rareinsights.substack.com/p/are-we-buying-hospital-growth-by", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-01-are-we-buying-hospital-growth-by-rationing-cheaper-care-upstream-v1.8.1-applied.md", + "models": ["Reference scenarios", "F0-F9 comparison"], + }, + "02": { + "title": "Fee-for-service, capitation and blended funding", + "url": "https://rareinsights.substack.com/p/fee-for-service-capitation-and-blended-funding-the-plain-english-version", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-02-fee-for-service-capitation-and-blended-funding-the-plain-english-version-v1.8.1-applied.md", + "models": ["Funding model comparison", "Educational explainer"], + }, + "03": { + "title": "Marginal supply", + "url": "https://rareinsights.substack.com/p/marginal-supply-the-tiny-economic-idea-that-decides-whether-appointments-exist", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-03-marginal-supply-the-tiny-economic-idea-that-decides-whether-appointments-exist-v1.8.1-applied.md", + "models": ["Microeconomics lab 1", "Supply generation"], + }, + "04": { + "title": "Why formulas do not solve games", + "url": "https://rareinsights.substack.com/p/why-formulas-do-not-solve-games", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-04-why-formulas-do-not-solve-games-v1.8.1-applied.md", + "models": ["Game theory labs", "Gaming risk"], + }, + "05": { + "title": "Current reform pathway", + "url": "https://rareinsights.substack.com/p/the-current-reform-pathway-stronger-than-a-straw-man-but-maybe-still-incomplete", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-05-the-current-reform-pathway-stronger-than-a-straw-man-but-maybe-still-incomplete-v1.8.1-applied.md", + "models": ["Reference scenario F0"], + }, + "06": { + "title": "What I mean by uncapping primary care funding", + "url": "https://rareinsights.substack.com/p/what-i-mean-by-uncapping-primary-care-funding", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-06-what-i-mean-by-uncapping-primary-care-funding-v1.8.1-applied.md", + "models": ["Microeconomics lab 3", "Scheduled payment"], + }, + "07": { + "title": "The hospital salience game and the Health New Zealand allocation game", + "url": "https://rareinsights.substack.com/p/the-hospital-salience-game-and-the-health-new-zealand-allocation-game", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-07-the-hospital-salience-game-and-the-health-new-zealand-allocation-game-v1.8.1-applied.md", + "models": ["Hospital salience", "Allocation-priority explainer"], + }, + "08": { + "title": "The capitation marginal-supply game and the consumer access game", + "url": "https://rareinsights.substack.com/p/the-capitation-marginal-supply-game-and-the-consumer-access-game", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-08-the-capitation-marginal-supply-game-and-the-consumer-access-game-v1.8.1-applied.md", + "models": ["Microeconomics lab", "Marginal supply simulation"], + }, + "09": { + "title": "Primary Health Organisations: useful functions, payment friction and cherry-picking", + "url": "https://rareinsights.substack.com/p/primary-health-organisations-useful-functions-payment-friction-and-cherry-picking", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-09-primary-health-organisations-useful-functions-payment-friction-and-cherry-picking-v1.8.1-applied.md", + "models": ["Game theory lab", "Gaming-risk frontier"], + }, + "10": { + "title": "Accident Compensation Corporation, ambulance and urgent care: the hidden upstream system", + "url": "https://rareinsights.substack.com/p/accident-compensation-corporation-ambulance-and-urgent-care-the-hidden-upstream-system", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-10-accident-compensation-corporation-ambulance-and-urgent-care-the-hidden-upstream-system-v1.8.1-applied.md", + "models": ["Reference scenarios", "Pathway stress explanation"], + }, + "11": { + "title": "Who should be allowed to generate primary care supply?", + "url": "https://rareinsights.substack.com/p/who-should-be-allowed-to-generate-primary-care-supply", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-11-who-should-be-allowed-to-generate-primary-care-supply-v1.8.1-applied.md", + "models": ["Scope capacity", "Educational explainer"], + }, + "12": { + "title": "Telehealth is an extender, not a replacement for local supply", + "url": "https://rareinsights.substack.com/p/telehealth-is-an-extender-not-a-replacement-for-local-supply", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-12-telehealth-is-an-extender-not-a-replacement-for-local-supply-v1.8.1-applied.md", + "models": ["Local supply", "Digital extender explanation"], + }, + "13": { + "title": "Co-payments: demand signal or equity failure?", + "url": "https://rareinsights.substack.com/p/co-payments-demand-signal-or-equity-failure", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-13-co-payments-demand-signal-or-equity-failure-v1.8.1-applied.md", + "models": ["Co-payment barrier module", "Equity protection"], + }, + "14": { + "title": "The 19 games: a map of the New Zealand primary care funding problem", + "url": "https://rareinsights.substack.com/p/the-19-games-a-map-of-the-new-zealand-primary-care-funding-problem", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-14-the-19-games-a-map-of-the-new-zealand-primary-care-funding-problem-v1.8.1-applied.md", + "models": ["Game map navigator", "Model gap map"], + }, + "15": { + "title": "The hybrid game: why no single lever is enough", + "url": "https://rareinsights.substack.com/p/the-hybrid-game-why-no-single-lever-is-enough", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-15-the-hybrid-game-why-no-single-lever-is-enough-v1.8.1-applied.md", + "models": ["Scenario profile", "Hybrid viability decomposition"], + }, + "16": { + "title": "Composite modelling: what the demonstrative model adds, and what it does not", + "url": "https://rareinsights.substack.com/p/composite-modelling-what-the-demonstrative-model-adds-and-what-it-does-not", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-16-composite-modelling-what-the-demonstrative-model-adds-and-what-it-does-not-v1.8.1-applied.md", + "models": ["Live model lab", "Uncertainty and model gap map"], + }, + "17": { + "title": "Game-informed Multi-Criteria Decision Analysis: making disagreement useful", + "url": "https://rareinsights.substack.com/p/game-informed-multi-criteria-decision-analysis-making-disagreement-useful", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-17-game-informed-multi-criteria-decision-analysis-making-disagreement-useful-v1.8.1-applied.md", + "models": ["Value of information", "Decision support"], + }, + "18": { + "title": "Recommendations: a primary care system that can grow before hospitals have to", + "url": "https://rareinsights.substack.com/p/recommendations-a-primary-care-system-that-can-grow-before-hospitals-have-to", + "file": "docs/substack-ready/posts-v1.8.1-applied/post-18-recommendations-a-primary-care-system-that-can-grow-before-hospitals-have-to-v1.8.1-applied.md", + "models": ["Recommendations", "Release model card"], + }, } def model_gap_map() -> pd.DataFrame: rows = [ - ("Current", "14-game demonstrative layer", "Executable in parent repo; partially represented in public educational labs", "Good benchmark, incomplete public runtime coverage"), - ("Current", "Static diagrams and Mermaid previews", "Many PNG/SVG/Mermaid assets exist across docs and Substack-ready figures", "Strong explainer inventory, not all connected to Streamlit modules"), - ("Current", "Public Streamlit runtime", "Educational labs calculate at runtime; reference scenarios load precomputed CSV", "Needs explicit live/cached/precomputed source labels"), - ("Comprehensive", "All-game executable navigator", "Not yet complete in public app", "Add a 19-games navigator with linked formulas, posts and visuals"), - ("Comprehensive", "Unified formula registry", "Formula sketches exist across docs and code", "Expose formula cards and calculation trace per scenario"), - ("SOTA", "Global sensitivity and uncertainty provenance", "Monte Carlo exists in parent repo", "Add bounded public uncertainty intervals and top-driver visuals"), - ("SOTA", "Calibration diagnostics", "Readiness table exists; linked-data calibration not available", "Keep as readiness/provenance, not observed-vs-predicted claims"), - ("Bleeding edge", "Scenario morphing and stochastic replay", "Not yet implemented", "Animate transition between current reform and full hybrid, with seeded stochastic replay"), - ("Bleeding edge", "Agent-flow visual", "ABM exists in parent repo", "Show capped agent allocation and unmet-attempt patterns as a teaching lens"), - ("Bleeding edge", "Calculation audit overlay", "Not yet implemented", "Show input, formula sketch, output, status and caveat for each number"), + ( + "Current", + "14-game demonstrative layer", + "Executable in parent repo; partially represented in public educational labs", + "Good benchmark, incomplete public runtime coverage", + ), + ( + "Current", + "Static diagrams and Mermaid previews", + "Many PNG/SVG/Mermaid assets exist across docs and Substack-ready figures", + "Strong explainer inventory, not all connected to Streamlit modules", + ), + ( + "Current", + "Public Streamlit runtime", + "Educational labs calculate at runtime; reference scenarios load precomputed CSV", + "Needs explicit live/cached/precomputed source labels", + ), + ( + "Comprehensive", + "All-game executable navigator", + "Not yet complete in public app", + "Add a 19-games navigator with linked formulas, posts and visuals", + ), + ( + "Comprehensive", + "Unified formula registry", + "Formula sketches exist across docs and code", + "Expose formula cards and calculation trace per scenario", + ), + ( + "SOTA", + "Global sensitivity and uncertainty provenance", + "Monte Carlo exists in parent repo", + "Add bounded public uncertainty intervals and top-driver visuals", + ), + ( + "SOTA", + "Calibration diagnostics", + "Readiness table exists; linked-data calibration not available", + "Keep as readiness/provenance, not observed-vs-predicted claims", + ), + ( + "Bleeding edge", + "Scenario morphing and stochastic replay", + "Not yet implemented", + "Animate transition between current reform and full hybrid, with seeded stochastic replay", + ), + ( + "Bleeding edge", + "Agent-flow visual", + "ABM exists in parent repo", + "Show capped agent allocation and unmet-attempt patterns as a teaching lens", + ), + ( + "Bleeding edge", + "Calculation audit overlay", + "Not yet implemented", + "Show input, formula sketch, output, status and caveat for each number", + ), ] return pd.DataFrame(rows, columns=["tier", "asset_or_gap", "current_state", "recommended_next_step"]) diff --git a/models/primarycare_model/scenario_service.py b/models/primarycare_model/scenario_service.py index 7fa9403..b64d9dc 100644 --- a/models/primarycare_model/scenario_service.py +++ b/models/primarycare_model/scenario_service.py @@ -61,6 +61,7 @@ def _diminishing_return(value: float, rate: float = 2.4) -> float: bounded = _clamp(value, 0.0, 1.0) return (1.0 - math.exp(-rate * bounded)) / (1.0 - math.exp(-rate)) + SCENARIO_INTERPRETATION = { "F0": "Current reform comparator", "F1": "Allocation reform only", @@ -235,7 +236,10 @@ def score_educational_settings(settings: EducationalSettings) -> dict[str, float steepness=7.0, ) hospital_pressure = 100 * _strategic_response( - 0.50 * (1 - supply / 100) + 0.22 * (1 - governance / 100) + 0.16 * (1 - _diminishing_return(local)) + 0.12 * (1 - equity_score / 100), + 0.50 * (1 - supply / 100) + + 0.22 * (1 - governance / 100) + + 0.16 * (1 - _diminishing_return(local)) + + 0.12 * (1 - equity_score / 100), threshold=0.45, steepness=6.0, ) @@ -261,10 +265,7 @@ def _score_legacy_settings(settings: EducationalSettings) -> dict[str, float]: """Backward-compatible alias for older public tests and docs.""" scores = score_educational_settings(settings) prefix = "to" + "y_" - return { - key.replace("educational_", prefix): value - for key, value in scores.items() - } + return {key.replace("educational_", prefix): value for key, value in scores.items()} globals()["score_" + "to" + "y_settings"] = _score_legacy_settings @@ -282,14 +283,49 @@ def load_first_existing(paths: Iterable[str | Path]) -> pd.DataFrame: def build_calibration_readiness_table() -> pd.DataFrame: """Return a public-runtime readiness table without reading non-public inputs.""" rows: tuple[tuple[str, str, str, str], ...] = ( - ("Primary care appointments", "Published aggregate utilisation/access series", "Needed", "Access and waiting-time calibration"), - ("Capitation and payment rules", "Published rate tables and programme funding summaries", "Needed", "Practice revenue and marginal-supply calibration"), + ( + "Primary care appointments", + "Published aggregate utilisation/access series", + "Needed", + "Access and waiting-time calibration", + ), + ( + "Capitation and payment rules", + "Published rate tables and programme funding summaries", + "Needed", + "Practice revenue and marginal-supply calibration", + ), ("Co-payments", "Published fee and out-of-pocket cost summaries", "Needed", "Demand/equity response"), - ("Ambulance pathways", "Published conveyance and alternative pathway aggregates", "Needed", "Hospital-deflection calibration"), - ("ACC treatment payments", "Published Cost of Treatment Regulations, contracts, and aggregate claims", "Needed", "Cross-funder and supply-stabilisation effects"), - ("ED and inpatient pressure", "Published hospital/ED aggregate series", "Needed", "Downstream hospital-pressure validation"), - ("Workforce and scope", "Published workforce, FTE, location, prescribing, and scope summaries", "Needed", "Scope-enabled supply calibration"), - ("Normative decision weights", "Editable public model assumptions only", "Needed", "Decision support; not stakeholder preferences"), + ( + "Ambulance pathways", + "Published conveyance and alternative pathway aggregates", + "Needed", + "Hospital-deflection calibration", + ), + ( + "ACC treatment payments", + "Published Cost of Treatment Regulations, contracts, and aggregate claims", + "Needed", + "Cross-funder and supply-stabilisation effects", + ), + ( + "ED and inpatient pressure", + "Published hospital/ED aggregate series", + "Needed", + "Downstream hospital-pressure validation", + ), + ( + "Workforce and scope", + "Published workforce, FTE, location, prescribing, and scope summaries", + "Needed", + "Scope-enabled supply calibration", + ), + ( + "Normative decision weights", + "Editable public model assumptions only", + "Needed", + "Decision support; not stakeholder preferences", + ), ) return pd.DataFrame(rows, columns=["domain", "input", "status", "why_it_matters"]) diff --git a/models/primarycare_model/schemas.py b/models/primarycare_model/schemas.py index e6c6faa..5c818cf 100644 --- a/models/primarycare_model/schemas.py +++ b/models/primarycare_model/schemas.py @@ -48,8 +48,10 @@ class ProviderType(StrEnum): NURSE = "nurse" PRACTICE = "practice" + # placeholder + class SimulationConfig(BaseModel): """Top-level configuration for a simulation run.""" @@ -90,6 +92,7 @@ def validate_end_after_start(self) -> PolicyParams: raise ValueError(f"end_month ({self.end_month}) must be > start_month ({self.start_month})") return self + class ScenarioParams(BaseModel): """A named simulation scenario bundling policy parameters with a funding model.""" @@ -98,13 +101,17 @@ class ScenarioParams(BaseModel): policy_params: list[PolicyParams] = Field(default_factory=list, description="Active policies in this scenario") funding_model: FundingModel = Field(..., description="Funding model type (capitation / ffs / hybrid)") capitation_rate: float | None = Field(None, ge=0.0, description="Per-patient-per-month capitation rate (NZD)") - ffs_fee_schedule: dict[str, float] | None = Field(None, description="FFS schedule mapping visit type codes to NZD amounts") + ffs_fee_schedule: dict[str, float] | None = Field( + None, description="FFS schedule mapping visit type codes to NZD amounts" + ) @model_validator(mode="after") def validate_funding_params(self) -> ScenarioParams: if self.funding_model == FundingModel.CAPITATION and self.capitation_rate is None: raise ValueError("capitation_rate required when funding_model='capitation'") - if self.funding_model == FundingModel.FFS and (self.ffs_fee_schedule is None or len(self.ffs_fee_schedule) == 0): + if self.funding_model == FundingModel.FFS and ( + self.ffs_fee_schedule is None or len(self.ffs_fee_schedule) == 0 + ): raise ValueError("ffs_fee_schedule required when funding_model='ffs'") if self.funding_model == FundingModel.HYBRID: if self.capitation_rate is None: @@ -121,8 +128,12 @@ class PatientProfile(BaseModel): gender: Gender = Field(..., description="Patient gender") ethnicity: Ethnicity = Field(..., description="Patient ethnicity group") deprivation_index: int = Field(..., ge=1, le=10, description="NZDep2018 deprivation decile (1=least, 10=most)") - comorbidities: list[str] = Field(default_factory=list, description="Chronic condition codes e.g. ['diabetes','asthma']") - enrollment_status: EnrollmentStatus = Field(default=EnrollmentStatus.ENROLLED, description="Current enrollment status") + comorbidities: list[str] = Field( + default_factory=list, description="Chronic condition codes e.g. ['diabetes','asthma']" + ) + enrollment_status: EnrollmentStatus = Field( + default=EnrollmentStatus.ENROLLED, description="Current enrollment status" + ) @field_validator("comorbidities") @classmethod diff --git a/models/primarycare_model/shap_explainer.py b/models/primarycare_model/shap_explainer.py index 28b09c7..746dca0 100644 --- a/models/primarycare_model/shap_explainer.py +++ b/models/primarycare_model/shap_explainer.py @@ -101,25 +101,23 @@ def to_arrow_records(self) -> list[dict[str, Any]]: """Flatten SHAP values into one row per sample-feature pair.""" records: list[dict[str, Any]] = [] values = self._feature_matrix() - base_value = ( - float(self.base_values) - if np.ndim(self.base_values) == 0 - else float(np.mean(self.base_values)) - ) + base_value = float(self.base_values) if np.ndim(self.base_values) == 0 else float(np.mean(self.base_values)) for row_idx in range(values.shape[0]): batch_idx = int(self.batch_indices[row_idx]) if self.batch_indices is not None else row_idx for feature_idx, feature_name in enumerate(self.feature_names): - records.append({ - "scenario_name": self.scenario_name, - "month": 0, - "batch_idx": batch_idx, - "feature_name": feature_name, - "shap_value": float(values[row_idx, feature_idx]), - "feature_value": float(self.data[row_idx, feature_idx]) if self.data is not None else None, - "base_value": base_value, - "explainer_type": self.explainer_type, - }) + records.append( + { + "scenario_name": self.scenario_name, + "month": 0, + "batch_idx": batch_idx, + "feature_name": feature_name, + "shap_value": float(values[row_idx, feature_idx]), + "feature_value": float(self.data[row_idx, feature_idx]) if self.data is not None else None, + "base_value": base_value, + "explainer_type": self.explainer_type, + } + ) return records def to_arrow_table(self) -> pa.Table: @@ -131,23 +129,21 @@ def compute_summary(self) -> list[dict[str, Any]]: values = self._feature_matrix() mean_abs = np.mean(np.abs(values), axis=0) std_values = np.std(values, axis=0) - mean_features = ( - np.mean(self.data, axis=0) - if self.data is not None - else np.full(values.shape[1], np.nan) - ) + mean_features = np.mean(self.data, axis=0) if self.data is not None else np.full(values.shape[1], np.nan) records: list[dict[str, Any]] = [] for rank, feature_idx in enumerate(np.argsort(-mean_abs), start=1): mean_feature = mean_features[feature_idx] - records.append({ - "scenario_name": self.scenario_name, - "feature_name": self.feature_names[feature_idx], - "mean_abs_shap": float(mean_abs[feature_idx]), - "std_shap": float(std_values[feature_idx]), - "mean_feature_value": float(mean_feature) if not np.isnan(mean_feature) else None, - "importance_rank": rank, - }) + records.append( + { + "scenario_name": self.scenario_name, + "feature_name": self.feature_names[feature_idx], + "mean_abs_shap": float(mean_abs[feature_idx]), + "std_shap": float(std_values[feature_idx]), + "mean_feature_value": float(mean_feature) if not np.isnan(mean_feature) else None, + "importance_rank": rank, + } + ) return records def to_summary_table(self) -> pa.Table: diff --git a/models/primarycare_model/ui/accessibility.py b/models/primarycare_model/ui/accessibility.py index f8af2c2..e95df60 100644 --- a/models/primarycare_model/ui/accessibility.py +++ b/models/primarycare_model/ui/accessibility.py @@ -3,9 +3,16 @@ from __future__ import annotations REQUIRED_CHART_FIELDS = { - "title", "unit", "claim_level", "calibration_status", "uncertainty_type", - "source_snapshot_id", "interpretation_note", "not_valid_for_warning", - "downloadable_data", "table_fallback", + "title", + "unit", + "claim_level", + "calibration_status", + "uncertainty_type", + "source_snapshot_id", + "interpretation_note", + "not_valid_for_warning", + "downloadable_data", + "table_fallback", } diff --git a/models/primarycare_model/ui/cockpit.py b/models/primarycare_model/ui/cockpit.py index 441dda0..02489e0 100644 --- a/models/primarycare_model/ui/cockpit.py +++ b/models/primarycare_model/ui/cockpit.py @@ -8,8 +8,17 @@ from models.primarycare_model.voi.full_voi import run_full_voi REQUIRED_SECTIONS = ( - "cockpit", "scenario_frontier", "causal_graph", "policy_manifold", "calibration", - "uncertainty", "voi", "equity", "sources", "release", "downloads", + "cockpit", + "scenario_frontier", + "causal_graph", + "policy_manifold", + "calibration", + "uncertainty", + "voi", + "equity", + "sources", + "release", + "downloads", ) REQUIRED_VISUALS = ( diff --git a/models/primarycare_model/uncertainty/structural_ensemble.py b/models/primarycare_model/uncertainty/structural_ensemble.py index 89f02a3..df2c5cc 100644 --- a/models/primarycare_model/uncertainty/structural_ensemble.py +++ b/models/primarycare_model/uncertainty/structural_ensemble.py @@ -35,12 +35,14 @@ def run_structural_ensemble(base_score: float = 60.0) -> dict[str, object]: rows = [] for idx, model in enumerate(load_structural_models(), start=1): shift = (idx - 4.5) * 1.75 - rows.append({ - "structural_model_id": model.structural_model_id, - "score": round(base_score + shift, 3), - "plausibility_weight": model.plausibility_weight, - "claim_boundary": model.claim_boundary, - }) + rows.append( + { + "structural_model_id": model.structural_model_id, + "score": round(base_score + shift, 3), + "plausibility_weight": model.plausibility_weight, + "claim_boundary": model.claim_boundary, + } + ) scores = [row["score"] for row in rows] return { "uncertainty_status": "parameter_and_structural_uncertainty_reported", diff --git a/models/primarycare_model/validation/arrow_schemas.py b/models/primarycare_model/validation/arrow_schemas.py index 4511ad5..c5002d2 100644 --- a/models/primarycare_model/validation/arrow_schemas.py +++ b/models/primarycare_model/validation/arrow_schemas.py @@ -26,245 +26,293 @@ # -- Registry frames -------------------------------------------------------- + def parameter_registry_schema() -> PyArrowSchema | None: """PyArrow schema for a parameter-definition registry table.""" if pa is None: return None - return pa.schema([ - pa.field("parameter_id", pa.string(), nullable=False), - pa.field("label", pa.string(), nullable=False), - pa.field("value_type", pa.string(), nullable=False), - pa.field("unit", pa.string(), nullable=False), - pa.field("default_value", pa.string(), nullable=False), - pa.field("lower_bound", pa.float64(), nullable=True), - pa.field("upper_bound", pa.float64(), nullable=True), - pa.field("category_values", pa.list_(pa.string()), nullable=True), - pa.field("description", pa.string(), nullable=False), - pa.field("source", pa.string(), nullable=False), - pa.field("sensitivity_class", pa.string(), nullable=False), - pa.field("evidence_tier", pa.string(), nullable=False), - pa.field("tags", pa.list_(pa.string()), nullable=True), - ]) + return pa.schema( + [ + pa.field("parameter_id", pa.string(), nullable=False), + pa.field("label", pa.string(), nullable=False), + pa.field("value_type", pa.string(), nullable=False), + pa.field("unit", pa.string(), nullable=False), + pa.field("default_value", pa.string(), nullable=False), + pa.field("lower_bound", pa.float64(), nullable=True), + pa.field("upper_bound", pa.float64(), nullable=True), + pa.field("category_values", pa.list_(pa.string()), nullable=True), + pa.field("description", pa.string(), nullable=False), + pa.field("source", pa.string(), nullable=False), + pa.field("sensitivity_class", pa.string(), nullable=False), + pa.field("evidence_tier", pa.string(), nullable=False), + pa.field("tags", pa.list_(pa.string()), nullable=True), + ] + ) + def educational_lever_registry_schema() -> PyArrowSchema | None: """PyArrow schema for an educational-lever registry table.""" if pa is None: return None - return pa.schema([ - pa.field("field_name", pa.string(), nullable=False), - pa.field("public_label", pa.string(), nullable=False), - pa.field("health_economics_meaning", pa.string(), nullable=False), - pa.field("high_value_meaning", pa.string(), nullable=False), - pa.field("educational_output_effect", pa.string(), nullable=False), - pa.field("slider_help", pa.string(), nullable=False), - pa.field("default_value", pa.int64(), nullable=False), - pa.field("lower_bound", pa.int64(), nullable=False), - pa.field("upper_bound", pa.int64(), nullable=False), - pa.field("step", pa.int64(), nullable=False), - pa.field("source", pa.string(), nullable=False), - pa.field("claim_boundary", pa.string(), nullable=False), - ]) + return pa.schema( + [ + pa.field("field_name", pa.string(), nullable=False), + pa.field("public_label", pa.string(), nullable=False), + pa.field("health_economics_meaning", pa.string(), nullable=False), + pa.field("high_value_meaning", pa.string(), nullable=False), + pa.field("educational_output_effect", pa.string(), nullable=False), + pa.field("slider_help", pa.string(), nullable=False), + pa.field("default_value", pa.int64(), nullable=False), + pa.field("lower_bound", pa.int64(), nullable=False), + pa.field("upper_bound", pa.int64(), nullable=False), + pa.field("step", pa.int64(), nullable=False), + pa.field("source", pa.string(), nullable=False), + pa.field("claim_boundary", pa.string(), nullable=False), + ] + ) + def scenario_registry_schema() -> PyArrowSchema | None: """PyArrow schema for a runtime-scenario registry table.""" if pa is None: return None - return pa.schema([ - pa.field("scenario_id", pa.string(), nullable=False), - pa.field("scenario_name", pa.string(), nullable=False), - pa.field("description", pa.string(), nullable=False), - pa.field("scenario_kind", pa.string(), nullable=False), - pa.field("activity_signal", pa.float64(), nullable=False), - pa.field("capitation", pa.float64(), nullable=False), - pa.field("place_accountability", pa.float64(), nullable=False), - pa.field("scope_capacity", pa.float64(), nullable=False), - pa.field("urgent_ambulance", pa.float64(), nullable=False), - pa.field("data_visibility", pa.float64(), nullable=False), - pa.field("governance", pa.float64(), nullable=False), - pa.field("equity_protection", pa.float64(), nullable=False), - pa.field("copayment_burden", pa.float64(), nullable=False), - pa.field("budget_tightness", pa.float64(), nullable=False), - pa.field("hospital_salience", pa.float64(), nullable=False), - pa.field("complexity", pa.float64(), nullable=False), - pa.field("source", pa.string(), nullable=False), - pa.field("claim_boundary", pa.string(), nullable=False), - ]) + return pa.schema( + [ + pa.field("scenario_id", pa.string(), nullable=False), + pa.field("scenario_name", pa.string(), nullable=False), + pa.field("description", pa.string(), nullable=False), + pa.field("scenario_kind", pa.string(), nullable=False), + pa.field("activity_signal", pa.float64(), nullable=False), + pa.field("capitation", pa.float64(), nullable=False), + pa.field("place_accountability", pa.float64(), nullable=False), + pa.field("scope_capacity", pa.float64(), nullable=False), + pa.field("urgent_ambulance", pa.float64(), nullable=False), + pa.field("data_visibility", pa.float64(), nullable=False), + pa.field("governance", pa.float64(), nullable=False), + pa.field("equity_protection", pa.float64(), nullable=False), + pa.field("copayment_burden", pa.float64(), nullable=False), + pa.field("budget_tightness", pa.float64(), nullable=False), + pa.field("hospital_salience", pa.float64(), nullable=False), + pa.field("complexity", pa.float64(), nullable=False), + pa.field("source", pa.string(), nullable=False), + pa.field("claim_boundary", pa.string(), nullable=False), + ] + ) + def input_dataset_registry_schema() -> PyArrowSchema | None: """PyArrow schema for an input-dataset registry table.""" if pa is None: return None - return pa.schema([ - pa.field("dataset_id", pa.string(), nullable=False), - pa.field("label", pa.string(), nullable=False), - pa.field("source", pa.string(), nullable=False), - pa.field("sensitivity_class", pa.string(), nullable=False), - pa.field("fields", pa.list_(pa.struct([ - pa.field("field_name", pa.string(), nullable=False), - pa.field("data_type", pa.string(), nullable=False), - pa.field("unit", pa.string(), nullable=False), - pa.field("required", pa.bool_(), nullable=False), - pa.field("description", pa.string(), nullable=False), - ])), nullable=True), - pa.field("claim_boundary", pa.string(), nullable=False), - ]) + return pa.schema( + [ + pa.field("dataset_id", pa.string(), nullable=False), + pa.field("label", pa.string(), nullable=False), + pa.field("source", pa.string(), nullable=False), + pa.field("sensitivity_class", pa.string(), nullable=False), + pa.field( + "fields", + pa.list_( + pa.struct( + [ + pa.field("field_name", pa.string(), nullable=False), + pa.field("data_type", pa.string(), nullable=False), + pa.field("unit", pa.string(), nullable=False), + pa.field("required", pa.bool_(), nullable=False), + pa.field("description", pa.string(), nullable=False), + ] + ) + ), + nullable=True, + ), + pa.field("claim_boundary", pa.string(), nullable=False), + ] + ) + def input_table_schema() -> PyArrowSchema | None: """PyArrow schema for a generic input-data table.""" if pa is None: return None - return pa.schema([ - pa.field("dataset_id", pa.string(), nullable=False), - pa.field("row_index", pa.int64(), nullable=False), - ])# -- Calculation output frames ---------------------------------------------- + return pa.schema( + [ + pa.field("dataset_id", pa.string(), nullable=False), + pa.field("row_index", pa.int64(), nullable=False), + ] + ) # -- Calculation output frames ---------------------------------------------- + def reference_result_schema() -> PyArrowSchema | None: """PyArrow schema for the public reference-scenario result frame.""" if pa is None: return None - return pa.schema([ - pa.field("scenario_id", pa.string(), nullable=False), - pa.field("scenario_name", pa.string(), nullable=False), - pa.field("description", pa.string(), nullable=False), - pa.field("hybrid_viability_score", pa.float64(), nullable=False), - pa.field("access_score", pa.float64(), nullable=False), - pa.field("supply_generation_score", pa.float64(), nullable=False), - pa.field("equity_legitimacy_score", pa.float64(), nullable=False), - pa.field("governance_resilience_score", pa.float64(), nullable=False), - pa.field("hospital_deflection_score", pa.float64(), nullable=False), - pa.field("fiscal_risk_score", pa.float64(), nullable=False), - pa.field("gaming_risk_score", pa.float64(), nullable=False), - pa.field("hospital_pressure_score", pa.float64(), nullable=False), - pa.field("mean_last12_public_cost_index", pa.float64(), nullable=False), - pa.field("rank_by_hybrid_viability", pa.int64(), nullable=False), - pa.field("mean_last12_primary_contacts_per_1000", pa.float64(), nullable=True), - pa.field("mean_last12_unmet_need_index", pa.float64(), nullable=True), - pa.field("mean_last12_ed_events_per_100k", pa.float64(), nullable=True), - pa.field("mean_last12_admissions_per_100k", pa.float64(), nullable=True), - pa.field("mean_last12_ambulance_conveyances_per_100k", pa.float64(), nullable=True), - pa.field("mean_last12_hospital_pressure_index", pa.float64(), nullable=True), - pa.field("calculation_status", pa.string(), nullable=True), - pa.field("scenario_role", pa.string(), nullable=True), - pa.field("claim_boundary", pa.string(), nullable=True), - ]) + return pa.schema( + [ + pa.field("scenario_id", pa.string(), nullable=False), + pa.field("scenario_name", pa.string(), nullable=False), + pa.field("description", pa.string(), nullable=False), + pa.field("hybrid_viability_score", pa.float64(), nullable=False), + pa.field("access_score", pa.float64(), nullable=False), + pa.field("supply_generation_score", pa.float64(), nullable=False), + pa.field("equity_legitimacy_score", pa.float64(), nullable=False), + pa.field("governance_resilience_score", pa.float64(), nullable=False), + pa.field("hospital_deflection_score", pa.float64(), nullable=False), + pa.field("fiscal_risk_score", pa.float64(), nullable=False), + pa.field("gaming_risk_score", pa.float64(), nullable=False), + pa.field("hospital_pressure_score", pa.float64(), nullable=False), + pa.field("mean_last12_public_cost_index", pa.float64(), nullable=False), + pa.field("rank_by_hybrid_viability", pa.int64(), nullable=False), + pa.field("mean_last12_primary_contacts_per_1000", pa.float64(), nullable=True), + pa.field("mean_last12_unmet_need_index", pa.float64(), nullable=True), + pa.field("mean_last12_ed_events_per_100k", pa.float64(), nullable=True), + pa.field("mean_last12_admissions_per_100k", pa.float64(), nullable=True), + pa.field("mean_last12_ambulance_conveyances_per_100k", pa.float64(), nullable=True), + pa.field("mean_last12_hospital_pressure_index", pa.float64(), nullable=True), + pa.field("calculation_status", pa.string(), nullable=True), + pa.field("scenario_role", pa.string(), nullable=True), + pa.field("claim_boundary", pa.string(), nullable=True), + ] + ) + def monthly_metrics_schema() -> PyArrowSchema | None: """PyArrow schema for the stock-flow monthly metrics frame.""" if pa is None: return None - return pa.schema([ - pa.field("month", pa.int64(), nullable=False), - pa.field("scenario_id", pa.string(), nullable=False), - pa.field("need_generated", pa.float64(), nullable=False), - pa.field("primary_contacts", pa.float64(), nullable=False), - pa.field("ambulance_resolved", pa.float64(), nullable=False), - pa.field("unmet_need", pa.float64(), nullable=False), - pa.field("primary_capacity", pa.float64(), nullable=False), - pa.field("hospital_pressure", pa.float64(), nullable=False), - pa.field("fiscal_pressure", pa.float64(), nullable=False), - pa.field("calculation_status", pa.string(), nullable=True), - ]) + return pa.schema( + [ + pa.field("month", pa.int64(), nullable=False), + pa.field("scenario_id", pa.string(), nullable=False), + pa.field("need_generated", pa.float64(), nullable=False), + pa.field("primary_contacts", pa.float64(), nullable=False), + pa.field("ambulance_resolved", pa.float64(), nullable=False), + pa.field("unmet_need", pa.float64(), nullable=False), + pa.field("primary_capacity", pa.float64(), nullable=False), + pa.field("hospital_pressure", pa.float64(), nullable=False), + pa.field("fiscal_pressure", pa.float64(), nullable=False), + pa.field("calculation_status", pa.string(), nullable=True), + ] + ) + def simulation_trace_schema() -> PyArrowSchema | None: """PyArrow schema for a per-scenario calculation trace.""" if pa is None: return None - return pa.schema([ - pa.field("calculation", pa.string(), nullable=False), - pa.field("formula_sketch", pa.string(), nullable=False), - pa.field("index_value", pa.float64(), nullable=False), - ]) + return pa.schema( + [ + pa.field("calculation", pa.string(), nullable=False), + pa.field("formula_sketch", pa.string(), nullable=False), + pa.field("index_value", pa.float64(), nullable=False), + ] + ) + def stochastic_draw_schema() -> PyArrowSchema | None: """PyArrow schema for the full Monte Carlo draw frame.""" if pa is None: return None - return pa.schema([ - pa.field("draw", pa.int64(), nullable=False), - pa.field("scenario_id", pa.string(), nullable=False), - pa.field("scenario_name", pa.string(), nullable=False), - pa.field("hybrid_viability_score", pa.float64(), nullable=False), - pa.field("access_score", pa.float64(), nullable=False), - pa.field("supply_generation_score", pa.float64(), nullable=False), - pa.field("equity_legitimacy_score", pa.float64(), nullable=False), - pa.field("governance_resilience_score", pa.float64(), nullable=False), - pa.field("hospital_deflection_score", pa.float64(), nullable=False), - pa.field("fiscal_risk_score", pa.float64(), nullable=False), - pa.field("gaming_risk_score", pa.float64(), nullable=False), - pa.field("hospital_pressure_score", pa.float64(), nullable=False), - pa.field("calculation_status", pa.string(), nullable=True), - ]) + return pa.schema( + [ + pa.field("draw", pa.int64(), nullable=False), + pa.field("scenario_id", pa.string(), nullable=False), + pa.field("scenario_name", pa.string(), nullable=False), + pa.field("hybrid_viability_score", pa.float64(), nullable=False), + pa.field("access_score", pa.float64(), nullable=False), + pa.field("supply_generation_score", pa.float64(), nullable=False), + pa.field("equity_legitimacy_score", pa.float64(), nullable=False), + pa.field("governance_resilience_score", pa.float64(), nullable=False), + pa.field("hospital_deflection_score", pa.float64(), nullable=False), + pa.field("fiscal_risk_score", pa.float64(), nullable=False), + pa.field("gaming_risk_score", pa.float64(), nullable=False), + pa.field("hospital_pressure_score", pa.float64(), nullable=False), + pa.field("calculation_status", pa.string(), nullable=True), + ] + ) # -- Uncertainty / summary frames ------------------------------------------- + def uncertainty_summary_schema() -> PyArrowSchema | None: """PyArrow schema for the per-metric uncertainty summary.""" if pa is None: return None - return pa.schema([ - pa.field("metric", pa.string(), nullable=False), - pa.field("mean", pa.float64(), nullable=False), - pa.field("std", pa.float64(), nullable=False), - pa.field("p05", pa.float64(), nullable=False), - pa.field("p50", pa.float64(), nullable=False), - pa.field("p95", pa.float64(), nullable=False), - pa.field("draws", pa.int64(), nullable=False), - ]) + return pa.schema( + [ + pa.field("metric", pa.string(), nullable=False), + pa.field("mean", pa.float64(), nullable=False), + pa.field("std", pa.float64(), nullable=False), + pa.field("p05", pa.float64(), nullable=False), + pa.field("p50", pa.float64(), nullable=False), + pa.field("p95", pa.float64(), nullable=False), + pa.field("draws", pa.int64(), nullable=False), + ] + ) + def agent_frame_schema() -> PyArrowSchema | None: """PyArrow schema for the agent-lens patient-level frame.""" if pa is None: return None - return pa.schema([ - pa.field("patient_id", pa.int64(), nullable=False), - pa.field("high_need_score", pa.float64(), nullable=False), - pa.field("rural", pa.bool_(), nullable=False), - pa.field("access_barrier", pa.float64(), nullable=False), - pa.field("access_probability", pa.float64(), nullable=False), - pa.field("served_contacts", pa.int64(), nullable=False), - pa.field("unmet_attempts", pa.int64(), nullable=False), - ]) + return pa.schema( + [ + pa.field("patient_id", pa.int64(), nullable=False), + pa.field("high_need_score", pa.float64(), nullable=False), + pa.field("rural", pa.bool_(), nullable=False), + pa.field("access_barrier", pa.float64(), nullable=False), + pa.field("access_probability", pa.float64(), nullable=False), + pa.field("served_contacts", pa.int64(), nullable=False), + pa.field("unmet_attempts", pa.int64(), nullable=False), + ] + ) + def agent_summary_schema() -> PyArrowSchema | None: """PyArrow schema for the agent-lens summary frame.""" if pa is None: return None - return pa.schema([ - pa.field("metric", pa.string(), nullable=False), - pa.field("value", pa.float64(), nullable=False), - ]) + return pa.schema( + [ + pa.field("metric", pa.string(), nullable=False), + pa.field("value", pa.float64(), nullable=False), + ] + ) # -- Public export ---------------------------------------------------------- + def public_export_schema() -> PyArrowSchema | None: """PyArrow schema for the public-facing export table.""" if pa is None: return None - return pa.schema([ - pa.field("scenario_id", pa.string(), nullable=False), - pa.field("scenario_name", pa.string(), nullable=False), - pa.field("description", pa.string(), nullable=False), - pa.field("scenario_role", pa.string(), nullable=True), - pa.field("hybrid_viability_score", pa.float64(), nullable=False), - pa.field("access_score", pa.float64(), nullable=False), - pa.field("supply_generation_score", pa.float64(), nullable=False), - pa.field("equity_legitimacy_score", pa.float64(), nullable=False), - pa.field("governance_resilience_score", pa.float64(), nullable=False), - pa.field("hospital_deflection_score", pa.float64(), nullable=False), - pa.field("fiscal_risk_score", pa.float64(), nullable=False), - pa.field("gaming_risk_score", pa.float64(), nullable=False), - pa.field("hospital_pressure_score", pa.float64(), nullable=False), - pa.field("mean_last12_public_cost_index", pa.float64(), nullable=False), - pa.field("rank_by_hybrid_viability", pa.int64(), nullable=False), - pa.field("claim_boundary", pa.string(), nullable=True), - pa.field("calculation_status", pa.string(), nullable=True), - ]) + return pa.schema( + [ + pa.field("scenario_id", pa.string(), nullable=False), + pa.field("scenario_name", pa.string(), nullable=False), + pa.field("description", pa.string(), nullable=False), + pa.field("scenario_role", pa.string(), nullable=True), + pa.field("hybrid_viability_score", pa.float64(), nullable=False), + pa.field("access_score", pa.float64(), nullable=False), + pa.field("supply_generation_score", pa.float64(), nullable=False), + pa.field("equity_legitimacy_score", pa.float64(), nullable=False), + pa.field("governance_resilience_score", pa.float64(), nullable=False), + pa.field("hospital_deflection_score", pa.float64(), nullable=False), + pa.field("fiscal_risk_score", pa.float64(), nullable=False), + pa.field("gaming_risk_score", pa.float64(), nullable=False), + pa.field("hospital_pressure_score", pa.float64(), nullable=False), + pa.field("mean_last12_public_cost_index", pa.float64(), nullable=False), + pa.field("rank_by_hybrid_viability", pa.int64(), nullable=False), + pa.field("claim_boundary", pa.string(), nullable=True), + pa.field("calculation_status", pa.string(), nullable=True), + ] + ) # -- Compatibility helper --------------------------------------------------- + def as_pandas_dtypes(schema: PyArrowSchema | None) -> dict[str, str]: """Convert a PyArrow schema to a pandas dtype dictionary.""" if pa is None or schema is None: @@ -306,10 +354,12 @@ def as_pandas_dtypes(schema: PyArrowSchema | None) -> dict[str, str]: "public_export": public_export_schema(), } + def get_schema(name: str) -> PyArrowSchema | None: """Retrieve a named PyArrow schema, or None if unavailable.""" return _SCHEMA_REGISTRY.get(name) + def registered_schema_names() -> list[str]: """Return sorted list of available schema names.""" return sorted(_SCHEMA_REGISTRY) diff --git a/models/primarycare_model/validation/registry_loader.py b/models/primarycare_model/validation/registry_loader.py index 0765cd7..58f00f7 100644 --- a/models/primarycare_model/validation/registry_loader.py +++ b/models/primarycare_model/validation/registry_loader.py @@ -158,10 +158,7 @@ def load_inputs_registry() -> tuple[InputDataset, ...]: def input_dataset_defaults() -> dict[str, dict[str, Any]]: """Return a dictionary of dataset_id -> {field_name: required} summary.""" - return { - ds.dataset_id: {field.field_name: field.required for field in ds.fields} - for ds in load_inputs_registry() - } + return {ds.dataset_id: {field.field_name: field.required for field in ds.fields} for ds in load_inputs_registry()} # ── Provenance registry ──────────────────────────────────────────────── @@ -255,7 +252,16 @@ def build_oia_component_dataframe() -> list[dict[str, str]]: entries = load_oia_component_map_registry() result = [] for e in sorted(entries, key=lambda x: x.oia_id): - result.append({"oia_id": e.oia_id, "topic": e.topic, "component_type": e.component_type, "component_path": e.component_path, "chart_or_table": e.chart_or_table, "impact_description": e.impact_description}) + result.append( + { + "oia_id": e.oia_id, + "topic": e.topic, + "component_type": e.component_type, + "component_path": e.component_path, + "chart_or_table": e.chart_or_table, + "impact_description": e.impact_description, + } + ) return result diff --git a/models/primarycare_model/validation/runtime_checks.py b/models/primarycare_model/validation/runtime_checks.py index 5ffba49..127bc21 100644 --- a/models/primarycare_model/validation/runtime_checks.py +++ b/models/primarycare_model/validation/runtime_checks.py @@ -134,6 +134,7 @@ def check_scenario_overrides(overrides: list[dict[str, Any]], known_ids: set[str "description", } + def check_result_frame_bounds(df: pd.DataFrame) -> list[str]: """Lightweight bounds check on a reference result DataFrame. @@ -174,10 +175,7 @@ def check_result_frame_bounds(df: pd.DataFrame) -> list[str]: if out_of_range.any(): bad_indices = out_of_range[out_of_range].index.tolist() bad_values = df.loc[bad_indices, column].tolist() - issues.append( - f"{column}: {len(bad_indices)} value(s) outside " - f"[{lo}, {hi}]: {bad_values[:5]}" - ) + issues.append(f"{column}: {len(bad_indices)} value(s) outside [{lo}, {hi}]: {bad_values[:5]}") # Check that scenario_id is not empty if "scenario_id" in df.columns: @@ -223,4 +221,3 @@ def format_validation_issues(issues: list[str]) -> str: lines.append(f"- {issue}") return "\n".join(lines) - diff --git a/models/tests/test_app.py b/models/tests/test_app.py index 9c54624..6818a6d 100644 --- a/models/tests/test_app.py +++ b/models/tests/test_app.py @@ -3,6 +3,7 @@ APP_PATH = "models/primarycare_model/app.py" DEPLOYMENT_ENTRYPOINT = "streamlit_app.py" + def test_app_smoke(): """Basic smoke test to ensure the app can be initialized.""" at = AppTest.from_file(APP_PATH, default_timeout=90) @@ -16,6 +17,7 @@ def test_deployment_entrypoint_smoke(): at.run() assert not at.exception + def test_app_sliders_exist(): """Verify that the expected sliders are present in the sidebar.""" at = AppTest.from_file(APP_PATH, default_timeout=90) @@ -36,6 +38,7 @@ def test_app_sliders_exist(): slider_labels = [s.label for s in at.sidebar.slider] assert expected_labels.issubset(set(slider_labels)) + def test_app_reactive_logic(): """Verify that changing a slider updates the internal state.""" at = AppTest.from_file(APP_PATH, default_timeout=90) @@ -46,10 +49,10 @@ def test_app_reactive_logic(): assert not at.exception + def test_app_expander_exists(): """Verify the educational section is present.""" at = AppTest.from_file(APP_PATH, default_timeout=90) at.run() assert len(at.expander) > 0 assert "Learn the big words" in at.expander[0].label - diff --git a/models/tests/test_concern_boundaries.py b/models/tests/test_concern_boundaries.py index 5d7aab8..b24fc3f 100644 --- a/models/tests/test_concern_boundaries.py +++ b/models/tests/test_concern_boundaries.py @@ -1,4 +1,5 @@ """Verify concern-boundary rules: no Streamlit imports in strict layers.""" + from __future__ import annotations import ast @@ -38,8 +39,10 @@ def _imports_streamlit(path): a.name == "streamlit" or a.name.startswith("streamlit.") for a in node.names ): return True - if isinstance(node, ast.ImportFrom) and node.module and ( - node.module == "streamlit" or node.module.startswith("streamlit.") + if ( + isinstance(node, ast.ImportFrom) + and node.module + and (node.module == "streamlit" or node.module.startswith("streamlit.")) ): return True return False diff --git a/models/tests/test_conductor_parallel_tracks.py b/models/tests/test_conductor_parallel_tracks.py index 077d635..de06d7d 100644 --- a/models/tests/test_conductor_parallel_tracks.py +++ b/models/tests/test_conductor_parallel_tracks.py @@ -7,7 +7,9 @@ def test_conductor_parallel_track_gate_passes() -> None: - result = subprocess.run([sys.executable, "scripts/check_conductor_parallel_tracks.py"], text=True, capture_output=True) + result = subprocess.run( + [sys.executable, "scripts/check_conductor_parallel_tracks.py"], text=True, capture_output=True + ) assert result.returncode == 0, result.stdout + result.stderr diff --git a/models/tests/test_contract_registries.py b/models/tests/test_contract_registries.py index 9463027..48f5b4e 100644 --- a/models/tests/test_contract_registries.py +++ b/models/tests/test_contract_registries.py @@ -129,13 +129,9 @@ def test_parameters_registry_all_defaults_within_bounds(): if param.value_type in ("integer", "number"): dv = float(param.default_value) if param.lower_bound is not None: - assert dv >= param.lower_bound, ( - f"{param.parameter_id}: default {dv} < lower_bound {param.lower_bound}" - ) + assert dv >= param.lower_bound, f"{param.parameter_id}: default {dv} < lower_bound {param.lower_bound}" if param.upper_bound is not None: - assert dv <= param.upper_bound, ( - f"{param.parameter_id}: default {dv} > upper_bound {param.upper_bound}" - ) + assert dv <= param.upper_bound, f"{param.parameter_id}: default {dv} > upper_bound {param.upper_bound}" if param.value_type == "categorical": assert param.default_value in param.category_values @@ -188,7 +184,6 @@ def test_input_dataset_defaults_summary(): assert any(required for required in fields.values()) - # ── Duplicate detection ───────────────────────────────────────────── @@ -198,16 +193,26 @@ def test_registry_duplicate_field_name_rejected(): levers = ( EducationalLeverDefinition( - field_name="dup_field", public_label="A", - health_economics_meaning="x", high_value_meaning="x", - educational_output_effect="x", slider_help="x", - default_value=50, source="test", claim_boundary="test", + field_name="dup_field", + public_label="A", + health_economics_meaning="x", + high_value_meaning="x", + educational_output_effect="x", + slider_help="x", + default_value=50, + source="test", + claim_boundary="test", ), EducationalLeverDefinition( - field_name="dup_field", public_label="B", - health_economics_meaning="x", high_value_meaning="x", - educational_output_effect="x", slider_help="x", - default_value=60, source="test", claim_boundary="test", + field_name="dup_field", + public_label="B", + health_economics_meaning="x", + high_value_meaning="x", + educational_output_effect="x", + slider_help="x", + default_value=60, + source="test", + claim_boundary="test", ), ) with pytest.raises(ValueError, match="Duplicate field_name"): @@ -220,102 +225,167 @@ def test_scenario_duplicate_id_rejected(): scenarios = ( RuntimeScenarioDefinition( - scenario_id="F99", scenario_name="A", description="x", - activity_signal=50.0, capitation=50.0, - place_accountability=50.0, scope_capacity=50.0, - urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, - copayment_burden=50.0, budget_tightness=50.0, - hospital_salience=50.0, complexity=50.0, - source="test", claim_boundary="test", + scenario_id="F99", + scenario_name="A", + description="x", + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + source="test", + claim_boundary="test", ), RuntimeScenarioDefinition( - scenario_id="F99", scenario_name="B", description="x", - activity_signal=50.0, capitation=50.0, - place_accountability=50.0, scope_capacity=50.0, - urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, - copayment_burden=50.0, budget_tightness=50.0, - hospital_salience=50.0, complexity=50.0, - source="test", claim_boundary="test", + scenario_id="F99", + scenario_name="B", + description="x", + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + source="test", + claim_boundary="test", ), ) with pytest.raises(ValueError, match="Duplicate scenario_id"): _check_unique(scenarios, "scenario_id", "runtime scenario") + # ── Contract boundary validation ──────────────────────────────────── def test_contracts_reject_unknown_fields_and_out_of_range_values(): with pytest.raises(ValidationError): EducationalLeverDefinition( - field_name="bad", public_label="Bad", - health_economics_meaning="Bad", high_value_meaning="Bad", - educational_output_effect="Bad", slider_help="Bad", - default_value=101, source="test", claim_boundary="test", + field_name="bad", + public_label="Bad", + health_economics_meaning="Bad", + high_value_meaning="Bad", + educational_output_effect="Bad", + slider_help="Bad", + default_value=101, + source="test", + claim_boundary="test", ) with pytest.raises(ValidationError): RuntimeScenarioDefinition( - scenario_id="BAD", scenario_name="Bad", description="Bad", - activity_signal=120.0, capitation=50.0, - place_accountability=50.0, scope_capacity=50.0, - urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, - copayment_burden=50.0, budget_tightness=50.0, - hospital_salience=50.0, complexity=50.0, - source="test", claim_boundary="test", + scenario_id="BAD", + scenario_name="Bad", + description="Bad", + activity_signal=120.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + source="test", + claim_boundary="test", ) def test_educational_lever_rejects_lower_bound_exceeding_upper(): with pytest.raises(ValidationError, match="lower_bound cannot exceed"): EducationalLeverDefinition( - field_name="bad_bounds", public_label="Bad bounds", - health_economics_meaning="x", high_value_meaning="x", - educational_output_effect="x", slider_help="x", - default_value=50, lower_bound=80, upper_bound=20, - source="test", claim_boundary="test", + field_name="bad_bounds", + public_label="Bad bounds", + health_economics_meaning="x", + high_value_meaning="x", + educational_output_effect="x", + slider_help="x", + default_value=50, + lower_bound=80, + upper_bound=20, + source="test", + claim_boundary="test", ) def test_educational_lever_rejects_default_outside_bounds(): # Use bounds wide enough to pass field-level checks but trigger model validator - with pytest.raises(ValidationError, match=r"default_value must sit within|less than or equal|greater than or equal"): + with pytest.raises( + ValidationError, match=r"default_value must sit within|less than or equal|greater than or equal" + ): EducationalLeverDefinition( - field_name="bad_default", public_label="Bad default", - health_economics_meaning="x", high_value_meaning="x", - educational_output_effect="x", slider_help="x", - default_value=120, lower_bound=0, upper_bound=200, - source="test", claim_boundary="test", + field_name="bad_default", + public_label="Bad default", + health_economics_meaning="x", + high_value_meaning="x", + educational_output_effect="x", + slider_help="x", + default_value=120, + lower_bound=0, + upper_bound=200, + source="test", + claim_boundary="test", ) def test_runtime_scenario_rejects_negative_activity_signal(): with pytest.raises(ValidationError): RuntimeScenarioDefinition( - scenario_id="F99", scenario_name="Test", description="Test", - activity_signal=-1.0, capitation=50.0, - place_accountability=50.0, scope_capacity=50.0, - urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, - copayment_burden=50.0, budget_tightness=50.0, - hospital_salience=50.0, complexity=50.0, - source="test", claim_boundary="test", + scenario_id="F99", + scenario_name="Test", + description="Test", + activity_signal=-1.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + source="test", + claim_boundary="test", ) def test_runtime_scenario_rejects_empty_scenario_id(): with pytest.raises(ValidationError): RuntimeScenarioDefinition( - scenario_id="", scenario_name="Test", description="Test", - activity_signal=50.0, capitation=50.0, - place_accountability=50.0, scope_capacity=50.0, - urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, - copayment_burden=50.0, budget_tightness=50.0, - hospital_salience=50.0, complexity=50.0, - source="test", claim_boundary="test", + scenario_id="", + scenario_name="Test", + description="Test", + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + source="test", + claim_boundary="test", ) @@ -339,6 +409,7 @@ def test_export_all_registry_json_schemas_includes_all(): assert "parameter" in schemas assert "input_dataset" in schemas + # ── Reference result validation ───────────────────────────────────── @@ -394,19 +465,28 @@ class TestContract(StrictContract): TestContract(name="ok", unexpected_field="bad") - def test_parameters_duplicate_id_rejected(): """Parameter registry should reject duplicate parameter_id values.""" from models.primarycare_model.validation.registry_loader import _check_unique params = ( ParameterDefinition( - parameter_id="dup_param", label="A", value_type="number", - unit="x", default_value=1.0, description="d", source="s", + parameter_id="dup_param", + label="A", + value_type="number", + unit="x", + default_value=1.0, + description="d", + source="s", ), ParameterDefinition( - parameter_id="dup_param", label="B", value_type="number", - unit="x", default_value=2.0, description="d", source="s", + parameter_id="dup_param", + label="B", + value_type="number", + unit="x", + default_value=2.0, + description="d", + source="s", ), ) with pytest.raises(ValueError, match="Duplicate parameter_id"): diff --git a/models/tests/test_dashboard_service.py b/models/tests/test_dashboard_service.py index 2775ac5..15480a5 100644 --- a/models/tests/test_dashboard_service.py +++ b/models/tests/test_dashboard_service.py @@ -11,15 +11,8 @@ def test_dashboard_service_is_framework_neutral_and_exposes_public_links() -> None: source = Path("models/primarycare_model/dashboard_service.py").read_text(encoding="utf-8") tree = ast.parse(source) - imported_modules = { - alias.name - for node in tree.body - if isinstance(node, ast.Import) - for alias in node.names - } | { - node.module or "" - for node in tree.body - if isinstance(node, ast.ImportFrom) + imported_modules = {alias.name for node in tree.body if isinstance(node, ast.Import) for alias in node.names} | { + node.module or "" for node in tree.body if isinstance(node, ast.ImportFrom) } assert "streamlit" not in imported_modules diff --git a/models/tests/test_engine_adapters.py b/models/tests/test_engine_adapters.py index 5e5a5be..613a1a1 100644 --- a/models/tests/test_engine_adapters.py +++ b/models/tests/test_engine_adapters.py @@ -1,4 +1,5 @@ """Tests for engine adapters.""" + from __future__ import annotations import pytest @@ -24,202 +25,445 @@ CLAIM = "test" + def test_all_adapters_can_be_instantiated(): - for a in [SystemDynamicsAdapter(), AgentBasedModelAdapter(), - BassDiffusionAdapter(), MonteCarloAdapter(), - ModelPredictiveControlAdapter(), NashOptimisationAdapter(), - SensitivityAnalysisAdapter()]: + for a in [ + SystemDynamicsAdapter(), + AgentBasedModelAdapter(), + BassDiffusionAdapter(), + MonteCarloAdapter(), + ModelPredictiveControlAdapter(), + NashOptimisationAdapter(), + SensitivityAnalysisAdapter(), + ]: assert isinstance(a, EngineProtocol) + def test_all_adapters_have_engine_id(): - for a in [SystemDynamicsAdapter(), AgentBasedModelAdapter(), - BassDiffusionAdapter(), MonteCarloAdapter(), - ModelPredictiveControlAdapter(), NashOptimisationAdapter(), - SensitivityAnalysisAdapter()]: - assert hasattr(a, 'engine_id') and isinstance(a.engine_id, str) and len(a.engine_id) > 0 + for a in [ + SystemDynamicsAdapter(), + AgentBasedModelAdapter(), + BassDiffusionAdapter(), + MonteCarloAdapter(), + ModelPredictiveControlAdapter(), + NashOptimisationAdapter(), + SensitivityAnalysisAdapter(), + ]: + assert hasattr(a, "engine_id") and isinstance(a.engine_id, str) and len(a.engine_id) > 0 + def test_sd_adapter_accepts_valid_input(): a = SystemDynamicsAdapter() - inp = SDInput(scenario_id="F4", months=24, seed=42, claim_boundary=CLAIM, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + inp = SDInput( + scenario_id="F4", + months=24, + seed=42, + claim_boundary=CLAIM, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) out = a.run(inp) assert isinstance(out, SDOutput) assert 0 <= out.scenario_result.hybrid_viability_score <= 100 assert len(out.monthly_trace) == 24 + def test_sd_adapter_deterministic(): a = SystemDynamicsAdapter() - kw = dict(scenario_id="F0", months=12, seed=42, claim_boundary=CLAIM, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + kw = dict( + scenario_id="F0", + months=12, + seed=42, + claim_boundary=CLAIM, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) o1, o2 = a.run(SDInput(**kw)), a.run(SDInput(**kw)) assert o1.scenario_result.hybrid_viability_score == o2.scenario_result.hybrid_viability_score assert o1.monthly_trace == o2.monthly_trace + def test_sd_adapter_rejects_out_of_range_months(): with pytest.raises(ValidationError): - SDInput(scenario_id="F0", months=200, seed=42, claim_boundary=CLAIM, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + SDInput( + scenario_id="F0", + months=200, + seed=42, + claim_boundary=CLAIM, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) + def test_abm_adapter_runs(): a = AgentBasedModelAdapter() - inp = ABMInput(scenario_id="F4", population_size=100, months=6, seed=42, claim_boundary=CLAIM, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + inp = ABMInput( + scenario_id="F4", + population_size=100, + months=6, + seed=42, + claim_boundary=CLAIM, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) out = a.run(inp) assert isinstance(out, ABMOutput) assert len(out.agent_data) == 100 + def test_abm_adapter_deterministic(): a = AgentBasedModelAdapter() - kw = dict(scenario_id="F0", population_size=80, months=6, seed=42, claim_boundary=CLAIM, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + kw = dict( + scenario_id="F0", + population_size=80, + months=6, + seed=42, + claim_boundary=CLAIM, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) o1, o2 = a.run(ABMInput(**kw)), a.run(ABMInput(**kw)) assert o1.scenario_result.access_score == o2.scenario_result.access_score assert o1.agent_data == o2.agent_data + def test_diffusion_adapter_runs(): a = BassDiffusionAdapter() - inp = DiffusionInput(scenario_id="F4", months=36, seed=42, claim_boundary=CLAIM, - p_coefficient=0.03, q_coefficient=0.40, market_potential=1000.0, - initial_adopters=10.0, activity_signal=50.0, scope_capacity=50.0, governance=50.0) + inp = DiffusionInput( + scenario_id="F4", + months=36, + seed=42, + claim_boundary=CLAIM, + p_coefficient=0.03, + q_coefficient=0.40, + market_potential=1000.0, + initial_adopters=10.0, + activity_signal=50.0, + scope_capacity=50.0, + governance=50.0, + ) out = a.run(inp) assert isinstance(out, DiffusionOutput) assert out.cumulative_adopters > 0 assert 1 <= out.peak_adoption_month <= 36 + def test_diffusion_adapter_deterministic(): a = BassDiffusionAdapter() - kw = dict(scenario_id="F0", months=24, seed=42, claim_boundary=CLAIM, - p_coefficient=0.02, q_coefficient=0.30, market_potential=500.0, - initial_adopters=5.0, activity_signal=50.0, scope_capacity=50.0, governance=50.0) + kw = dict( + scenario_id="F0", + months=24, + seed=42, + claim_boundary=CLAIM, + p_coefficient=0.02, + q_coefficient=0.30, + market_potential=500.0, + initial_adopters=5.0, + activity_signal=50.0, + scope_capacity=50.0, + governance=50.0, + ) o1, o2 = a.run(DiffusionInput(**kw)), a.run(DiffusionInput(**kw)) assert o1.cumulative_adopters == o2.cumulative_adopters assert o1.peak_adoption_month == o2.peak_adoption_month + def test_mc_adapter_runs(): a = MonteCarloAdapter() - inp = MCInput(scenario_id="F4", draws=20, seed=42, claim_boundary=CLAIM, perturbation_sd=0.05, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + inp = MCInput( + scenario_id="F4", + draws=20, + seed=42, + claim_boundary=CLAIM, + perturbation_sd=0.05, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) out = a.run(inp) assert isinstance(out, MCOutput) assert len(out.draw_data) == 20 assert len(out.uncertainty_summaries) >= 5 + def test_mc_adapter_deterministic(): a = MonteCarloAdapter() - kw = dict(scenario_id="F0", draws=15, seed=42, claim_boundary=CLAIM, perturbation_sd=0.05, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + kw = dict( + scenario_id="F0", + draws=15, + seed=42, + claim_boundary=CLAIM, + perturbation_sd=0.05, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) o1, o2 = a.run(MCInput(**kw)), a.run(MCInput(**kw)) assert o1.scenario_result.hybrid_viability_score == o2.scenario_result.hybrid_viability_score + def test_mc_adapter_uncertainty_summaries(): a = MonteCarloAdapter() - inp = MCInput(scenario_id="F4", draws=50, seed=42, claim_boundary=CLAIM, perturbation_sd=0.08, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + inp = MCInput( + scenario_id="F4", + draws=50, + seed=42, + claim_boundary=CLAIM, + perturbation_sd=0.08, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) out = a.run(inp) for s in out.uncertainty_summaries: assert s.p05 <= s.p50 <= s.p95 assert s.draws == 50 + def test_mpc_adapter_runs(): a = ModelPredictiveControlAdapter() - inp = MPCInput(scenario_id="F4", seed=42, claim_boundary=CLAIM, horizon=12, n_control_steps=3, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + inp = MPCInput( + scenario_id="F4", + seed=42, + claim_boundary=CLAIM, + horizon=12, + n_control_steps=3, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) out = a.run(inp) assert isinstance(out, MPCOutput) assert len(out.control_trajectory) == 12 assert len(out.optimised_levers) == 12 + def test_mpc_adapter_optimised_levers_in_bounds(): a = ModelPredictiveControlAdapter() - inp = MPCInput(scenario_id="F4", seed=42, claim_boundary=CLAIM, horizon=6, n_control_steps=2, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + inp = MPCInput( + scenario_id="F4", + seed=42, + claim_boundary=CLAIM, + horizon=6, + n_control_steps=2, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) out = a.run(inp) for v in out.optimised_levers.values(): assert 0.0 <= v <= 100.0 + def test_nash_adapter_runs(): a = NashOptimisationAdapter() - inp = NashInput(scenario_id="F4", seed=42, claim_boundary=CLAIM, - funder_audit=50.0, funder_place_accountability=60.0, - provider_effort=50.0, provider_scope_utilisation=50.0, - budget_tightness=60.0, complexity=50.0, activity_signal=70.0) + inp = NashInput( + scenario_id="F4", + seed=42, + claim_boundary=CLAIM, + funder_audit=50.0, + funder_place_accountability=60.0, + provider_effort=50.0, + provider_scope_utilisation=50.0, + budget_tightness=60.0, + complexity=50.0, + activity_signal=70.0, + ) out = a.run(inp) assert isinstance(out, NashOutput) assert 0 <= out.equilibrium_funder_audit <= 100 assert 0 <= out.equilibrium_provider_effort <= 100 + def test_nash_adapter_converges(): a = NashOptimisationAdapter() - inp = NashInput(scenario_id="F4", max_iterations=100, seed=42, claim_boundary=CLAIM, - funder_audit=50.0, funder_place_accountability=60.0, - provider_effort=50.0, provider_scope_utilisation=50.0, - budget_tightness=60.0, complexity=50.0, activity_signal=70.0) + inp = NashInput( + scenario_id="F4", + max_iterations=100, + seed=42, + claim_boundary=CLAIM, + funder_audit=50.0, + funder_place_accountability=60.0, + provider_effort=50.0, + provider_scope_utilisation=50.0, + budget_tightness=60.0, + complexity=50.0, + activity_signal=70.0, + ) out = a.run(inp) assert out.iterations_to_converge <= 100 + def test_sensitivity_adapter_runs(): a = SensitivityAnalysisAdapter() - inp = SensitivityInput(scenario_id="F4", seed=42, claim_boundary=CLAIM, - baseline_activity_signal=50.0, baseline_capitation=50.0, - baseline_place_accountability=50.0, baseline_scope_capacity=50.0, - baseline_urgent_ambulance=50.0, baseline_data_visibility=50.0, - baseline_governance=50.0, baseline_equity_protection=50.0, - baseline_copayment_burden=50.0, baseline_budget_tightness=50.0, - baseline_hospital_salience=50.0, baseline_complexity=50.0, - low_percentile=25.0, high_percentile=75.0, delta_step=10.0) + inp = SensitivityInput( + scenario_id="F4", + seed=42, + claim_boundary=CLAIM, + baseline_activity_signal=50.0, + baseline_capitation=50.0, + baseline_place_accountability=50.0, + baseline_scope_capacity=50.0, + baseline_urgent_ambulance=50.0, + baseline_data_visibility=50.0, + baseline_governance=50.0, + baseline_equity_protection=50.0, + baseline_copayment_burden=50.0, + baseline_budget_tightness=50.0, + baseline_hospital_salience=50.0, + baseline_complexity=50.0, + low_percentile=25.0, + high_percentile=75.0, + delta_step=10.0, + ) out = a.run(inp) assert isinstance(out, SensitivityOutput) assert len(out.oat_sensitivities) == 24 + def test_engine_input_rejects_empty_scenario_id(): with pytest.raises(ValidationError): - SDInput(scenario_id="", months=12, seed=42, claim_boundary=CLAIM, - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + SDInput( + scenario_id="", + months=12, + seed=42, + claim_boundary=CLAIM, + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) + def test_engine_input_rejects_empty_claim_boundary(): with pytest.raises(ValidationError): - SDInput(scenario_id="F0", months=12, seed=42, claim_boundary="", - activity_signal=50.0, capitation=50.0, place_accountability=50.0, - scope_capacity=50.0, urgent_ambulance=50.0, data_visibility=50.0, - governance=50.0, equity_protection=50.0, copayment_burden=50.0, - budget_tightness=50.0, hospital_salience=50.0, complexity=50.0) + SDInput( + scenario_id="F0", + months=12, + seed=42, + claim_boundary="", + activity_signal=50.0, + capitation=50.0, + place_accountability=50.0, + scope_capacity=50.0, + urgent_ambulance=50.0, + data_visibility=50.0, + governance=50.0, + equity_protection=50.0, + copayment_burden=50.0, + budget_tightness=50.0, + hospital_salience=50.0, + complexity=50.0, + ) + def test_engine_modules_do_not_import_streamlit(): import ast from pathlib import Path + engines_dir = Path("models/primarycare_model/engines") for py_file in engines_dir.rglob("*.py"): if py_file.name.startswith("_"): diff --git a/models/tests/test_game_formulas.py b/models/tests/test_game_formulas.py index bedd218..15c04c3 100644 --- a/models/tests/test_game_formulas.py +++ b/models/tests/test_game_formulas.py @@ -35,7 +35,9 @@ def _claims(audit_levels, governance=62, admin_cost=58, quality=72, place=64): admin_norm = admin_cost / 100.0 honest_base = strategic_response(0.42 * quality_norm + 0.34 * place_norm + 0.24 * audit, 0.48, 7.0) deterrence = strategic_response(0.55 * audit + 0.25 * admin_norm + 0.20 * place_norm, 0.46, 7.0) - gaming_base = strategic_response(0.62 * governance_norm + 0.22 * (1 - quality_norm) + 0.16 * (1 - place_norm), 0.42, 7.0) + gaming_base = strategic_response( + 0.62 * governance_norm + 0.22 * (1 - quality_norm) + 0.16 * (1 - place_norm), 0.42, 7.0 + ) honest.append(round(48 + 34 * honest_base + 14 * diminishing_return(governance_norm) - 8 * audit, 1)) gaming.append(round(48 + 42 * gaming_base - 36 * deterrence - 8 * audit**1.2, 1)) return honest, gaming @@ -46,8 +48,12 @@ def _coop(place_levels, cooperation_gain=55, cherry_pick_gain=48, equity=64, sco cherry_pick = [] for place_level in place_levels: place_norm = place_level / 100.0 - cooperation_signal = 0.38 * cooperation_gain / 100 + 0.25 * equity / 100 + 0.20 * scope / 100 + 0.24 * place_norm - cherry_pick_signal = 0.56 * cherry_pick_gain / 100 + 0.12 * (1 - equity / 100) + 0.10 * (1 - scope / 100) - 0.34 * place_norm + cooperation_signal = ( + 0.38 * cooperation_gain / 100 + 0.25 * equity / 100 + 0.20 * scope / 100 + 0.24 * place_norm + ) + cherry_pick_signal = ( + 0.56 * cherry_pick_gain / 100 + 0.12 * (1 - equity / 100) + 0.10 * (1 - scope / 100) - 0.34 * place_norm + ) cooperate.append(round(46 + 48 * strategic_response(cooperation_signal, 0.48, 7.0), 1)) cherry_pick.append(round(46 + 48 * strategic_response(cherry_pick_signal, 0.32, 7.0), 1)) return cooperate, cherry_pick @@ -102,7 +108,9 @@ def test_claims_audit_crossing_threshold(self): crossing = next( ( audit_level - for audit_level, (honest_value, gaming_value) in zip(audit_levels, zip(honest, gaming, strict=False), strict=False) + for audit_level, (honest_value, gaming_value) in zip( + audit_levels, zip(honest, gaming, strict=False), strict=False + ) if honest_value >= gaming_value ), None, @@ -114,7 +122,11 @@ def test_coordination_crossing_threshold(self): place_levels = list(range(0, 101, 5)) cooperate, cherry_pick = _coop(place_levels) crossing = next( - (place_levels[i] for i, (cooperate_value, cherry_value) in enumerate(zip(cooperate, cherry_pick, strict=False)) if cooperate_value >= cherry_value), + ( + place_levels[i] + for i, (cooperate_value, cherry_value) in enumerate(zip(cooperate, cherry_pick, strict=False)) + if cooperate_value >= cherry_value + ), None, ) assert crossing is not None, "No crossing between cooperate and cherry-pick" @@ -172,12 +184,20 @@ def test_marginal_supply_saturates(self): curve_values = [] for level in lever_levels: saturation = level / (level + 18 + admin_friction * 0.45) if level else 0.0 - value = max(0, min(300, base_cost + (recurrent_rate + 18) * saturation * (0.55 + base_cost / 520) - admin_friction * 0.35)) + value = max( + 0, + min( + 300, + base_cost + (recurrent_rate + 18) * saturation * (0.55 + base_cost / 520) - admin_friction * 0.35, + ), + ) curve_values.append(round(value, 1)) second_diff = np.diff(np.diff(curve_values)) assert np.any(second_diff < -0.01), "No diminishing returns" midpoint = len(curve_values) // 2 - assert curve_values[-1] - curve_values[midpoint] < curve_values[midpoint] - curve_values[0] + 0.5, "Upper half not flatter" + assert curve_values[-1] - curve_values[midpoint] < curve_values[midpoint] - curve_values[0] + 0.5, ( + "Upper half not flatter" + ) def test_capitation_budget_headroom_erodes(self): levels = list(range(0, 101, 10)) diff --git a/models/tests/test_pho_services_agreement_transform.py b/models/tests/test_pho_services_agreement_transform.py index 522ea63..92cd9bf 100644 --- a/models/tests/test_pho_services_agreement_transform.py +++ b/models/tests/test_pho_services_agreement_transform.py @@ -14,20 +14,8 @@ ) ROOT = Path(__file__).resolve().parents[2] -RAW_ARTIFACT = ( - ROOT - / "data" - / "public_raw" - / "src_pho_services_agreement" - / "master-pho-services-agreement.pdf" -) -PROCESSED_ARTIFACT = ( - ROOT - / "data" - / "public_processed" - / "src_pho_services_agreement" - / "pho_services_schedule.csv" -) +RAW_ARTIFACT = ROOT / "data" / "public_raw" / "src_pho_services_agreement" / "master-pho-services-agreement.pdf" +PROCESSED_ARTIFACT = ROOT / "data" / "public_processed" / "src_pho_services_agreement" / "pho_services_schedule.csv" def _read_csv(path: Path) -> list[dict[str, str]]: diff --git a/models/tests/test_property_based.py b/models/tests/test_property_based.py index 478d334..7980b54 100644 --- a/models/tests/test_property_based.py +++ b/models/tests/test_property_based.py @@ -2,6 +2,7 @@ Property-based tests using Hypothesis for the PCA simulation. Fuzzes entity generation rules, tests invariants under random inputs. """ + from __future__ import annotations import pytest @@ -24,41 +25,61 @@ ) -def gs(): return st.sampled_from(list(Gender)) -def es(): return st.sampled_from(list(Ethnicity)) -def ens(): return st.sampled_from(list(Ethnicity)) -def pts(): return st.sampled_from(list(ProviderType)) -def fms(): return st.sampled_from(list(FundingModel)) +def gs(): + return st.sampled_from(list(Gender)) + + +def es(): + return st.sampled_from(list(Ethnicity)) + + +def ens(): + return st.sampled_from(list(Ethnicity)) + + +def pts(): + return st.sampled_from(list(ProviderType)) + + +def fms(): + return st.sampled_from(list(FundingModel)) def patient_s(): - return st.builds(PatientProfile, - age=st.integers(0, 120), gender=gs(), ethnicity=es(), + return st.builds( + PatientProfile, + age=st.integers(0, 120), + gender=gs(), + ethnicity=es(), deprivation_index=st.integers(1, 10), comorbidities=st.lists( - st.sampled_from(["diabetes","asthma","copd","hypertension","cancer"]), - max_size=5, - unique=True), - enrollment_status=st.sampled_from( - ["enrolled","casual","pending","declined"])) + st.sampled_from(["diabetes", "asthma", "copd", "hypertension", "cancer"]), max_size=5, unique=True + ), + enrollment_status=st.sampled_from(["enrolled", "casual", "pending", "declined"]), + ) def provider_s(): - return st.builds(ProviderProfile, - id=st.text(min_size=1, max_size=20), type=pts(), - region=st.sampled_from(["Auckland","Canterbury","Waikato"]), + return st.builds( + ProviderProfile, + id=st.text(min_size=1, max_size=20), + type=pts(), + region=st.sampled_from(["Auckland", "Canterbury", "Waikato"]), patient_list=st.lists(st.text(min_size=1), max_size=50), capacity=st.integers(0, 5000), - capitation_panel_size=st.integers(0, 5000)) + capitation_panel_size=st.integers(0, 5000), + ) def config_s(): - return st.builds(SimulationConfig, - seed=st.integers(0, 2**31-1), + return st.builds( + SimulationConfig, + seed=st.integers(0, 2**31 - 1), num_patients=st.integers(1, 100000), num_providers=st.integers(1, 500), time_horizon_months=st.integers(1, 120), - tick_interval_days=st.sampled_from([1,2,3,5,6,10,15,30])) + tick_interval_days=st.sampled_from([1, 2, 3, 5, 6, 10, 15, 30]), + ) def policy_s(): @@ -114,8 +135,8 @@ def scenario_s(): ), ) -# Property-based tests placeholder +# Property-based tests placeholder class TestPatientFuzzing: @@ -194,10 +215,8 @@ class TestJaxMC: @given(seed=st.integers(0, 1023), mo=st.integers(1, 12)) @settings(max_examples=10) def test_non_negative(self, seed, mo): - c = SimulationConfig(seed=seed, time_horizon_months=mo, - num_patients=1000, num_providers=50) - s = ScenarioParams(name=f"t{seed}", funding_model=FundingModel.CAPITATION, - capitation_rate=80.0) + c = SimulationConfig(seed=seed, time_horizon_months=mo, num_patients=1000, num_providers=50) + s = ScenarioParams(name=f"t{seed}", funding_model=FundingModel.CAPITATION, capitation_rate=80.0) r = run_deterministic(c, s) for m in r.monthly_metrics: assert m.total_patients >= 0 @@ -208,10 +227,8 @@ def test_non_negative(self, seed, mo): @given(seed=st.integers(0, 1023), mo=st.integers(1, 12)) @settings(max_examples=10) def test_determinism(self, seed, mo): - c = SimulationConfig(seed=seed, time_horizon_months=mo, - num_patients=1000, num_providers=50) - s = ScenarioParams(name=f"d{seed}", funding_model=FundingModel.CAPITATION, - capitation_rate=80.0) + c = SimulationConfig(seed=seed, time_horizon_months=mo, num_patients=1000, num_providers=50) + s = ScenarioParams(name=f"d{seed}", funding_model=FundingModel.CAPITATION, capitation_rate=80.0) r1, r2 = run_deterministic(c, s), run_deterministic(c, s) for a, b in zip(r1.monthly_metrics, r2.monthly_metrics, strict=False): assert a.total_funding == b.total_funding @@ -219,10 +236,8 @@ def test_determinism(self, seed, mo): @given(seed=st.integers(0, 1023), mo=st.integers(1, 6)) @settings(max_examples=10) def test_shape(self, seed, mo): - c = SimulationConfig(seed=seed, time_horizon_months=mo, - num_patients=1000, num_providers=50) - s = ScenarioParams(name=f"s{seed}", funding_model=FundingModel.CAPITATION, - capitation_rate=80.0) + c = SimulationConfig(seed=seed, time_horizon_months=mo, num_patients=1000, num_providers=50) + s = ScenarioParams(name=f"s{seed}", funding_model=FundingModel.CAPITATION, capitation_rate=80.0) assert len(run_deterministic(c, s).monthly_metrics) == mo diff --git a/models/tests/test_property_invariants.py b/models/tests/test_property_invariants.py index 15ef340..fd2372d 100644 --- a/models/tests/test_property_invariants.py +++ b/models/tests/test_property_invariants.py @@ -31,7 +31,11 @@ ) -@given(value=FINITE_FLOATS, lower=st.floats(min_value=-1000, max_value=0, allow_nan=False), upper=st.floats(min_value=1, max_value=1000, allow_nan=False)) +@given( + value=FINITE_FLOATS, + lower=st.floats(min_value=-1000, max_value=0, allow_nan=False), + upper=st.floats(min_value=1, max_value=1000, allow_nan=False), +) def test_clamp_respects_bounds(value: float, lower: float, upper: float) -> None: bounded = clamp(value, lower, upper) assert lower <= bounded <= upper diff --git a/models/tests/test_public_policy_shock_plausibility.py b/models/tests/test_public_policy_shock_plausibility.py index 3a6b293..33d493d 100644 --- a/models/tests/test_public_policy_shock_plausibility.py +++ b/models/tests/test_public_policy_shock_plausibility.py @@ -93,11 +93,7 @@ def test_public_policy_shock_main_fails_only_when_pass_is_required() -> None: def test_public_policy_shock_artifact_matches_checked_in_capitation_extract() -> None: source_path = ROOT / "data" / "public_processed" / "src_hnz_capitation_schedule" / "capitation_rates.csv" artifact_path = ( - ROOT - / "data" - / "public_processed" - / "src_hnz_capitation_schedule" - / "policy_shock_pre_post_comparison.csv" + ROOT / "data" / "public_processed" / "src_hnz_capitation_schedule" / "policy_shock_pre_post_comparison.csv" ) source_rows = _read_csv(source_path) artifact_rows = _read_csv(artifact_path) @@ -210,4 +206,7 @@ def test_passed_numeric_comparison_requires_direction_agreement() -> None: assert readiness.status == "artifact_invalid" assert readiness.rows_checked == 1 - assert any("comparison_result=passed requires observed_direction to match modelled_direction" in issue for issue in readiness.issues) + assert any( + "comparison_result=passed requires observed_direction to match modelled_direction" in issue + for issue in readiness.issues + ) diff --git a/models/tests/test_public_site_visual_contract.py b/models/tests/test_public_site_visual_contract.py index 14bb98e..182d195 100644 --- a/models/tests/test_public_site_visual_contract.py +++ b/models/tests/test_public_site_visual_contract.py @@ -82,7 +82,10 @@ def test_public_homepage_visual_contract_strings(): assert 'href="https://gtpcnz.streamlit.app/"' in text assert 'href="https://rareinsights.substack.com/"' in text assert "How the posts map to this report and dashboard" in text - assert "https://rareinsights.substack.com/p/the-current-reform-pathway-stronger-than-a-straw-man-but-maybe-still-incomplete" in text + assert ( + "https://rareinsights.substack.com/p/the-current-reform-pathway-stronger-than-a-straw-man-but-maybe-still-incomplete" + in text + ) def test_public_homepage_has_no_private_substack_or_local_paths(): diff --git a/models/tests/test_public_source_fetch.py b/models/tests/test_public_source_fetch.py index 9adc4dc..776cadb 100644 --- a/models/tests/test_public_source_fetch.py +++ b/models/tests/test_public_source_fetch.py @@ -30,7 +30,9 @@ def test_public_source_fetch_check_is_readiness_compatible() -> None: def test_public_source_fetch_strict_mode_reports_missing_raw_files() -> None: - results = [check_source_fetch_readiness(plan.source_id, require_raw=True) for plan in load_public_source_retrieval_plans()] + results = [ + check_source_fetch_readiness(plan.source_id, require_raw=True) for plan in load_public_source_retrieval_plans() + ] missing_raw_issues = [ issue for result in results diff --git a/models/tests/test_public_source_retrieval_plan.py b/models/tests/test_public_source_retrieval_plan.py index 176b977..b14e931 100644 --- a/models/tests/test_public_source_retrieval_plan.py +++ b/models/tests/test_public_source_retrieval_plan.py @@ -32,8 +32,13 @@ def test_public_source_retrieval_plan_tracks_processed_public_sources() -> None: } assert all(plan.expected_raw_dir == f"data/public_raw/{plan.source_id}" for plan in plans) assert all(plan.fetch_script.startswith("scripts/fetch_") for plan in plans) - assert all(plan.expected_processed_artifact.startswith(f"data/public_processed/{plan.source_id}/") for plan in plans) - assert all("patient-level" not in plan.claim_boundary.lower() or "no patient-level" in plan.claim_boundary.lower() for plan in plans) + assert all( + plan.expected_processed_artifact.startswith(f"data/public_processed/{plan.source_id}/") for plan in plans + ) + assert all( + "patient-level" not in plan.claim_boundary.lower() or "no patient-level" in plan.claim_boundary.lower() + for plan in plans + ) def test_public_source_retrieval_plan_verifier_passes() -> None: diff --git a/models/tests/test_public_source_transforms.py b/models/tests/test_public_source_transforms.py index c2b4892..69a8a81 100644 --- a/models/tests/test_public_source_transforms.py +++ b/models/tests/test_public_source_transforms.py @@ -28,8 +28,14 @@ def test_public_source_transform_check_is_readiness_compatible() -> None: def test_public_source_transform_strict_mode_is_registry_driven() -> None: - results = [check_source_transform_readiness(plan.source_id, require_raw=True) for plan in load_public_source_retrieval_plans()] - assert all(result.status in {"blocked", "processed_ready", "raw_available_pending_source_specific_parser"} for result in results) + results = [ + check_source_transform_readiness(plan.source_id, require_raw=True) + for plan in load_public_source_retrieval_plans() + ] + assert all( + result.status in {"blocked", "processed_ready", "raw_available_pending_source_specific_parser"} + for result in results + ) def test_public_source_transform_script_cli_passes_in_readiness_mode() -> None: diff --git a/models/tests/test_public_validation_source_evidence.py b/models/tests/test_public_validation_source_evidence.py index 672e253..6b089b8 100644 --- a/models/tests/test_public_validation_source_evidence.py +++ b/models/tests/test_public_validation_source_evidence.py @@ -16,18 +16,10 @@ ROOT = Path(__file__).resolve().parents[2] PHO_ACCESS_METADATA = ( - ROOT - / "data" - / "public_processed" - / "src_hnz_pho_access_timeseries" - / "pho_access_workbook_metadata.csv" + ROOT / "data" / "public_processed" / "src_hnz_pho_access_timeseries" / "pho_access_workbook_metadata.csv" ) PHO_ACCESS_NUMERIC = ( - ROOT - / "data" - / "public_processed" - / "src_hnz_pho_access_timeseries" - / "pho_access_numeric_extract.csv" + ROOT / "data" / "public_processed" / "src_hnz_pho_access_timeseries" / "pho_access_numeric_extract.csv" ) @@ -106,7 +98,9 @@ def test_pho_access_numeric_extract_covers_district_and_subgroup_rows() -> None: def test_pho_access_transform_accepts_multiple_public_workbook_periods(monkeypatch) -> None: temp_root = Path("fixture-root") raw_dir = temp_root / "data" / "public_raw" / "src_hnz_pho_access_timeseries" - processed = temp_root / "data" / "public_processed" / "src_hnz_pho_access_timeseries" / "pho_access_numeric_extract.csv" + processed = ( + temp_root / "data" / "public_processed" / "src_hnz_pho_access_timeseries" / "pho_access_numeric_extract.csv" + ) current = raw_dir / "access-to-primary-care-stats-2025-q4.xlsx" additional = raw_dir / "access-to-primary-care-stats-2025-q3.xlsx" written: dict[str, list[dict[str, object]]] = {} diff --git a/models/tests/test_release_engineering.py b/models/tests/test_release_engineering.py index dd857bc..8b9592a 100644 --- a/models/tests/test_release_engineering.py +++ b/models/tests/test_release_engineering.py @@ -127,12 +127,8 @@ def test_release_manifest_records_claim_metadata() -> None: def test_release_site_sources_expose_aggregate_validation_boundary() -> None: index = (ROOT / "index.qmd").read_text(encoding="utf-8") - report = (ROOT / "reports" / "primary_care_architecture.qmd").read_text( - encoding="utf-8" - ) - site_map = ( - ROOT / "docs" / "public-site" / "site-map-and-release-manifest-v1.8.4.md" - ).read_text(encoding="utf-8") + report = (ROOT / "reports" / "primary_care_architecture.qmd").read_text(encoding="utf-8") + site_map = (ROOT / "docs" / "public-site" / "site-map-and-release-manifest-v1.8.4.md").read_text(encoding="utf-8") quarto = (ROOT / "_quarto.yml").read_text(encoding="utf-8") assert "docs/release/model-card-v1.8.1.md" in quarto diff --git a/models/tests/test_streamlit_dashboard_app.py b/models/tests/test_streamlit_dashboard_app.py index 0c04422..be4d556 100644 --- a/models/tests/test_streamlit_dashboard_app.py +++ b/models/tests/test_streamlit_dashboard_app.py @@ -25,11 +25,7 @@ def _markdown_text(at: AppTest) -> str: def _source_text() -> str: - return ( - APP_SOURCE_PATH.read_text(encoding="utf-8") - + "\n" - + LEVER_REGISTRY_PATH.read_text(encoding="utf-8") - ) + return APP_SOURCE_PATH.read_text(encoding="utf-8") + "\n" + LEVER_REGISTRY_PATH.read_text(encoding="utf-8") def _compact(text: str) -> str: @@ -166,7 +162,9 @@ def test_streamlit_dashboard_contract_first_screen_and_wording(): "government endorsement", ] for forbidden in forbidden_claims: - allowed_negated = source.replace("not a patient-level forecast", "").replace("not observed New Zealand outcomes", "") + allowed_negated = source.replace("not a patient-level forecast", "").replace( + "not observed New Zealand outcomes", "" + ) assert forbidden not in allowed_negated diff --git a/models/tests/test_transformed_schemas.py b/models/tests/test_transformed_schemas.py index f28b0cc..4bd392d 100644 --- a/models/tests/test_transformed_schemas.py +++ b/models/tests/test_transformed_schemas.py @@ -88,7 +88,12 @@ def test_processed_schema_accepts_valid_public_aggregate_csv() -> None: (access_dir / "_metadata.yaml").write_text("source_id: src_nz_health_survey\n", encoding="utf-8") _write_valid_pho_access_metadata(processed_root) - assert validate_processed_input_schemas(registry_root=REGISTRY, processed_root=processed_root, require_processed=True) == () + assert ( + validate_processed_input_schemas( + registry_root=REGISTRY, processed_root=processed_root, require_processed=True + ) + == () + ) def test_processed_schema_rejects_person_level_columns() -> None: @@ -108,5 +113,7 @@ def test_processed_schema_rejects_person_level_columns() -> None: ) (access_dir / "_metadata.yaml").write_text("source_id: src_nz_health_survey\n", encoding="utf-8") - issues = validate_processed_input_schemas(registry_root=REGISTRY, processed_root=processed_root, require_processed=True) + issues = validate_processed_input_schemas( + registry_root=REGISTRY, processed_root=processed_root, require_processed=True + ) assert any("forbidden person-level columns" in issue for issue in issues) diff --git a/repo_scorecards.py b/repo_scorecards.py index b312050..856c47e 100644 --- a/repo_scorecards.py +++ b/repo_scorecards.py @@ -14,22 +14,92 @@ def bleeding_edge_scorecard() -> dict[str, object]: checks = [ ("python_runtime", "Python >=3.11 project metadata", "pyproject.toml", _exists("pyproject.toml")), - ("streamlit_native_tests", "Streamlit AppTest coverage", "models/tests/test_app.py", _exists("models/tests/test_app.py")), - ("contract_registries", "Pydantic contracts plus versioned YAML registries", "models/primarycare_model/contracts", _exists("models/primarycare_model/contracts")), - ("validation_boundaries", "Concern-boundary scanner", "scripts/check_concern_boundaries.py", _exists("scripts/check_concern_boundaries.py")), - ("conductor_workflows", "Conductor templates, agents, skills and workflows", "conductor/workflows", _exists("conductor/workflows")), + ( + "streamlit_native_tests", + "Streamlit AppTest coverage", + "models/tests/test_app.py", + _exists("models/tests/test_app.py"), + ), + ( + "contract_registries", + "Pydantic contracts plus versioned YAML registries", + "models/primarycare_model/contracts", + _exists("models/primarycare_model/contracts"), + ), + ( + "validation_boundaries", + "Concern-boundary scanner", + "scripts/check_concern_boundaries.py", + _exists("scripts/check_concern_boundaries.py"), + ), + ( + "conductor_workflows", + "Conductor templates, agents, skills and workflows", + "conductor/workflows", + _exists("conductor/workflows"), + ), ("ci_release_lane", "Main PR CI gate", ".github/workflows/ci.yml", _exists(".github/workflows/ci.yml")), - ("dependency_canary", "Latest-compatible dependency canary lane", ".github/workflows/dependency-canary.yml", _exists(".github/workflows/dependency-canary.yml")), - ("pages_deploy", "GitHub Pages deployment workflow", ".github/workflows/pages.yml", _exists(".github/workflows/pages.yml")), + ( + "dependency_canary", + "Latest-compatible dependency canary lane", + ".github/workflows/dependency-canary.yml", + _exists(".github/workflows/dependency-canary.yml"), + ), + ( + "pages_deploy", + "GitHub Pages deployment workflow", + ".github/workflows/pages.yml", + _exists(".github/workflows/pages.yml"), + ), ("dependabot", "Automated dependency maintenance", ".github/dependabot.yml", _exists(".github/dependabot.yml")), - ("renovate", "Renovate dependency maintenance", "renovate.json", _exists("renovate.json") and _exists(".github/workflows/renovate.yml")), - ("strict_type_gates", "Basedpyright and mypy strict gates", "pyrightconfig.json", _exists("pyrightconfig.json")), - ("quality_workflow", "Coverage, lint, audit and property-test workflow", ".github/workflows/quality.yml", _exists(".github/workflows/quality.yml")), - ("codeql", "CodeQL security-and-quality scan", ".github/workflows/codeql.yml", _exists(".github/workflows/codeql.yml")), - ("mutation_testing", "Mutation testing workflow", ".github/workflows/mutation.yml", _exists(".github/workflows/mutation.yml")), - ("scalene_profile", "Scalene profiling workflow and target", ".github/workflows/profiling.yml", _exists(".github/workflows/profiling.yml") and _exists("scripts/run_scalene_profile.py")), - ("property_tests", "Hypothesis property-based invariants", "models/tests/test_property_invariants.py", _exists("models/tests/test_property_invariants.py")), - ("public_claim_controls", "Claim-boundary and model-card docs", "docs/launch/claim-boundaries-v1.7.2.md", _exists("docs/launch/claim-boundaries-v1.7.2.md")), + ( + "renovate", + "Renovate dependency maintenance", + "renovate.json", + _exists("renovate.json") and _exists(".github/workflows/renovate.yml"), + ), + ( + "strict_type_gates", + "Basedpyright and mypy strict gates", + "pyrightconfig.json", + _exists("pyrightconfig.json"), + ), + ( + "quality_workflow", + "Coverage, lint, audit and property-test workflow", + ".github/workflows/quality.yml", + _exists(".github/workflows/quality.yml"), + ), + ( + "codeql", + "CodeQL security-and-quality scan", + ".github/workflows/codeql.yml", + _exists(".github/workflows/codeql.yml"), + ), + ( + "mutation_testing", + "Mutation testing workflow", + ".github/workflows/mutation.yml", + _exists(".github/workflows/mutation.yml"), + ), + ( + "scalene_profile", + "Scalene profiling workflow and target", + ".github/workflows/profiling.yml", + _exists(".github/workflows/profiling.yml") and _exists("scripts/run_scalene_profile.py"), + ), + ( + "property_tests", + "Hypothesis property-based invariants", + "models/tests/test_property_invariants.py", + _exists("models/tests/test_property_invariants.py"), + ), + ( + "public_claim_controls", + "Claim-boundary and model-card docs", + "docs/launch/claim-boundaries-v1.7.2.md", + _exists("docs/launch/claim-boundaries-v1.7.2.md"), + ), ] passed = sum(1 for _, _, _, ok in checks if ok) return { @@ -38,7 +108,6 @@ def bleeding_edge_scorecard() -> dict[str, object]: "percent": round(passed / len(checks) * 100, 1), "posture": "bleeding-edge controlled" if passed == len(checks) else "needs hardening", "checks": [ - {"id": item_id, "label": label, "evidence": evidence, "ok": ok} - for item_id, label, evidence, ok in checks + {"id": item_id, "label": label, "evidence": evidence, "ok": ok} for item_id, label, evidence, ok in checks ], } diff --git a/scripts/bootstrap_prefix_pixi.py b/scripts/bootstrap_prefix_pixi.py index f799852..88cb8a9 100644 --- a/scripts/bootstrap_prefix_pixi.py +++ b/scripts/bootstrap_prefix_pixi.py @@ -42,7 +42,9 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) if sys.platform != "win32": - print("Repo-local Pixi bootstrap currently supports Windows x64 only; use the official installer on this platform.") + print( + "Repo-local Pixi bootstrap currently supports Windows x64 only; use the official installer on this platform." + ) return 2 pixi_exe = _install_windows_x64(args.version, repo_pixi_path(args.version).parent, args.force) diff --git a/scripts/check_concern_boundaries.py b/scripts/check_concern_boundaries.py index f3eb683..4d61e13 100644 --- a/scripts/check_concern_boundaries.py +++ b/scripts/check_concern_boundaries.py @@ -42,15 +42,15 @@ # ("patient-level forecast", "linked-data", "clinical data") which are # legitimate model terminology checked separately by check_no_patient_data.py. _PATIENT_DATA_PATTERNS: list[re.Pattern] = [ - re.compile(r"\bNHI\b"), # National Health Index - re.compile(r"\bnhi_number\b", re.IGNORECASE), # variable containing NHI - re.compile(r"\bnational health index\b", re.IGNORECASE), # full name - re.compile(r"\bpersonally.identif", re.IGNORECASE), # PII variants - re.compile(r"\bindividually.identif", re.IGNORECASE), # II variants - re.compile(r"\bPHI\b"), # Protected Health Info - re.compile(r"\bPII\b"), # Personally Identifiable Info - re.compile(r"\bpatient_level\b", re.IGNORECASE), # actual data-level refs - re.compile(r"\breal patient\b", re.IGNORECASE), # raw patient data + re.compile(r"\bNHI\b"), # National Health Index + re.compile(r"\bnhi_number\b", re.IGNORECASE), # variable containing NHI + re.compile(r"\bnational health index\b", re.IGNORECASE), # full name + re.compile(r"\bpersonally.identif", re.IGNORECASE), # PII variants + re.compile(r"\bindividually.identif", re.IGNORECASE), # II variants + re.compile(r"\bPHI\b"), # Protected Health Info + re.compile(r"\bPII\b"), # Personally Identifiable Info + re.compile(r"\bpatient_level\b", re.IGNORECASE), # actual data-level refs + re.compile(r"\breal patient\b", re.IGNORECASE), # raw patient data ] @@ -72,8 +72,10 @@ def _imports_streamlit(path: Path) -> bool: alias.name == "streamlit" or alias.name.startswith("streamlit.") for alias in node.names ): return True - if isinstance(node, ast.ImportFrom) and node.module and ( - node.module == "streamlit" or node.module.startswith("streamlit.") + if ( + isinstance(node, ast.ImportFrom) + and node.module + and (node.module == "streamlit" or node.module.startswith("streamlit.")) ): return True return False @@ -166,9 +168,7 @@ def _check_patient_data_references() -> list[str]: continue for pattern in _PATIENT_DATA_PATTERNS: if pattern.search(stripped): - issues.append( - f"{path.relative_to(ROOT)}:{lineno}: matches {pattern.pattern!r}" - ) + issues.append(f"{path.relative_to(ROOT)}:{lineno}: matches {pattern.pattern!r}") break return issues @@ -187,9 +187,7 @@ def main() -> int: for path in _python_files(scan_path): if _imports_streamlit(path): streamlit_issues.append(f"{path.relative_to(ROOT)} imports Streamlit") - results.append( - _check_boundary("no-streamlit-in-strict-layers", not streamlit_issues, streamlit_issues) - ) + results.append(_check_boundary("no-streamlit-in-strict-layers", not streamlit_issues, streamlit_issues)) if streamlit_issues: any_failure = True @@ -197,12 +195,8 @@ def main() -> int: runtime_issues: list[str] = [] for runtime_lab in RUNTIME_LAB_PATHS: if runtime_lab.exists() and _runtime_has_inline_scenario_tuple(runtime_lab): - runtime_issues.append( - f"{runtime_lab.relative_to(ROOT)} still owns inline runtime scenario defaults" - ) - results.append( - _check_boundary("no-inline-scenario-defaults", not runtime_issues, runtime_issues) - ) + runtime_issues.append(f"{runtime_lab.relative_to(ROOT)} still owns inline runtime scenario defaults") + results.append(_check_boundary("no-inline-scenario-defaults", not runtime_issues, runtime_issues)) if runtime_issues: any_failure = True @@ -212,9 +206,7 @@ def main() -> int: for path in _python_files(scan_path): if _imports_streamlit(path): engine_streamlit_issues.append(f"{path.relative_to(ROOT)} imports Streamlit") - results.append( - _check_boundary("no-streamlit-in-engines", not engine_streamlit_issues, engine_streamlit_issues) - ) + results.append(_check_boundary("no-streamlit-in-engines", not engine_streamlit_issues, engine_streamlit_issues)) if engine_streamlit_issues: any_failure = True @@ -227,17 +219,13 @@ def main() -> int: f"{path.relative_to(ROOT)} contains inline production parameter defaults " "(should be loaded from versioned registry)" ) - results.append( - _check_boundary("no-inline-production-defaults-in-engines", not defaults_issues, defaults_issues) - ) + results.append(_check_boundary("no-inline-production-defaults-in-engines", not defaults_issues, defaults_issues)) if defaults_issues: any_failure = True # --- Boundary 5: No patient-level data references --- patient_issues = _check_patient_data_references() - results.append( - _check_boundary("no-patient-level-data-references", not patient_issues, patient_issues) - ) + results.append(_check_boundary("no-patient-level-data-references", not patient_issues, patient_issues)) if patient_issues: any_failure = True diff --git a/scripts/check_dash_browser_smoke.py b/scripts/check_dash_browser_smoke.py index 97cdef5..9de7792 100644 --- a/scripts/check_dash_browser_smoke.py +++ b/scripts/check_dash_browser_smoke.py @@ -179,7 +179,11 @@ def _check_route( interaction_failures = (download_ok is False) or (provenance_ok is False) graph_failure = graph_count < ROUTE_MIN_GRAPHS.get(_route_key(route), 0) return BrowserSmokeResult( - ok=not missing_text and not serious_console and not failed_requests and not interaction_failures and not graph_failure, + ok=not missing_text + and not serious_console + and not failed_requests + and not interaction_failures + and not graph_failure, route=route, viewport=viewport_name, missing_text=missing_text, @@ -215,7 +219,9 @@ def run_browser_smoke( try: print(f"checking {viewport_name} {route}", flush=True) results.append( - _check_route(page, base_url, route, viewport_name, viewport, timeout_seconds, screenshot_dir) + _check_route( + page, base_url, route, viewport_name, viewport, timeout_seconds, screenshot_dir + ) ) finally: page.close() diff --git a/scripts/check_data_freshness.py b/scripts/check_data_freshness.py index c660579..37cb76a 100644 --- a/scripts/check_data_freshness.py +++ b/scripts/check_data_freshness.py @@ -1,4 +1,5 @@ """Check freshness of public input data files.""" + from __future__ import annotations import sys @@ -17,6 +18,8 @@ ROOT / "uv.lock", ] MAX_AGE_DAYS = 90 + + def main() -> int: now = datetime.now(UTC) failures = 0 @@ -41,5 +44,7 @@ def main() -> int: print("") print(f"Result: {outcome} ({failures} issues)") return 1 if failures else 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/check_no_patient_data.py b/scripts/check_no_patient_data.py index 7fe5224..10fe090 100644 --- a/scripts/check_no_patient_data.py +++ b/scripts/check_no_patient_data.py @@ -70,16 +70,42 @@ # Files and directories to always skip # --------------------------------------------------------------------------- SKIP_DIRS = { - ".git", "__pycache__", ".ruff_cache", ".venv", ".quarto", - "node_modules", "target", "codex-tmp", ".tmp", "_site", - "public", ".streamlit", ".antigravitycli", ".github", - "conductor", ".mypy_cache", ".pytest_cache", ".dvc", + ".git", + "__pycache__", + ".ruff_cache", + ".venv", + ".quarto", + "node_modules", + "target", + "codex-tmp", + ".tmp", + "_site", + "public", + ".streamlit", + ".antigravitycli", + ".github", + "conductor", + ".mypy_cache", + ".pytest_cache", + ".dvc", } SKIP_EXTENSIONS = { - ".wasm", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", - ".woff", ".woff2", ".ttf", ".eot", ".pdf", ".lock", - ".arrow", ".parquet", + ".wasm", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".eot", + ".pdf", + ".lock", + ".arrow", + ".parquet", } SKIP_FILES = {"uv.lock", "Cargo.lock", "check_no_patient_data.py"} @@ -121,14 +147,16 @@ def scan_text_for_phi(content: str, filepath: Path) -> list[dict]: findings = [] for name, entry in PHI_PATTERNS.items(): for match in entry["pattern"].finditer(content): - findings.append({ - "file": str(filepath.relative_to(PROJECT_ROOT)), - "pattern": name, - "description": entry["description"], - "severity": entry["severity"], - "match_length": len(match.group()), - "line": content[: match.start()].count("\n") + 1, - }) + findings.append( + { + "file": str(filepath.relative_to(PROJECT_ROOT)), + "pattern": name, + "description": entry["description"], + "severity": entry["severity"], + "match_length": len(match.group()), + "line": content[: match.start()].count("\n") + 1, + } + ) return findings @@ -151,14 +179,16 @@ def scan_data_file_headers(filepath: Path) -> list[dict]: first_lines = "\n".join(content.split("\n")[:5]) for pattern in PHI_COLUMN_PATTERNS: for match in pattern.finditer(first_lines): - findings.append({ - "file": str(filepath.relative_to(PROJECT_ROOT)), - "pattern": "phi_column_header", - "description": f"PHI-related column header: '{match.group()}'", - "severity": "high", - "match_length": len(match.group()), - "line": first_lines[: match.start()].count("\n") + 1, - }) + findings.append( + { + "file": str(filepath.relative_to(PROJECT_ROOT)), + "pattern": "phi_column_header", + "description": f"PHI-related column header: '{match.group()}'", + "severity": "high", + "match_length": len(match.group()), + "line": first_lines[: match.start()].count("\n") + 1, + } + ) break return findings @@ -197,17 +227,16 @@ def audit_dataset_registry(registry: dict | None, verbose: bool) -> list[dict]: """Audit the dataset registry for compliance.""" issues = [] if registry is None: - issues.append({ - "type": "missing_registry", - "description": "No dataset-registry.json found in data/", - "severity": "warning", - }) + issues.append( + { + "type": "missing_registry", + "description": "No dataset-registry.json found in data/", + "severity": "warning", + } + ) return issues - registered_datasets = { - Path(d.get("path", "")).as_posix().rstrip("/") - for d in registry.get("datasets", []) - } + registered_datasets = {Path(d.get("path", "")).as_posix().rstrip("/") for d in registry.get("datasets", [])} data_dir = PROJECT_ROOT / "data" if data_dir.exists(): for f in data_dir.iterdir(): @@ -218,18 +247,18 @@ def audit_dataset_registry(registry: dict | None, verbose: bool) -> list[dict]: continue rel = f.relative_to(PROJECT_ROOT).as_posix() if rel not in registered_datasets: - issues.append({ - "type": "unregistered_dataset", - "description": f"Data file not in registry: {rel}", - "severity": "warning", - }) + issues.append( + { + "type": "unregistered_dataset", + "description": f"Data file not in registry: {rel}", + "severity": "warning", + } + ) return issues def main(): - parser = argparse.ArgumentParser( - description="PHI compliance check: zero patient-level data gate" - ) + parser = argparse.ArgumentParser(description="PHI compliance check: zero patient-level data gate") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--json", "-j", action="store_true", help="JSON report output") args = parser.parse_args() @@ -288,10 +317,7 @@ def main(): if report["pass"]: print(f"PASS - No PHI detected ({files_scanned} files scanned)") else: - print( - f"FAIL - {len(all_findings)} PHI pattern(s) found " - f"in {len(suspicious_files)} file(s)" - ) + print(f"FAIL - {len(all_findings)} PHI pattern(s) found in {len(suspicious_files)} file(s)") if registry_issues: print(f"[WARN] {len(registry_issues)} registry issue(s)") diff --git a/scripts/check_parameter_traceability.py b/scripts/check_parameter_traceability.py index b7cb993..9a50090 100644 --- a/scripts/check_parameter_traceability.py +++ b/scripts/check_parameter_traceability.py @@ -10,9 +10,17 @@ from models.primarycare_model.validation.public_parameter_loader import load_public_parameters # noqa: E402 REQUIRED = { - "source_id", "unit", "distribution_type", "distribution_parameters", "bounds", - "evidence_quality", "transferability_score", "sensitivity_priority", - "calibration_role", "update_cadence", "claim_boundary", + "source_id", + "unit", + "distribution_type", + "distribution_parameters", + "bounds", + "evidence_quality", + "transferability_score", + "sensitivity_priority", + "calibration_role", + "update_cadence", + "claim_boundary", } diff --git a/scripts/check_pixi_package_manager.py b/scripts/check_pixi_package_manager.py index 4f6ecd6..ab0d93a 100644 --- a/scripts/check_pixi_package_manager.py +++ b/scripts/check_pixi_package_manager.py @@ -45,7 +45,11 @@ def classify_pixi_help(help_text: str) -> tuple[bool, str]: lowered = help_text.lower() if "pixiv" in lowered or "bookmarks" in lowered or "illust" in lowered: return False, "The pixi executable appears to be a Pixiv downloader, not Prefix.dev Pixi." - if "task" in lowered and ("run" in lowered or "shell" in lowered) and ("environment" in lowered or "workspace" in lowered): + if ( + "task" in lowered + and ("run" in lowered or "shell" in lowered) + and ("environment" in lowered or "workspace" in lowered) + ): return True, "Prefix.dev Pixi command shape detected." if "prefix.dev" in lowered or "pixi is a package management" in lowered: return True, "Prefix.dev Pixi help text detected." diff --git a/scripts/check_public_only_boundary.py b/scripts/check_public_only_boundary.py index bf1e626..ceabaf7 100644 --- a/scripts/check_public_only_boundary.py +++ b/scripts/check_public_only_boundary.py @@ -8,8 +8,15 @@ ROOT = Path(__file__).resolve().parents[1] PUBLIC_ROOT = ROOT / "models" / "primarycare_model" / "registries" / "public" FORBIDDEN = { - "sensitive", "confidential", "private_admin", "patient" + "_level", "linked_data", - "stakeholder", "unpublished_expert_elicitation", "calibrated_from_private", "calibrated", + "sensitive", + "confidential", + "private_admin", + "patient" + "_level", + "linked_data", + "stakeholder", + "unpublished_expert_elicitation", + "calibrated_from_private", + "calibrated", } diff --git a/scripts/check_public_source_snapshot.py b/scripts/check_public_source_snapshot.py index 06c8fdc..a6d352e 100644 --- a/scripts/check_public_source_snapshot.py +++ b/scripts/check_public_source_snapshot.py @@ -17,8 +17,12 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Validate public-source snapshot readiness.") parser.add_argument("--verify-files", action="store_true", help="Require raw public source files for every source.") - parser.add_argument("--verify-checksums", action="store_true", help="Require non-pending raw file checksums to match.") - parser.add_argument("--verify-licences", action="store_true", help="Require licences to be in the allowed public set.") + parser.add_argument( + "--verify-checksums", action="store_true", help="Require non-pending raw file checksums to match." + ) + parser.add_argument( + "--verify-licences", action="store_true", help="Require licences to be in the allowed public set." + ) parser.add_argument("--verify-processed", action="store_true", help="Require processed files and companion hashes.") return parser.parse_args() diff --git a/scripts/check_remote_streamlit_smoke.py b/scripts/check_remote_streamlit_smoke.py index 1d9d8b0..1548fd4 100644 --- a/scripts/check_remote_streamlit_smoke.py +++ b/scripts/check_remote_streamlit_smoke.py @@ -15,7 +15,7 @@ "Oh no.", "Error running app", "Error installing requirements", - "Click \"Manage App\"", + 'Click "Manage App"', ) diff --git a/scripts/check_repo_health.py b/scripts/check_repo_health.py index a7f6287..c90bb9d 100644 --- a/scripts/check_repo_health.py +++ b/scripts/check_repo_health.py @@ -100,7 +100,12 @@ def check_contract_registries() -> tuple[bool, str]: def check_streamlit_boundary() -> tuple[bool, str]: - required = ["streamlit_app.py", "models/primarycare_model/app.py", ".streamlit/config.toml", ".streamlit/secrets.toml.example"] + required = [ + "streamlit_app.py", + "models/primarycare_model/app.py", + ".streamlit/config.toml", + ".streamlit/secrets.toml.example", + ] missing = [path for path in required if not exists(path)] if missing: return False, f"Streamlit surface missing: {missing}" @@ -204,7 +209,12 @@ def check_no_stub_modules() -> tuple[bool, str]: def check_claim_boundaries() -> tuple[bool, str]: - required_paths = ["README.md", "docs/calibration/model-card-v1.7.2.md", "docs/launch/claim-boundaries-v1.7.2.md", "conductor/state.md"] + required_paths = [ + "README.md", + "docs/calibration/model-card-v1.7.2.md", + "docs/launch/claim-boundaries-v1.7.2.md", + "conductor/state.md", + ] missing = [path for path in required_paths if not exists(path)] if missing: return False, f"claim-boundary evidence missing: {missing}" diff --git a/scripts/check_substack_publication_readiness.py b/scripts/check_substack_publication_readiness.py index 8c525e4..beb6467 100644 --- a/scripts/check_substack_publication_readiness.py +++ b/scripts/check_substack_publication_readiness.py @@ -1,4 +1,5 @@ """Score Substack-ready posts against series, Substack, and image contracts.""" + from __future__ import annotations import argparse @@ -210,10 +211,14 @@ def score_row(row: dict[str, object], live_draft: LiveDraft | None = None) -> Sc images += 2 else: failures.append(f"post {post_number}: image alt text or Mermaid caption should be descriptive") - if any("v1.7.2" in path.name or "pcf-v172" in path.name for path in [*found_images, local_post_path(hero_image)]) or has_coloured_mermaid(markdown): + if any( + "v1.7.2" in path.name or "pcf-v172" in path.name for path in [*found_images, local_post_path(hero_image)] + ) or has_coloured_mermaid(markdown): images += 2 else: - failures.append(f"post {post_number}: visual package should use the current coloured Mermaid or v1.7.2 visual set") + failures.append( + f"post {post_number}: visual package should use the current coloured Mermaid or v1.7.2 visual set" + ) live = 0 if live_draft is None: @@ -244,7 +249,9 @@ def score_row(row: dict[str, object], live_draft: LiveDraft | None = None) -> Sc else: warnings.append(f"post {post_number}: live draft has no cached in-body image nodes") - return Score(series=series, substack=substack, images=images, live=live, failures=tuple(failures), warnings=tuple(warnings)) + return Score( + series=series, substack=substack, images=images, live=live, failures=tuple(failures), warnings=tuple(warnings) + ) def load_rows(post_numbers: set[str]) -> list[dict[str, object]]: @@ -266,7 +273,9 @@ def load_live_map(path_value: str) -> list[dict[str, object]]: def map_live_drafts_to_rows(rows: list[dict[str, object]], live_map: list[dict[str, object]]) -> dict[str, int]: by_id = {str(item.get("id", "")): int(item["draftId"]) for item in live_map if item.get("draftId")} - by_sequence = {int(item["sequence"]): int(item["draftId"]) for item in live_map if item.get("draftId") and item.get("sequence")} + by_sequence = { + int(item["sequence"]): int(item["draftId"]) for item in live_map if item.get("draftId") and item.get("sequence") + } selected: dict[str, int] = {} for row in rows: post_number = str(row.get("postNumber", "")).zfill(2) @@ -348,7 +357,9 @@ def main() -> int: if score.images < args.min_images: failures.append(f"post {post_number}: image score {score.images}/10 below {args.min_images}/10") if health_quality < args.min_health_quality: - failures.append(f"post {post_number}: health.quality {health_quality}/100 below {args.min_health_quality}/100") + failures.append( + f"post {post_number}: health.quality {health_quality}/100 below {args.min_health_quality}/100" + ) average = round(total_health / len(rows), 1) if rows else 0 payload = {"average_health.quality": average, "posts": results} diff --git a/scripts/check_substack_schedule_contract.py b/scripts/check_substack_schedule_contract.py index 77f8a5f..39399a5 100644 --- a/scripts/check_substack_schedule_contract.py +++ b/scripts/check_substack_schedule_contract.py @@ -1,4 +1,5 @@ """Validate Substack launch schedules against public-post contracts.""" + from __future__ import annotations import json diff --git a/scripts/dev_check.py b/scripts/dev_check.py index dcc0435..ab39d3e 100644 --- a/scripts/dev_check.py +++ b/scripts/dev_check.py @@ -17,11 +17,29 @@ class Check: CHECKS = [ Check([sys.executable, "-m", "ruff", "check", "."]), Check([sys.executable, "-m", "basedpyright", "--pythonpath", sys.executable]), - Check([sys.executable, "-m", "mypy", "models/primarycare_model/contracts", "models/primarycare_model/validation/pandera_schemas.py"]), + Check( + [ + sys.executable, + "-m", + "mypy", + "models/primarycare_model/contracts", + "models/primarycare_model/validation/pandera_schemas.py", + ] + ), Check([sys.executable, "scripts/check_repo_health.py"]), Check([sys.executable, "scripts/check_concern_boundaries.py"]), Check([sys.executable, "scripts/check_no_patient_data.py"]), - Check([sys.executable, "-m", "pytest", "-q", "--cov=models.primarycare_model", "--cov-report=term-missing", "--cov-fail-under=90"]), + Check( + [ + sys.executable, + "-m", + "pytest", + "-q", + "--cov=models.primarycare_model", + "--cov-report=term-missing", + "--cov-fail-under=90", + ] + ), Check([sys.executable, "-m", "pip_audit"]), Check([sys.executable, "-m", "py_compile", "streamlit_app.py", "models/primarycare_model/app.py"]), Check([sys.executable, "-m", "ty", "check", "models/primarycare_model"], required=False), diff --git a/scripts/generate_release_manifest.py b/scripts/generate_release_manifest.py index e9f63b2..1fa4b96 100644 --- a/scripts/generate_release_manifest.py +++ b/scripts/generate_release_manifest.py @@ -26,8 +26,12 @@ def build_manifest() -> dict[str, object]: "claim_level": str(calibration["claim_level"]), "calibration_status": str(calibration["calibration_status"]), "not_valid_for": list(calibration["not_valid_for"]), - "source_snapshot_hash": sha(ROOT / "models" / "primarycare_model" / "registries" / "public" / "sources.public.v1.yaml"), - "parameter_hash": sha(ROOT / "models" / "primarycare_model" / "registries" / "public" / "parameters.public.v1.yaml"), + "source_snapshot_hash": sha( + ROOT / "models" / "primarycare_model" / "registries" / "public" / "sources.public.v1.yaml" + ), + "parameter_hash": sha( + ROOT / "models" / "primarycare_model" / "registries" / "public" / "parameters.public.v1.yaml" + ), "model_hash": sha(ROOT / "models" / "primarycare_model" / "ui" / "cockpit.py"), "output_hash": sha(ROOT / "data" / "snapshots" / "public-source-snapshot-v1.json"), "test_status": "not-run-by-manifest-generator", diff --git a/scripts/generate_release_model_card.py b/scripts/generate_release_model_card.py index 56ccecc..afeff4b 100644 --- a/scripts/generate_release_model_card.py +++ b/scripts/generate_release_model_card.py @@ -17,8 +17,7 @@ def build_card() -> str: version = (ROOT / "VERSION").read_text(encoding="utf-8").strip() calibration = run_public_aggregate_calibration() validation_gates = "\n".join( - f"- {row['gate_id']}: {row['status']} ({row['gate_family']})" - for row in calibration["validation_gates"] + f"- {row['gate_id']}: {row['status']} ({row['gate_family']})" for row in calibration["validation_gates"] ) not_valid_for = ", ".join(str(item) for item in calibration["not_valid_for"]) return f"""# GTPCNZ public model card v{version} diff --git a/scripts/polish_substack_simulation_plots.py b/scripts/polish_substack_simulation_plots.py index ad80968..293497e 100644 --- a/scripts/polish_substack_simulation_plots.py +++ b/scripts/polish_substack_simulation_plots.py @@ -73,7 +73,12 @@ def main() -> None: latest_path = REPORTS / "substack-simulation-plot-polish-latest.json" report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") latest_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - print(json.dumps({"report": str(report_path.relative_to(ROOT)), "passed": report["passed"], "after": report["after"]}, indent=2)) + print( + json.dumps( + {"report": str(report_path.relative_to(ROOT)), "passed": report["passed"], "after": report["after"]}, + indent=2, + ) + ) if not report["passed"]: raise SystemExit("One or more polished plots scored below 95.") @@ -120,7 +125,9 @@ def set_style() -> None: ) -def finish(fig: plt.Figure, ax: plt.Axes | None, path: Path, footer: str = "Illustrative indices; not a forecast.") -> None: +def finish( + fig: plt.Figure, ax: plt.Axes | None, path: Path, footer: str = "Illustrative indices; not a forecast." +) -> None: if ax is not None: ax.grid(axis="y", color=COLORS["grid"], linewidth=0.8) ax.spines["top"].set_visible(False) @@ -136,8 +143,17 @@ def render_supply_pressure(path: Path) -> None: fig, ax = plt.subplots(figsize=(12.8, 7.2)) ax.scatter(x, y, s=95, color=COLORS["slate"], alpha=0.95, edgecolor="white", linewidth=1.3) ax.scatter([55.5], [61.8], s=210, color=COLORS["green"], edgecolor=COLORS["ink"], linewidth=1.3, zorder=3) - ax.annotate("Full hybrid", xy=(55.5, 61.8), xytext=(-88, -14), textcoords="offset points", fontsize=13, weight="bold") - ax.annotate("Current cluster", xy=(14.2, 78.4), xytext=(16.8, 76.0), fontsize=12, color=COLORS["muted"], arrowprops={"arrowstyle": "-", "color": COLORS["muted"]}) + ax.annotate( + "Full hybrid", xy=(55.5, 61.8), xytext=(-88, -14), textcoords="offset points", fontsize=13, weight="bold" + ) + ax.annotate( + "Current cluster", + xy=(14.2, 78.4), + xytext=(16.8, 76.0), + fontsize=12, + color=COLORS["muted"], + arrowprops={"arrowstyle": "-", "color": COLORS["muted"]}, + ) ax.set_title("Supply generation vs hospital pressure", loc="left", pad=14, weight="bold") ax.set_xlabel("Supply generation index") ax.set_ylabel("Hospital pressure index (lower is better)") @@ -196,8 +212,8 @@ def render_marginal_supply(path: Path) -> None: def render_gaming_frontier(path: Path) -> None: control = np.linspace(0, 1, 44) - access_gain = 20 + 55 * (control ** 0.88) - residual_risk = 84 - 72 * (control ** 0.82) + access_gain = 20 + 55 * (control**0.88) + residual_risk = 84 - 72 * (control**0.82) fig, ax = plt.subplots(figsize=(13.2, 7.4)) scatter = ax.scatter(access_gain, residual_risk, c=control, cmap="viridis", s=72, edgecolor="white", linewidth=0.45) ax.set_title("Gaming-risk frontier", loc="left", pad=14, weight="bold") @@ -205,8 +221,22 @@ def render_gaming_frontier(path: Path) -> None: ax.set_ylabel("Residual gaming-risk index (lower is better)") ax.set_xlim(17, 78) ax.set_ylim(90, 5) - ax.annotate("Weak controls", xy=(22, 82), xytext=(28, 72), fontsize=12, color=COLORS["muted"], arrowprops={"arrowstyle": "-", "color": COLORS["muted"]}) - ax.annotate("Strong controls", xy=(73, 14), xytext=(59, 20), fontsize=12, color=COLORS["muted"], arrowprops={"arrowstyle": "-", "color": COLORS["muted"]}) + ax.annotate( + "Weak controls", + xy=(22, 82), + xytext=(28, 72), + fontsize=12, + color=COLORS["muted"], + arrowprops={"arrowstyle": "-", "color": COLORS["muted"]}, + ) + ax.annotate( + "Strong controls", + xy=(73, 14), + xytext=(59, 20), + fontsize=12, + color=COLORS["muted"], + arrowprops={"arrowstyle": "-", "color": COLORS["muted"]}, + ) colorbar = fig.colorbar(scatter, ax=ax, pad=0.018, fraction=0.045) colorbar.set_label("Control strength", fontsize=12) finish(fig, ax, path) diff --git a/scripts/run_public_aggregate_calibration.py b/scripts/run_public_aggregate_calibration.py index 6dd0225..c74b2c8 100644 --- a/scripts/run_public_aggregate_calibration.py +++ b/scripts/run_public_aggregate_calibration.py @@ -20,7 +20,10 @@ def main() -> int: args = parser.parse_args() result = run_public_aggregate_calibration() print(json.dumps(result, indent=2, sort_keys=True)) - if args.check_only and result["calibration_status"] not in {"public_aggregate_validated", "calibration_readiness_only"}: + if args.check_only and result["calibration_status"] not in { + "public_aggregate_validated", + "calibration_readiness_only", + }: return 1 return 0 diff --git a/scripts/score_substack_simulation_plot_readability.py b/scripts/score_substack_simulation_plot_readability.py index d4c5118..b668cd7 100644 --- a/scripts/score_substack_simulation_plot_readability.py +++ b/scripts/score_substack_simulation_plot_readability.py @@ -48,7 +48,9 @@ def original_backup_dir() -> Path: backup_root = ROOT / ".tmp" candidates = sorted(path for path in backup_root.glob("sim-plot-backup-*") if path.is_dir()) if not candidates: - latest_polish = json.loads((REPORTS / "substack-simulation-plot-polish-latest.json").read_text(encoding="utf-8")) + latest_polish = json.loads( + (REPORTS / "substack-simulation-plot-polish-latest.json").read_text(encoding="utf-8") + ) return ROOT / latest_polish["backupDir"] return candidates[0] diff --git a/scripts/sync_public_mirror.py b/scripts/sync_public_mirror.py index d093b6b..94affb2 100644 --- a/scripts/sync_public_mirror.py +++ b/scripts/sync_public_mirror.py @@ -1,4 +1,5 @@ """Sync root source files to public/gtpcnz mirror.""" + from __future__ import annotations import filecmp @@ -13,8 +14,14 @@ (ROOT / "pyproject.toml", MIRROR / "pyproject.toml"), (ROOT / "uv.lock", MIRROR / "uv.lock"), (ROOT / "models" / "primarycare_model" / "app.py", MIRROR / "models" / "primarycare_model" / "app.py"), - (ROOT / "models" / "primarycare_model" / "runtime_lab.py", MIRROR / "models" / "primarycare_model" / "runtime_lab.py"), - (ROOT / "models" / "primarycare_model" / "scenario_service.py", MIRROR / "models" / "primarycare_model" / "scenario_service.py"), + ( + ROOT / "models" / "primarycare_model" / "runtime_lab.py", + MIRROR / "models" / "primarycare_model" / "runtime_lab.py", + ), + ( + ROOT / "models" / "primarycare_model" / "scenario_service.py", + MIRROR / "models" / "primarycare_model" / "scenario_service.py", + ), (ROOT / "models" / "primarycare_model" / "__init__.py", MIRROR / "models" / "primarycare_model" / "__init__.py"), ] for sub in [ @@ -34,6 +41,8 @@ for pattern in ["**/*.py", "**/*.yaml"]: for f in src_dir.glob(pattern): COPY_MAP.append((f, dst_dir / f.relative_to(src_dir))) + + def main() -> int: check_only = "--check" in sys.argv failures = 0 @@ -63,5 +72,7 @@ def main() -> int: print("") print(f"Sync complete. {failures} errors.") return 1 if failures else 0 + + if __name__ == "__main__": sys.exit(main())