|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace App\Console\Commands; |
| 4 | + |
| 5 | +use App\Concerns\SourceAware; |
| 6 | +use App\Context\SourceContext; |
| 7 | +use App\Models\App\Source; |
| 8 | +use App\Models\App\Study; |
| 9 | +use App\Models\App\StudyResult; |
| 10 | +use Illuminate\Console\Command; |
| 11 | +use Illuminate\Support\Facades\DB; |
| 12 | + |
| 13 | +/** |
| 14 | + * Hypertension Outcomes Program v5 executor (ACUM-PROT-HTN-V5-001). |
| 15 | + * |
| 16 | + * This is the real study executor scaffold from CLAUDE_PROMPT_v5.md §2. It runs |
| 17 | + * only the analyses that are genuinely computable in this environment with |
| 18 | + * read-only aggregate SQL over the Acumenus omop CDM and no external statistics |
| 19 | + * runtime: |
| 20 | + * |
| 21 | + * Analysis M — comorbidity comparison matrix (§5.1, descriptive core): real |
| 22 | + * prevalence + Wilson 95% CI per morbidity × population × epoch, computed from |
| 23 | + * results.cohort membership × omop.condition_occurrence, morbidity concepts |
| 24 | + * resolved from verified app.concept_sets via vocab.concept_ancestor. |
| 25 | + * |
| 26 | + * The causal / survival analyses (O overlap-weighting, P target-trial + IPCW, |
| 27 | + * R instrumental variable, F/G/H survival, N BP-distribution) require the R / |
| 28 | + * HADES runtime (WeightIt/PSweight/survival), which is NOT present in this |
| 29 | + * compose stack — those actions log a clear skip rather than fabricate results. |
| 30 | + * The covariate-adjusted odds ratios in Analysis M (also R-based) are likewise |
| 31 | + * deferred; the descriptive prevalence core is exact and needs no R. |
| 32 | + * |
| 33 | + * Writes only to results.htn_v4_* and app.study_results. Never touches |
| 34 | + * omop / vocab (read-only). No person_id / PHI in any egress — group-level |
| 35 | + * aggregates only. |
| 36 | + */ |
| 37 | +class StudyHtnV4 extends Command |
| 38 | +{ |
| 39 | + use SourceAware; |
| 40 | + |
| 41 | + protected $signature = 'study:htn-v4 |
| 42 | + {--action=analyses : reuse-audit|analyses|report} |
| 43 | + {--plan-version=v5 : analysis-plan version (avoids the reserved --version flag)} |
| 44 | + {--study=165 : study id} |
| 45 | + {--source=ACUMENUS : source key whose results schema holds the tables} |
| 46 | + {--dry-run : report without persisting}'; |
| 47 | + |
| 48 | + protected $description = 'Hypertension v5 executor — runs the CDM-computable analyses (Analysis M comorbidity matrix); R-based causal analyses are skipped when the R runtime is absent'; |
| 49 | + |
| 50 | + /** Delay-group / comparator populations (verified counts, study 165). */ |
| 51 | + private const POPULATIONS = [ |
| 52 | + 5450 => 'G1 (timely ≤3mo)', |
| 53 | + 5451 => 'G2 (3–6mo)', |
| 54 | + 5452 => 'G3 (6–12mo)', |
| 55 | + 5453 => 'G4 (delayed >12mo)', |
| 56 | + 5454 => 'Never-diagnosed', |
| 57 | + 5455 => 'Comparator C', |
| 58 | + ]; |
| 59 | + |
| 60 | + /** |
| 61 | + * Morbidity → verified concept_set id. Only sets with resolvable |
| 62 | + * concept_set_items are included; the remaining spec morbidities are reported |
| 63 | + * as pending concept-set materialisation rather than resolved by guesswork. |
| 64 | + * |
| 65 | + * @var array<string, int> |
| 66 | + */ |
| 67 | + private const MORBIDITY_SETS = [ |
| 68 | + 'Diabetes mellitus' => 55, |
| 69 | + 'Heart failure' => 176, |
| 70 | + 'Chronic kidney disease' => 186, |
| 71 | + 'Primary aldosteronism' => 191, |
| 72 | + ]; |
| 73 | + |
| 74 | + /** Spec morbidities with no verified concept set yet (Analysis M coverage gap). */ |
| 75 | + private const PENDING_MORBIDITIES = [ |
| 76 | + 'Dyslipidemia', 'Obesity', 'Sleep apnea', 'COPD', 'Depression/anxiety', |
| 77 | + 'Coronary artery disease', 'Peripheral vascular disease', 'Cerebrovascular disease', |
| 78 | + 'Atrial fibrillation', 'Hypertensive retinopathy', 'Cancer', 'Dementia', 'Liver disease', |
| 79 | + ]; |
| 80 | + |
| 81 | + public function handle(): int |
| 82 | + { |
| 83 | + $action = (string) $this->option('action'); |
| 84 | + $studyId = (int) $this->option('study'); |
| 85 | + |
| 86 | + if (! Study::query()->whereKey($studyId)->exists()) { |
| 87 | + $this->error("Study {$studyId} not found."); |
| 88 | + |
| 89 | + return self::FAILURE; |
| 90 | + } |
| 91 | + |
| 92 | + return match ($action) { |
| 93 | + 'reuse-audit' => $this->reuseAudit($studyId), |
| 94 | + 'analyses' => $this->runAnalyses($studyId), |
| 95 | + 'report' => $this->report($studyId), |
| 96 | + default => tap(self::FAILURE, fn () => $this->error("Unknown action '{$action}'.")), |
| 97 | + }; |
| 98 | + } |
| 99 | + |
| 100 | + private function reuseAudit(int $studyId): int |
| 101 | + { |
| 102 | + $this->info("Reuse audit — study {$studyId}"); |
| 103 | + foreach (self::POPULATIONS as $id => $label) { |
| 104 | + $n = DB::table('results.cohort')->where('cohort_definition_id', $id)->count(); |
| 105 | + $this->line(sprintf(' %-22s cohort %d = %d', $label, $id, $n)); |
| 106 | + } |
| 107 | + $this->line(' Morbidity concept sets available: '.implode(', ', array_keys(self::MORBIDITY_SETS))); |
| 108 | + $this->warn(' Pending concept-set materialisation: '.implode(', ', self::PENDING_MORBIDITIES)); |
| 109 | + |
| 110 | + return self::SUCCESS; |
| 111 | + } |
| 112 | + |
| 113 | + private function runAnalyses(int $studyId): int |
| 114 | + { |
| 115 | + $this->info("Analysis M — comorbidity comparison matrix (real CDM) · study {$studyId}"); |
| 116 | + $this->reportRuntimeGaps(); |
| 117 | + |
| 118 | + $denoms = $this->populationDenominators(); |
| 119 | + $rows = []; |
| 120 | + $heatmap = []; |
| 121 | + |
| 122 | + foreach (self::MORBIDITY_SETS as $morbidity => $conceptSetId) { |
| 123 | + $byPop = $this->morbidityByPopulation($conceptSetId); |
| 124 | + foreach (self::POPULATIONS as $popId => $popLabel) { |
| 125 | + $counts = $byPop[$popId] ?? ['pre' => 0, 'new' => 0, 'ever' => 0]; |
| 126 | + $denom = $denoms[$popId] ?? 0; |
| 127 | + [$prev, $lo, $hi] = $this->wilson($counts['ever'], $denom); |
| 128 | + |
| 129 | + $rows[] = [ |
| 130 | + 'morbidity' => $morbidity, |
| 131 | + 'population' => $popLabel, |
| 132 | + 'prevalence' => $prev, |
| 133 | + 'wilson_lo' => $lo, |
| 134 | + 'wilson_hi' => $hi, |
| 135 | + 'n_present' => $counts['ever'], |
| 136 | + 'n_total' => $denom, |
| 137 | + 'adjusted_or' => null, |
| 138 | + 'or_ci_lo' => null, |
| 139 | + 'or_ci_hi' => null, |
| 140 | + ]; |
| 141 | + $heatmap[] = [ |
| 142 | + 'morbidity' => $morbidity, |
| 143 | + 'population' => $popLabel, |
| 144 | + 'prevalence' => $prev, |
| 145 | + 'wilson_lo' => $lo, |
| 146 | + 'wilson_hi' => $hi, |
| 147 | + 'n_present' => $counts['ever'], |
| 148 | + 'n_pre_existing' => $counts['pre'], |
| 149 | + 'n_newly' => $counts['new'], |
| 150 | + 'n_total' => $denom, |
| 151 | + ]; |
| 152 | + $this->line(sprintf(' %-24s %-20s ever=%d/%d (%.1f%%)', $morbidity, $popLabel, $counts['ever'], $denom, $prev * 100)); |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + if ($this->option('dry-run')) { |
| 157 | + $this->warn(' [dry-run] '.count($rows).' matrix rows computed; not persisted.'); |
| 158 | + |
| 159 | + return self::SUCCESS; |
| 160 | + } |
| 161 | + |
| 162 | + $this->persistLongForm($rows); |
| 163 | + $this->persistStudyResult($studyId, $heatmap); |
| 164 | + $this->info('Analysis M persisted (real CDM data). O/P/R/N/F/G/H require the R runtime — skipped.'); |
| 165 | + |
| 166 | + return self::SUCCESS; |
| 167 | + } |
| 168 | + |
| 169 | + private function report(int $studyId): int |
| 170 | + { |
| 171 | + $this->info("Report — study {$studyId}: render via the frontend v5 Report tab (StudyV5ReportTab)."); |
| 172 | + $this->line(' Standalone HTML/PDF report generation is deferred to the R report step (runtime absent).'); |
| 173 | + |
| 174 | + return self::SUCCESS; |
| 175 | + } |
| 176 | + |
| 177 | + private function reportRuntimeGaps(): void |
| 178 | + { |
| 179 | + $this->warn(' R / HADES runtime not present in this stack — the following are NOT run:'); |
| 180 | + $this->line(' O (ATO overlap-weighting) · P (target-trial + IPCW) · R (site IV / 2SRI)'); |
| 181 | + $this->line(' N (BP distribution, R) · F/G/H (survival) · Analysis M adjusted ORs (R logistic)'); |
| 182 | + } |
| 183 | + |
| 184 | + /** @return array<int, int> cohort_definition_id → member count. */ |
| 185 | + private function populationDenominators(): array |
| 186 | + { |
| 187 | + return DB::table('results.cohort') |
| 188 | + ->select('cohort_definition_id', DB::raw('count(*) as n')) |
| 189 | + ->whereIn('cohort_definition_id', array_keys(self::POPULATIONS)) |
| 190 | + ->groupBy('cohort_definition_id') |
| 191 | + ->pluck('n', 'cohort_definition_id') |
| 192 | + ->map(fn ($n): int => (int) $n) |
| 193 | + ->all(); |
| 194 | + } |
| 195 | + |
| 196 | + /** |
| 197 | + * Real per-population comorbidity counts (pre-existing / newly-occurring / |
| 198 | + * ever) for one morbidity concept set. Concepts resolved from verified seed |
| 199 | + * roots via vocab.concept_ancestor (standard OMOP descendant expansion — |
| 200 | + * verified roots, not guessed ids). Fully-qualified schema names read the |
| 201 | + * omop/vocab/results schemas on the default connection. |
| 202 | + * |
| 203 | + * @return array<int, array{pre: int, new: int, ever: int}> |
| 204 | + */ |
| 205 | + private function morbidityByPopulation(int $conceptSetId): array |
| 206 | + { |
| 207 | + $sql = <<<'SQL' |
| 208 | + with concepts as ( |
| 209 | + select ca.descendant_concept_id as concept_id |
| 210 | + from app.concept_set_items csi |
| 211 | + join vocab.concept_ancestor ca on ca.ancestor_concept_id = csi.concept_id |
| 212 | + where csi.concept_set_id = ? |
| 213 | + and coalesce(csi.is_excluded, false) = false |
| 214 | + and coalesce(csi.include_descendants, true) = true |
| 215 | + union |
| 216 | + select concept_id |
| 217 | + from app.concept_set_items |
| 218 | + where concept_set_id = ? |
| 219 | + and coalesce(is_excluded, false) = false |
| 220 | + and coalesce(include_descendants, true) = false |
| 221 | + ), |
| 222 | + excluded as ( |
| 223 | + select concept_id |
| 224 | + from app.concept_set_items |
| 225 | + where concept_set_id = ? and coalesce(is_excluded, false) = true |
| 226 | + ), |
| 227 | + eligible as ( |
| 228 | + select concept_id from concepts |
| 229 | + except |
| 230 | + select concept_id from excluded |
| 231 | + ), |
| 232 | + pop as ( |
| 233 | + select cohort_definition_id, subject_id, cohort_start_date |
| 234 | + from results.cohort |
| 235 | + where cohort_definition_id in (5450, 5451, 5452, 5453, 5454, 5455) |
| 236 | + ), |
| 237 | + hits as ( |
| 238 | + select p.cohort_definition_id, p.subject_id, |
| 239 | + bool_or(co.condition_start_date <= p.cohort_start_date) as pre_existing, |
| 240 | + bool_or(co.condition_start_date > p.cohort_start_date) as newly |
| 241 | + from pop p |
| 242 | + join omop.condition_occurrence co on co.person_id = p.subject_id |
| 243 | + where co.condition_concept_id in (select concept_id from eligible) |
| 244 | + group by p.cohort_definition_id, p.subject_id |
| 245 | + ) |
| 246 | + select cohort_definition_id, |
| 247 | + count(*) filter (where pre_existing) as n_pre, |
| 248 | + count(*) filter (where newly) as n_new, |
| 249 | + count(*) as n_ever |
| 250 | + from hits |
| 251 | + group by cohort_definition_id |
| 252 | + SQL; |
| 253 | + |
| 254 | + $out = []; |
| 255 | + foreach (DB::select($sql, [$conceptSetId, $conceptSetId, $conceptSetId]) as $row) { |
| 256 | + $out[(int) $row->cohort_definition_id] = [ |
| 257 | + 'pre' => (int) $row->n_pre, |
| 258 | + 'new' => (int) $row->n_new, |
| 259 | + 'ever' => (int) $row->n_ever, |
| 260 | + ]; |
| 261 | + } |
| 262 | + |
| 263 | + return $out; |
| 264 | + } |
| 265 | + |
| 266 | + /** |
| 267 | + * Wilson score interval for a binomial proportion. |
| 268 | + * |
| 269 | + * @return array{0: float, 1: float, 2: float} [prevalence, lo, hi] |
| 270 | + */ |
| 271 | + private function wilson(int $present, int $total, float $z = 1.96): array |
| 272 | + { |
| 273 | + if ($total <= 0) { |
| 274 | + return [0.0, 0.0, 0.0]; |
| 275 | + } |
| 276 | + $p = $present / $total; |
| 277 | + $z2 = $z * $z; |
| 278 | + $denom = 1 + $z2 / $total; |
| 279 | + $centre = ($p + $z2 / (2 * $total)) / $denom; |
| 280 | + $margin = ($z * sqrt(($p * (1 - $p) + $z2 / (4 * $total)) / $total)) / $denom; |
| 281 | + |
| 282 | + return [ |
| 283 | + round($p, 4), |
| 284 | + round(max(0.0, $centre - $margin), 4), |
| 285 | + round(min(1.0, $centre + $margin), 4), |
| 286 | + ]; |
| 287 | + } |
| 288 | + |
| 289 | + /** |
| 290 | + * @param list<array<string, mixed>> $rows |
| 291 | + */ |
| 292 | + private function persistLongForm(array $rows): void |
| 293 | + { |
| 294 | + $source = Source::query()->where('source_key', $this->option('source'))->first(); |
| 295 | + if (! $source instanceof Source) { |
| 296 | + $this->warn(" ⚠ Source '{$this->option('source')}' not found — skipped long-form table write."); |
| 297 | + |
| 298 | + return; |
| 299 | + } |
| 300 | + SourceContext::forSource($source); |
| 301 | + $c = $this->results(); |
| 302 | + |
| 303 | + $exists = (bool) ($c->selectOne("select to_regclass('results.htn_v4_m_comorbidity_matrix') is not null as ok")->ok ?? false); |
| 304 | + if (! $exists) { |
| 305 | + $this->warn(' ⚠ results.htn_v4_m_comorbidity_matrix missing — run scripts/sql/htn-v5-fixture-tables.sql first.'); |
| 306 | + |
| 307 | + return; |
| 308 | + } |
| 309 | + |
| 310 | + $c->statement('TRUNCATE results.htn_v4_m_comorbidity_matrix'); |
| 311 | + foreach ($rows as $row) { |
| 312 | + $c->table('htn_v4_m_comorbidity_matrix')->insert($row); |
| 313 | + } |
| 314 | + $this->line(' ✓ results.htn_v4_m_comorbidity_matrix ('.count($rows).' real rows)'); |
| 315 | + } |
| 316 | + |
| 317 | + /** |
| 318 | + * Replace the comorbidity_matrix study_results row's summary_data with the |
| 319 | + * real CDM figures (drops the fixture flag for this analysis). |
| 320 | + * |
| 321 | + * @param list<array<string, mixed>> $heatmap |
| 322 | + */ |
| 323 | + private function persistStudyResult(int $studyId, array $heatmap): void |
| 324 | + { |
| 325 | + $result = StudyResult::query() |
| 326 | + ->where('study_id', $studyId) |
| 327 | + ->where('result_type', 'comorbidity_matrix') |
| 328 | + ->first(); |
| 329 | + |
| 330 | + if (! $result instanceof StudyResult) { |
| 331 | + $this->warn(' ⚠ no comorbidity_matrix study_results row to update (run the fixture seeder first).'); |
| 332 | + |
| 333 | + return; |
| 334 | + } |
| 335 | + |
| 336 | + $result->summary_data = [ |
| 337 | + 'analysis_code' => 'M', |
| 338 | + 'label' => 'Comorbidity Comparison Matrix (real CDM prevalence)', |
| 339 | + 'data_source' => 'cdm', |
| 340 | + 'computed_at' => now()->toDateString(), |
| 341 | + 'morbidities' => array_keys(self::MORBIDITY_SETS), |
| 342 | + 'populations' => array_values(self::POPULATIONS), |
| 343 | + 'heatmap' => $heatmap, |
| 344 | + 'pending_morbidities' => self::PENDING_MORBIDITIES, |
| 345 | + 'note' => 'Real prevalence + Wilson 95% CI from the Acumenus omop CDM for '.count(self::MORBIDITY_SETS).' morbidities with verified concept sets. Adjusted ORs and the remaining '.count(self::PENDING_MORBIDITIES).' morbidities are pending (require R runtime / concept-set materialisation).', |
| 346 | + 'result_table' => 'comorbidity-matrix', |
| 347 | + ]; |
| 348 | + $result->diagnostics = ['data_source' => 'cdm', 'r_runtime' => 'absent']; |
| 349 | + $result->save(); |
| 350 | + |
| 351 | + $this->line(' ✓ app.study_results comorbidity_matrix row updated to real CDM data'); |
| 352 | + } |
| 353 | +} |
0 commit comments