research-tool-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- tests/test_variable_classifier.py +124 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from core.database import DATA_ROOT, get_connection, init_db
|
|
12
|
+
from core.planning.diagnostics import check_violation
|
|
13
|
+
from core.reporting import latest_locked_plan as _latest_locked_plan, svg_escape as _svg_escape
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
COVARIATE_LABEL_MAP = {
|
|
17
|
+
"treatment_arm": "Treatment Group",
|
|
18
|
+
"high_risk_fish": "High-Risk Cytogenetics",
|
|
19
|
+
"prior_lines": "Prior Lines of Therapy",
|
|
20
|
+
"age": "Age, years",
|
|
21
|
+
"iss_stage": "ISS Stage",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
COVARIATE_UNIT_MAP = {
|
|
25
|
+
"prior_lines": "Per additional line",
|
|
26
|
+
"age": "Per year increase",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CovariateRow:
|
|
32
|
+
covariate: str
|
|
33
|
+
hr: float
|
|
34
|
+
ci_lower: float
|
|
35
|
+
ci_upper: float
|
|
36
|
+
wald_p: float
|
|
37
|
+
coef: float | None
|
|
38
|
+
se: float | None
|
|
39
|
+
z: float | None
|
|
40
|
+
reference_level: str | None = None
|
|
41
|
+
tested_level: str | None = None
|
|
42
|
+
unstable: bool = False
|
|
43
|
+
violated: bool = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ci_crosses_one(self) -> bool:
|
|
47
|
+
return self.ci_lower <= 1 <= self.ci_upper
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def display_label(self) -> str:
|
|
51
|
+
base_name = COVARIATE_LABEL_MAP.get(self.covariate, self.covariate.replace("_", " ").title())
|
|
52
|
+
if self.reference_level is not None and self.tested_level is not None:
|
|
53
|
+
ref_str = str(self.reference_level).capitalize() if str(self.reference_level).lower() in ("yes", "no") else str(self.reference_level)
|
|
54
|
+
test_str = str(self.tested_level).capitalize() if str(self.tested_level).lower() in ("yes", "no") else str(self.tested_level)
|
|
55
|
+
return f"{base_name} ({test_str} vs {ref_str})"
|
|
56
|
+
if " (interaction)" in self.covariate:
|
|
57
|
+
return self.covariate
|
|
58
|
+
unit_str = COVARIATE_UNIT_MAP.get(self.covariate, "per 1-unit increase")
|
|
59
|
+
return f"{base_name} ({unit_str})"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ForestPlotData:
|
|
64
|
+
result_id: int
|
|
65
|
+
study_plan_version: int
|
|
66
|
+
concordance_index: float | None
|
|
67
|
+
lr_test_p: float | None
|
|
68
|
+
n_analyzed: int
|
|
69
|
+
n_events: int
|
|
70
|
+
epv: float | None
|
|
71
|
+
epv_warning: bool
|
|
72
|
+
covariates: list[CovariateRow]
|
|
73
|
+
violation_warning: bool = False
|
|
74
|
+
violation_summary: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_epv(warnings_text: str | None) -> tuple[float | None, bool]:
|
|
78
|
+
if not warnings_text:
|
|
79
|
+
return None, False
|
|
80
|
+
m = re.search(r"EPV=([\d.]+)", warnings_text)
|
|
81
|
+
if m:
|
|
82
|
+
epv = float(m.group(1))
|
|
83
|
+
return epv, epv < 10
|
|
84
|
+
return None, False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_event_count(study_id: str, event_col: str, conn) -> int:
|
|
88
|
+
for table in (f"raw_{study_id}", f"raw_masked_{study_id}"):
|
|
89
|
+
try:
|
|
90
|
+
row = conn.execute(
|
|
91
|
+
f'SELECT COUNT("{event_col}") AS n_observed, '
|
|
92
|
+
f'SUM(CASE WHEN CAST("{event_col}" AS INTEGER) = 1 THEN 1 ELSE 0 END) '
|
|
93
|
+
f'AS n_events FROM {table}'
|
|
94
|
+
).fetchone()
|
|
95
|
+
except Exception:
|
|
96
|
+
continue
|
|
97
|
+
if row and int(row["n_observed"] or 0) > 0:
|
|
98
|
+
return int(row["n_events"] or 0)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_forest_data(study_id: str) -> ForestPlotData:
|
|
103
|
+
conn = get_connection(study_id)
|
|
104
|
+
init_db(conn)
|
|
105
|
+
|
|
106
|
+
plan = _latest_locked_plan(study_id)
|
|
107
|
+
|
|
108
|
+
warnings_text = json.dumps(plan.get("warnings", {}))
|
|
109
|
+
epv, epv_warn = _extract_epv(warnings_text)
|
|
110
|
+
|
|
111
|
+
result = conn.execute(
|
|
112
|
+
"""SELECT id, study_plan_version, concordance_index, lr_test_p,
|
|
113
|
+
sample_counts_json, status_json, ph_diagnostics_json
|
|
114
|
+
FROM analysis_results
|
|
115
|
+
WHERE study_id=? AND test_name='cox_ph_model'
|
|
116
|
+
AND id NOT IN (
|
|
117
|
+
SELECT COALESCE(superseded_previous_result_id, -1)
|
|
118
|
+
FROM analysis_results
|
|
119
|
+
WHERE study_id=? AND test_name='cox_ph_model'
|
|
120
|
+
AND superseded_previous_result_id IS NOT NULL
|
|
121
|
+
)
|
|
122
|
+
ORDER BY id DESC
|
|
123
|
+
LIMIT 1""",
|
|
124
|
+
(study_id, study_id),
|
|
125
|
+
).fetchone()
|
|
126
|
+
if not result:
|
|
127
|
+
conn.close()
|
|
128
|
+
raise ValueError(f"No non-superseded cox_ph_model result found for {study_id}")
|
|
129
|
+
|
|
130
|
+
rd = dict(result)
|
|
131
|
+
result_id = rd["id"]
|
|
132
|
+
|
|
133
|
+
sc = json.loads(rd["sample_counts_json"]) if rd.get("sample_counts_json") else {}
|
|
134
|
+
n_analyzed = sc.get("n_analyzed", 0)
|
|
135
|
+
|
|
136
|
+
# Get event column from study plan's cox model config
|
|
137
|
+
event_col = None
|
|
138
|
+
cox_models = plan.get("cox_ph_models", [])
|
|
139
|
+
if cox_models:
|
|
140
|
+
event_col = cox_models[0].get("event_col")
|
|
141
|
+
n_events = _get_event_count(study_id, event_col, conn) if event_col else 0
|
|
142
|
+
|
|
143
|
+
cov_rows = conn.execute(
|
|
144
|
+
"""SELECT covariate, hr, ci_lower, ci_upper, wald_p, coef, se, z,
|
|
145
|
+
reference_level, tested_level
|
|
146
|
+
FROM analysis_covariate_results
|
|
147
|
+
WHERE result_id=?
|
|
148
|
+
ORDER BY id""",
|
|
149
|
+
(result_id,),
|
|
150
|
+
).fetchall()
|
|
151
|
+
|
|
152
|
+
covariates: list[CovariateRow] = []
|
|
153
|
+
for r in cov_rows:
|
|
154
|
+
cd = dict(r)
|
|
155
|
+
unstable = (
|
|
156
|
+
cd.get("hr") is None
|
|
157
|
+
or cd.get("ci_lower") is None
|
|
158
|
+
or cd.get("ci_upper") is None
|
|
159
|
+
or (cd.get("hr") is not None and (math.isinf(cd["hr"]) or math.isnan(cd["hr"])))
|
|
160
|
+
or (cd.get("ci_lower") is not None and (math.isinf(cd["ci_lower"]) or math.isnan(cd["ci_lower"])))
|
|
161
|
+
or (cd.get("ci_upper") is not None and (math.isinf(cd["ci_upper"]) or math.isnan(cd["ci_upper"])))
|
|
162
|
+
)
|
|
163
|
+
covariates.append(CovariateRow(
|
|
164
|
+
covariate=cd["covariate"],
|
|
165
|
+
hr=cd["hr"] if cd.get("hr") is not None else 0,
|
|
166
|
+
ci_lower=cd["ci_lower"] if cd.get("ci_lower") is not None else 0,
|
|
167
|
+
ci_upper=cd["ci_upper"] if cd.get("ci_upper") is not None else 0,
|
|
168
|
+
wald_p=cd["wald_p"] if cd.get("wald_p") is not None else 1.0,
|
|
169
|
+
coef=cd.get("coef"),
|
|
170
|
+
se=cd.get("se"),
|
|
171
|
+
z=cd.get("z"),
|
|
172
|
+
reference_level=cd.get("reference_level"),
|
|
173
|
+
tested_level=cd.get("tested_level"),
|
|
174
|
+
unstable=unstable,
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
conn.close()
|
|
178
|
+
|
|
179
|
+
return ForestPlotData(
|
|
180
|
+
result_id=result_id,
|
|
181
|
+
study_plan_version=rd["study_plan_version"],
|
|
182
|
+
concordance_index=rd.get("concordance_index"),
|
|
183
|
+
lr_test_p=rd.get("lr_test_p"),
|
|
184
|
+
n_analyzed=n_analyzed,
|
|
185
|
+
n_events=n_events,
|
|
186
|
+
epv=epv,
|
|
187
|
+
epv_warning=epv_warn,
|
|
188
|
+
covariates=covariates,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _populate_violation(data: ForestPlotData, result_row: dict) -> ForestPlotData:
|
|
193
|
+
"""Add post-unmask violation info from result row to ForestPlotData.
|
|
194
|
+
Also marks individual CovariateRow.violated for matched Schoenfeld violations."""
|
|
195
|
+
has_viol, summary, details = check_violation(result_row)
|
|
196
|
+
data.violation_warning = has_viol
|
|
197
|
+
data.violation_summary = summary
|
|
198
|
+
if has_viol:
|
|
199
|
+
ph_raw = result_row.get("ph_diagnostics_json")
|
|
200
|
+
if ph_raw:
|
|
201
|
+
import json
|
|
202
|
+
ph = json.loads(ph_raw) if isinstance(ph_raw, str) else ph_raw
|
|
203
|
+
violated_bases: list[str] = []
|
|
204
|
+
for cov in ph.get("covariates", []):
|
|
205
|
+
p = cov.get("p_value", 1)
|
|
206
|
+
if p < 0.05:
|
|
207
|
+
name = cov.get("covariate", "")
|
|
208
|
+
# Strip lifelines' [T.level] suffix for matching
|
|
209
|
+
base = name.split("[")[0].strip()
|
|
210
|
+
violated_bases.append(base)
|
|
211
|
+
for cv in data.covariates:
|
|
212
|
+
if cv.covariate in violated_bases:
|
|
213
|
+
cv.violated = True
|
|
214
|
+
return data
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── SVG renderer ─────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def _log_scale_x(hr: float, x_min: float, x_max: float, width: float) -> float:
|
|
221
|
+
if hr <= 0 or x_min <= 0 or x_max <= 0:
|
|
222
|
+
return width / 2
|
|
223
|
+
return (math.log(hr) - math.log(x_min)) / (math.log(x_max) - math.log(x_min)) * width
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
SVG_TPL = """<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"
|
|
227
|
+
font-family="Arial, Helvetica, sans-serif" font-size="13">
|
|
228
|
+
{body}
|
|
229
|
+
</svg>"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render_svg(data: ForestPlotData, output_path: str) -> None:
|
|
233
|
+
margin_l = 20
|
|
234
|
+
margin_r = 30
|
|
235
|
+
margin_t = 40
|
|
236
|
+
margin_b = 80
|
|
237
|
+
label_w = 280
|
|
238
|
+
plot_l = margin_l + label_w + 20 # 320
|
|
239
|
+
plot_w = 400
|
|
240
|
+
row_h = 36
|
|
241
|
+
txt_x = plot_l + plot_w + 20 # 740
|
|
242
|
+
p_col_offset = 190 # 930 for p-value column
|
|
243
|
+
text_col_w = 270
|
|
244
|
+
|
|
245
|
+
svg_w = txt_x + text_col_w + margin_r # 1040
|
|
246
|
+
n = len(data.covariates)
|
|
247
|
+
plot_h = max(n * row_h, 60)
|
|
248
|
+
svg_h = margin_t + plot_h + margin_b
|
|
249
|
+
|
|
250
|
+
# Determine log range: include all CIs and HR=1, with padding for x-axis ticks
|
|
251
|
+
all_vals = [1.0]
|
|
252
|
+
for c in data.covariates:
|
|
253
|
+
if not c.unstable:
|
|
254
|
+
all_vals.extend([c.hr, c.ci_lower, c.ci_upper])
|
|
255
|
+
min_v = min(all_vals)
|
|
256
|
+
max_v = max(all_vals)
|
|
257
|
+
|
|
258
|
+
lo = min(0.04 if min_v < 0.1 else 0.08, min_v / 1.5)
|
|
259
|
+
hi = max(15.0 if max_v > 8.0 else 12.0, max_v * 1.5)
|
|
260
|
+
if lo <= 0:
|
|
261
|
+
lo = 0.04
|
|
262
|
+
|
|
263
|
+
svg_header = f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" ' \
|
|
264
|
+
f'font-family="Arial, Helvetica, sans-serif" font-size="13">'
|
|
265
|
+
|
|
266
|
+
parts: list[str] = []
|
|
267
|
+
|
|
268
|
+
parts.append(f' <text x="{margin_l}" y="24" font-size="16" font-weight="bold" '
|
|
269
|
+
f'fill="#1F4E78">Forest Plot — Cox PH Model</text>')
|
|
270
|
+
|
|
271
|
+
# Header row — side-by-side column headers aligned with data columns
|
|
272
|
+
header_y = margin_t
|
|
273
|
+
parts.append(f' <text x="{margin_l}" y="{header_y}" font-size="11" '
|
|
274
|
+
f'font-weight="bold" fill="#555">Covariate</text>')
|
|
275
|
+
parts.append(f' <text x="{txt_x}" y="{header_y}" font-size="11" '
|
|
276
|
+
f'font-weight="bold" fill="#555">aHR [95% CI]</text>')
|
|
277
|
+
parts.append(f' <text x="{txt_x + p_col_offset}" y="{header_y}" font-size="11" '
|
|
278
|
+
f'font-weight="bold" fill="#555">p-value</text>')
|
|
279
|
+
|
|
280
|
+
# Horizontal reference line at HR=1
|
|
281
|
+
ref_x = _log_scale_x(1.0, lo, hi, plot_w) + plot_l
|
|
282
|
+
parts.append(
|
|
283
|
+
f' <line x1="{ref_x}" y1="{margin_t}" x2="{ref_x}" y2="{margin_t + plot_h}" '
|
|
284
|
+
f'stroke="#999" stroke-width="1.5" stroke-dasharray="6,3" />'
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# X-axis
|
|
288
|
+
ax_y = margin_t + plot_h + 5
|
|
289
|
+
parts.append(f' <line x1="{plot_l}" y1="{ax_y}" x2="{plot_l + plot_w}" y2="{ax_y}" '
|
|
290
|
+
f'stroke="#333" stroke-width="1" />')
|
|
291
|
+
# Tick labels for x-axis (HR values) — include 0.05 and 15.0
|
|
292
|
+
tick_vals = [0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0]
|
|
293
|
+
tick_vals = [v for v in tick_vals if lo <= v <= hi]
|
|
294
|
+
for tv in tick_vals:
|
|
295
|
+
tx = _log_scale_x(tv, lo, hi, plot_w) + plot_l
|
|
296
|
+
parts.append(f' <line x1="{tx}" y1="{ax_y}" x2="{tx}" y2="{ax_y + 4}" '
|
|
297
|
+
f'stroke="#333" stroke-width="1" />')
|
|
298
|
+
parts.append(f' <text x="{tx}" y="{ax_y + 16}" font-size="10" fill="#666" '
|
|
299
|
+
f'text-anchor="middle">{tv}</text>')
|
|
300
|
+
|
|
301
|
+
# Render each covariate row
|
|
302
|
+
for i, cov in enumerate(data.covariates):
|
|
303
|
+
y = margin_t + i * row_h + row_h // 2 + 10
|
|
304
|
+
|
|
305
|
+
use_grey = cov.violated
|
|
306
|
+
label_color = "#888" if use_grey else "#333"
|
|
307
|
+
|
|
308
|
+
# Covariate label (left column)
|
|
309
|
+
parts.append(f' <text x="{margin_l}" y="{y + 4}" font-size="12" fill="{label_color}">'
|
|
310
|
+
f'{_svg_escape(cov.display_label)}</text>')
|
|
311
|
+
|
|
312
|
+
if cov.unstable:
|
|
313
|
+
parts.append(f' <text x="{plot_l + 10}" y="{y + 4}" font-size="11" '
|
|
314
|
+
f'fill="#E74C3C" font-style="italic">'
|
|
315
|
+
f'Did not converge / unstable estimate</text>')
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
color = "#888" if cov.ci_crosses_one else "#1F4E78"
|
|
319
|
+
|
|
320
|
+
# CI line
|
|
321
|
+
lx = _log_scale_x(cov.ci_lower, lo, hi, plot_w) + plot_l
|
|
322
|
+
ux = _log_scale_x(cov.ci_upper, lo, hi, plot_w) + plot_l
|
|
323
|
+
parts.append(f' <line x1="{lx}" y1="{y}" x2="{ux}" y2="{y}" '
|
|
324
|
+
f'stroke="{color}" stroke-width="2.5" stroke-linecap="round" />')
|
|
325
|
+
|
|
326
|
+
# CI whiskers
|
|
327
|
+
whisk_h = 5
|
|
328
|
+
parts.append(f' <line x1="{lx}" y1="{y - whisk_h}" x2="{lx}" y2="{y + whisk_h}" '
|
|
329
|
+
f'stroke="{color}" stroke-width="1.5" />')
|
|
330
|
+
parts.append(f' <line x1="{ux}" y1="{y - whisk_h}" x2="{ux}" y2="{y + whisk_h}" '
|
|
331
|
+
f'stroke="{color}" stroke-width="1.5" />')
|
|
332
|
+
|
|
333
|
+
# Point estimate marker
|
|
334
|
+
hx = _log_scale_x(cov.hr, lo, hi, plot_w) + plot_l
|
|
335
|
+
if use_grey:
|
|
336
|
+
# Hollow diamond for violated covariates
|
|
337
|
+
d = 6 # half-size
|
|
338
|
+
parts.append(f' <polygon points="{hx},{y - d} {hx + d},{y} {hx},{y + d} {hx - d},{y}" '
|
|
339
|
+
f'fill="white" stroke="{color}" stroke-width="2" />')
|
|
340
|
+
else:
|
|
341
|
+
marker_size = 5
|
|
342
|
+
parts.append(f' <rect x="{hx - marker_size}" y="{y - marker_size}" '
|
|
343
|
+
f'width="{marker_size * 2}" height="{marker_size * 2}" '
|
|
344
|
+
f'fill="{color}" />')
|
|
345
|
+
|
|
346
|
+
# Text: HR [CI], p-value — 3dp to avoid hiding CI crossing 1.0 (§9 fix)
|
|
347
|
+
hr_str = f"{cov.hr:.3f}"
|
|
348
|
+
ci_str = f"[{cov.ci_lower:.3f}, {cov.ci_upper:.3f}]"
|
|
349
|
+
p_str = f"p = {cov.wald_p:.3f}"
|
|
350
|
+
txt_color = "#888" if use_grey else "#333"
|
|
351
|
+
parts.append(f' <text x="{txt_x}" y="{y + 4}" font-size="11" fill="{txt_color}">'
|
|
352
|
+
f'{hr_str} {ci_str}</text>')
|
|
353
|
+
parts.append(f' <text x="{txt_x + p_col_offset}" y="{y + 4}" font-size="11" '
|
|
354
|
+
f'fill="{txt_color}">{p_str}</text>')
|
|
355
|
+
|
|
356
|
+
# Footer: model summary
|
|
357
|
+
footer_y = margin_t + plot_h + 40
|
|
358
|
+
footer_lines = 0
|
|
359
|
+
|
|
360
|
+
summary_parts = []
|
|
361
|
+
if data.concordance_index is not None:
|
|
362
|
+
summary_parts.append(f"C-index={data.concordance_index:.3f}")
|
|
363
|
+
if data.lr_test_p is not None:
|
|
364
|
+
summary_parts.append(f"LR test p={data.lr_test_p:.4f}")
|
|
365
|
+
summary_parts.append(f"N={data.n_analyzed}")
|
|
366
|
+
summary_parts.append(f"Events={data.n_events}")
|
|
367
|
+
summary_str = " | ".join(summary_parts)
|
|
368
|
+
parts.append(f' <text x="{margin_l}" y="{footer_y}" font-size="11" fill="#555">'
|
|
369
|
+
f'{_svg_escape(summary_str)}</text>')
|
|
370
|
+
footer_lines += 1
|
|
371
|
+
|
|
372
|
+
# aHR footnote
|
|
373
|
+
parts.append(f' <text x="{margin_l}" y="{footer_y + 18}" font-size="10" fill="#555" '
|
|
374
|
+
f'font-style="italic">aHR = adjusted Hazard Ratio (multivariable Cox model)</text>')
|
|
375
|
+
footer_lines += 1
|
|
376
|
+
|
|
377
|
+
# EPV caveat
|
|
378
|
+
epv_y = footer_y + 30
|
|
379
|
+
if data.epv_warning and data.epv is not None:
|
|
380
|
+
parts.append(f' <text x="{margin_l}" y="{epv_y}" font-size="11" '
|
|
381
|
+
f'fill="#C0392B" font-weight="bold">'
|
|
382
|
+
f'⚠ Caution: EPV={data.epv:.1f}, below recommended threshold '
|
|
383
|
+
f'— estimates may be unstable.</text>')
|
|
384
|
+
epv_y += 16
|
|
385
|
+
footer_lines += 1
|
|
386
|
+
|
|
387
|
+
# Post-unmask violation annotation + legend
|
|
388
|
+
if data.violation_warning and data.violation_summary:
|
|
389
|
+
parts.append(f' <text x="{margin_l}" y="{epv_y}" font-size="11" '
|
|
390
|
+
f'fill="#C0392B" font-weight="bold">'
|
|
391
|
+
f'⚠ Assumption violation: {_svg_escape(data.violation_summary)}</text>')
|
|
392
|
+
epv_y += 16
|
|
393
|
+
footer_lines += 1
|
|
394
|
+
has_marked = any(c.violated for c in data.covariates)
|
|
395
|
+
if has_marked:
|
|
396
|
+
parts.append(f' <text x="{margin_l}" y="{epv_y}" font-size="10" '
|
|
397
|
+
f'fill="#888">'
|
|
398
|
+
f'◇ = assumption violation detected for this covariate</text>')
|
|
399
|
+
epv_y += 16
|
|
400
|
+
footer_lines += 1
|
|
401
|
+
|
|
402
|
+
# Resize canvas if footer overflowed the original margin_b (§9 fix)
|
|
403
|
+
# Use epv_y (last text y-position) + descender + padding instead of line count
|
|
404
|
+
needed_footer_h = epv_y - (margin_t + plot_h) + 20
|
|
405
|
+
if needed_footer_h > margin_b:
|
|
406
|
+
svg_h = margin_t + plot_h + needed_footer_h
|
|
407
|
+
svg_header = f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" ' \
|
|
408
|
+
f'font-family="Arial, Helvetica, sans-serif" font-size="13">'
|
|
409
|
+
|
|
410
|
+
parts.insert(0, svg_header)
|
|
411
|
+
parts.append("</svg>")
|
|
412
|
+
Path(output_path).write_text("\n".join(parts))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ── ASCII renderer ───────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
def render_ascii(data: ForestPlotData) -> str:
|
|
418
|
+
lines: list[str] = []
|
|
419
|
+
lines.append("Forest Plot — Cox PH Model")
|
|
420
|
+
lines.append("")
|
|
421
|
+
|
|
422
|
+
# Determine max covariate name width
|
|
423
|
+
max_name_len = max(len(c.display_label) for c in data.covariates) if data.covariates else 10
|
|
424
|
+
name_w = max(max_name_len + 2, 14)
|
|
425
|
+
|
|
426
|
+
# Build scale
|
|
427
|
+
all_vals = [1.0]
|
|
428
|
+
for c in data.covariates:
|
|
429
|
+
if not c.unstable and c.hr > 0 and c.ci_lower > 0 and c.ci_upper > 0:
|
|
430
|
+
all_vals.extend([c.hr, c.ci_lower, c.ci_upper])
|
|
431
|
+
lo = min(all_vals) / 1.3
|
|
432
|
+
hi = max(all_vals) * 1.3
|
|
433
|
+
if lo <= 0:
|
|
434
|
+
lo = 0.1
|
|
435
|
+
|
|
436
|
+
plot_w = 30
|
|
437
|
+
hr_w = 7
|
|
438
|
+
ci_w = 6
|
|
439
|
+
|
|
440
|
+
def _pos(v: float) -> int:
|
|
441
|
+
if v <= 0:
|
|
442
|
+
return plot_w // 2
|
|
443
|
+
return int((math.log(v) - math.log(lo)) / (math.log(hi) - math.log(lo)) * plot_w)
|
|
444
|
+
|
|
445
|
+
ref = _pos(1.0)
|
|
446
|
+
|
|
447
|
+
# Header row above covariate table — exact same format as data rows
|
|
448
|
+
header_name = "Covariate".ljust(name_w)
|
|
449
|
+
header_plot = " " * plot_w
|
|
450
|
+
header_hr = "aHR".rjust(hr_w)
|
|
451
|
+
header_ci = "[95% CI]".ljust(1 + ci_w + 2 + ci_w + 1)
|
|
452
|
+
header_p = "p-value".rjust(7)
|
|
453
|
+
lines.append(f"{header_name}│{header_plot} {header_hr} {header_ci} {header_p}")
|
|
454
|
+
|
|
455
|
+
# Header
|
|
456
|
+
scale_line = " " * name_w + "|"
|
|
457
|
+
tick_labels = [0.1, 0.2, 0.5, 1, 2, 5, 10]
|
|
458
|
+
tick_labels = [t for t in tick_labels if lo <= t <= hi]
|
|
459
|
+
for tv in tick_labels:
|
|
460
|
+
p = _pos(tv)
|
|
461
|
+
if p < 0:
|
|
462
|
+
continue
|
|
463
|
+
scale_line += f"{'─' * max(0, p - len(scale_line) + name_w + 1)}┬"
|
|
464
|
+
lines.append(scale_line)
|
|
465
|
+
|
|
466
|
+
tick_text = " " * name_w + "│"
|
|
467
|
+
for tv in tick_labels:
|
|
468
|
+
p = _pos(tv)
|
|
469
|
+
s = f"{tv}"
|
|
470
|
+
pad = p - len(tick_text) + name_w + 1
|
|
471
|
+
if pad > 0:
|
|
472
|
+
tick_text += " " * pad + s
|
|
473
|
+
lines.append(tick_text)
|
|
474
|
+
|
|
475
|
+
# Separator covers name│ [plot] HR [CI, CI] p-value
|
|
476
|
+
_ci_field = 1 + ci_w + 2 + ci_w + 1 # [lower, upper]
|
|
477
|
+
_data_w = 1 + plot_w + 2 + hr_w + 1 + _ci_field + 1 + 8 # │plot HR CI pv
|
|
478
|
+
lines.append("─" * (name_w + _data_w))
|
|
479
|
+
|
|
480
|
+
for cov in data.covariates:
|
|
481
|
+
name = cov.display_label.ljust(name_w)
|
|
482
|
+
|
|
483
|
+
if cov.unstable:
|
|
484
|
+
lines.append(f"{name}│ ** Did not converge / unstable estimate **")
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
p = _pos(cov.hr)
|
|
488
|
+
lp = _pos(cov.ci_lower)
|
|
489
|
+
up = _pos(cov.ci_upper)
|
|
490
|
+
|
|
491
|
+
marker = "◇" if cov.ci_crosses_one else "◆"
|
|
492
|
+
color_tag = " (n.s.)" if cov.ci_crosses_one else ""
|
|
493
|
+
|
|
494
|
+
# Build plot line
|
|
495
|
+
plot_buf = [" "] * plot_w
|
|
496
|
+
for j in range(max(0, lp), min(plot_w, up + 1)):
|
|
497
|
+
plot_buf[j] = "─"
|
|
498
|
+
if 0 <= p < plot_w:
|
|
499
|
+
plot_buf[p] = marker
|
|
500
|
+
if 0 <= ref < plot_w:
|
|
501
|
+
plot_buf[ref] = "│"
|
|
502
|
+
|
|
503
|
+
plot_str = "".join(plot_buf)
|
|
504
|
+
|
|
505
|
+
hr_w = 7
|
|
506
|
+
# ci_w is already defined at function scope (line 361)
|
|
507
|
+
hr_str = f"{cov.hr:>{hr_w}.2f}"
|
|
508
|
+
ci_str = f"[{cov.ci_lower:>{ci_w}.2f}, {cov.ci_upper:>{ci_w}.2f}]"
|
|
509
|
+
p_str = f"p={cov.wald_p:.3f}"
|
|
510
|
+
lines.append(f"{name}│{plot_str} {hr_str} {ci_str} {p_str}{color_tag}")
|
|
511
|
+
|
|
512
|
+
lines.append("")
|
|
513
|
+
|
|
514
|
+
# Summary
|
|
515
|
+
summary_parts = []
|
|
516
|
+
if data.concordance_index is not None:
|
|
517
|
+
summary_parts.append(f"C-index={data.concordance_index:.3f}")
|
|
518
|
+
if data.lr_test_p is not None:
|
|
519
|
+
summary_parts.append(f"LR test p={data.lr_test_p:.4f}")
|
|
520
|
+
summary_parts.append(f"N={data.n_analyzed}")
|
|
521
|
+
summary_parts.append(f"Events={data.n_events}")
|
|
522
|
+
lines.append(" | ".join(summary_parts))
|
|
523
|
+
lines.append("aHR = adjusted Hazard Ratio (multivariable Cox model)")
|
|
524
|
+
|
|
525
|
+
if data.epv_warning and data.epv is not None:
|
|
526
|
+
lines.append(
|
|
527
|
+
f"⚠ Caution: EPV={data.epv:.1f}, below recommended threshold "
|
|
528
|
+
f"— estimates may be unstable."
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
return "\n".join(lines)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ── Entry point ──────────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
def render_forest(study_id: str, output_path: str | None = None, ascii: bool = False) -> Path | str:
|
|
537
|
+
data = load_forest_data(study_id)
|
|
538
|
+
if ascii:
|
|
539
|
+
return render_ascii(data)
|
|
540
|
+
path = output_path or str(DATA_ROOT / study_id / "forest_plot.svg")
|
|
541
|
+
# Re-read the result row to pass status_json + ph_diagnostics_json to violation check
|
|
542
|
+
conn = get_connection(study_id)
|
|
543
|
+
init_db(conn)
|
|
544
|
+
row = conn.execute(
|
|
545
|
+
"""SELECT status_json, ph_diagnostics_json
|
|
546
|
+
FROM analysis_results
|
|
547
|
+
WHERE study_id=? AND test_name='cox_ph_model'
|
|
548
|
+
AND id NOT IN (
|
|
549
|
+
SELECT COALESCE(superseded_previous_result_id, -1)
|
|
550
|
+
FROM analysis_results
|
|
551
|
+
WHERE study_id=? AND test_name='cox_ph_model'
|
|
552
|
+
AND superseded_previous_result_id IS NOT NULL
|
|
553
|
+
)
|
|
554
|
+
ORDER BY id DESC LIMIT 1""",
|
|
555
|
+
(study_id, study_id),
|
|
556
|
+
).fetchone()
|
|
557
|
+
conn.close()
|
|
558
|
+
if row:
|
|
559
|
+
data = _populate_violation(data, dict(row))
|
|
560
|
+
render_svg(data, path)
|
|
561
|
+
return Path(path)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def generate_forest_plot_png(study_id: str, output_path: str | Path | None = None) -> Path:
|
|
565
|
+
"""Render a publication-ready PNG forest plot using matplotlib."""
|
|
566
|
+
import matplotlib
|
|
567
|
+
matplotlib.use("Agg")
|
|
568
|
+
import matplotlib.pyplot as plt
|
|
569
|
+
import numpy as np
|
|
570
|
+
|
|
571
|
+
data = load_forest_data(study_id)
|
|
572
|
+
if output_path is None:
|
|
573
|
+
output_path = DATA_ROOT / study_id / "forest_plot.png"
|
|
574
|
+
else:
|
|
575
|
+
output_path = Path(output_path)
|
|
576
|
+
|
|
577
|
+
fig, ax = plt.subplots(figsize=(10, 4.5), dpi=150)
|
|
578
|
+
fig.subplots_adjust(left=0.32, right=0.62, top=0.85, bottom=0.2)
|
|
579
|
+
|
|
580
|
+
n = len(data.covariates)
|
|
581
|
+
y_positions = np.arange(n, 0, -1)
|
|
582
|
+
|
|
583
|
+
for i, cov in enumerate(data.covariates):
|
|
584
|
+
y = y_positions[i]
|
|
585
|
+
color = "#888888" if cov.ci_crosses_one else "#1F4E78"
|
|
586
|
+
|
|
587
|
+
err_low = max(0, cov.hr - cov.ci_lower)
|
|
588
|
+
err_high = max(0, cov.ci_upper - cov.hr)
|
|
589
|
+
ax.errorbar(
|
|
590
|
+
cov.hr, y, xerr=[[err_low], [err_high]],
|
|
591
|
+
fmt="s", color=color, ecolor=color, elinewidth=2, capsize=4, capthick=1.5, markersize=6,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
ax.text(-0.03, y, cov.display_label, transform=ax.get_yaxis_transform(), ha="right", va="center", fontsize=11)
|
|
595
|
+
|
|
596
|
+
hr_str = f"{cov.hr:.2f} [{cov.ci_lower:.2f}, {cov.ci_upper:.2f}]"
|
|
597
|
+
p_str = f"p = {cov.wald_p:.3f}" if cov.wald_p >= 0.001 else "p < 0.001"
|
|
598
|
+
ax.text(1.03, y, hr_str, transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11)
|
|
599
|
+
ax.text(1.55, y, p_str, transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11)
|
|
600
|
+
|
|
601
|
+
ax.set_xscale("log")
|
|
602
|
+
ax.axvline(1.0, color="#999999", linestyle="--", linewidth=1.5)
|
|
603
|
+
ax.set_yticks([])
|
|
604
|
+
ax.set_ylim(0.5, n + 0.8)
|
|
605
|
+
ax.set_xlabel("Hazard Ratio (log scale)", fontsize=11)
|
|
606
|
+
ax.set_title("Forest Plot — Cox Proportional Hazards Model", fontsize=13, fontweight="bold", color="#1F4E78", pad=15)
|
|
607
|
+
|
|
608
|
+
ax.text(-0.03, n + 0.5, "Covariate", transform=ax.get_yaxis_transform(), ha="right", va="center", fontsize=11, fontweight="bold", color="#555555")
|
|
609
|
+
ax.text(1.03, n + 0.5, "aHR [95% CI]", transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11, fontweight="bold", color="#555555")
|
|
610
|
+
ax.text(1.55, n + 0.5, "p-value", transform=ax.get_yaxis_transform(), ha="left", va="center", fontsize=11, fontweight="bold", color="#555555")
|
|
611
|
+
|
|
612
|
+
plt.savefig(str(output_path), bbox_inches="tight")
|
|
613
|
+
plt.close(fig)
|
|
614
|
+
return output_path
|