Skip to content

Commit ccd72b4

Browse files
committed
feat(studies): real executor — compute Analysis M comorbidity matrix from CDM
Adds study:htn-v4 (StudyHtnV4, CLAUDE_PROMPT_v5 §2) and runs Analysis M's descriptive core against the live Acumenus omop CDM, replacing that fixture with real data: - Morbidity concepts resolved from verified app.concept_sets seed roots via vocab.concept_ancestor (no guessed ids). Covers Diabetes, Heart failure, CKD, Primary aldosteronism (the morbidities with verified sets). - Real count + prevalence + Wilson 95% CI per morbidity × 6 populations (G1–G4/never/comparator from results.cohort) × 2 epochs. 24 real rows → results.htn_v4_m_comorbidity_matrix; comorbidity_matrix study_results row now data_source=cdm (fixture flag dropped). Index-driven, ~0.2s/morbidity, no R. - Frontend M view shows a "Real CDM data" note + pending-morbidity list. The R/HADES runtime is absent from this stack, so O/P/R/N/F/G/H (all causal / survival) are logged as skipped, not fabricated. Pint + PHPStan L8, tsc + vite + eslint clean.
1 parent 0710625 commit ccd72b4

3 files changed

Lines changed: 409 additions & 2 deletions

File tree

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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+
}

docs/devlog/modules/studies/2026-07-04-htn-v5-frontend-surfacing.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ for a real v5 finding.
6565
- Frontend: `tsc --noEmit` clean, `vite build` clean, `eslint` clean, `vitest`
6666
14/14 (adapter + narrowing logic).
6767

68+
## Follow-up — real executor + real Analysis M (same day)
69+
70+
Built `study:htn-v4` (`StudyHtnV4` command, CLAUDE_PROMPT_v5 §2) and ran the one
71+
analysis genuinely computable in this environment: **Analysis M (comorbidity
72+
matrix), descriptive core**, from the live CDM.
73+
74+
- Morbidity concepts resolved from **verified** `app.concept_sets` seed roots via
75+
`vocab.concept_ancestor` descendant expansion (standard OMOP resolution — no
76+
guessed concept ids). Four morbidities have verified sets: Diabetes (55), Heart
77+
failure (176), CKD (186), Primary aldosteronism (191).
78+
- Per morbidity × 6 real populations (G1–G4, never-diagnosed, comparator C from
79+
`results.cohort`) × 2 epochs (pre-existing / newly-occurring vs the member's
80+
index date): real count + prevalence + **Wilson 95% CI**. 24 real rows → real
81+
`results.htn_v4_m_comorbidity_matrix`; the `comorbidity_matrix` study_results
82+
row is now `data_source=cdm` (fixture flag dropped) and the UI shows a green
83+
"Real CDM data" note listing the pending morbidities.
84+
- Sanity: CKD 9–13% in the diagnosed delay groups vs 0.5% never-diagnosed / 0.4%
85+
comparator; diabetes shows the expected gradient — clinically coherent.
86+
- Query is index-driven (`idx_co_concept_person`), ~0.2 s per morbidity, no
87+
`measurement` value-scan, no R. Pint + PHPStan L8 clean.
88+
89+
### Hard environmental blockers (why the rest stays fixture)
90+
- **The R / HADES runtime is absent from this compose stack** (`r-runtime` = "no
91+
such service"). That makes **O (ATO), P (target-trial + IPCW), R (site IV /
92+
2SRI), F/G/H (survival), N (BP distribution), and Analysis M's adjusted ORs**
93+
impossible to run here — they are the statistical heart of v5. The command logs
94+
an explicit skip rather than fabricating estimates.
95+
- The remaining 13 spec morbidities need concept-set materialisation (verified
96+
roots), not guesswork.
97+
98+
**To finish v5 for real:** provision the R/HADES runtime (WeightIt/PSweight,
99+
survival, IPCW, 2SRI), materialise the 17 morbidity concept sets, then extend
100+
`study:htn-v4 --action=run` to author the R payloads via `StudyDesignToolRunner`.
101+
This is a statistically-sensitive execution that warrants review before it runs
102+
against the 1M-patient clinical CDM.
103+
68104
## Deferred / follow-up
69105
- Generic source-scoped long-form table-reader endpoint (Layer 2 server side) —
70106
not needed for the fixture (summary_data carries the full arrays); the

0 commit comments

Comments
 (0)