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,20 @@
|
|
|
1
|
+
"""Patient flow diagram (CONSORT/STROBE style) for study pipeline stages."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .flowchart import (
|
|
5
|
+
FlowStage,
|
|
6
|
+
FlowchartData,
|
|
7
|
+
load_flowchart_data,
|
|
8
|
+
render_ascii,
|
|
9
|
+
render_svg,
|
|
10
|
+
render_flowchart,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FlowStage",
|
|
15
|
+
"FlowchartData",
|
|
16
|
+
"load_flowchart_data",
|
|
17
|
+
"render_ascii",
|
|
18
|
+
"render_svg",
|
|
19
|
+
"render_flowchart",
|
|
20
|
+
]
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Patient flow diagram (CONSORT/STROBE style) for study pipeline stages."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from core.database import DATA_ROOT, get_connection
|
|
9
|
+
from core.reporting import (
|
|
10
|
+
format_label as _format_label,
|
|
11
|
+
latest_locked_plan as _latest_locked_plan,
|
|
12
|
+
svg_escape as _svg_escape,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FlowStage:
|
|
18
|
+
name: str
|
|
19
|
+
total: int
|
|
20
|
+
excluded: int
|
|
21
|
+
remaining: int
|
|
22
|
+
details: dict = field(default_factory=dict)
|
|
23
|
+
note: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FlowchartData:
|
|
28
|
+
study_id: str
|
|
29
|
+
stages: list[FlowStage]
|
|
30
|
+
study_name: str = ""
|
|
31
|
+
study_type: str = ""
|
|
32
|
+
arm_column: str | None = None
|
|
33
|
+
arm_counts: dict[str, int] = field(default_factory=dict)
|
|
34
|
+
arm_analyzed_counts: dict[str, int] = field(default_factory=dict)
|
|
35
|
+
primary_analysis_label: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_plan_arm_col(study_id: str) -> str | None:
|
|
39
|
+
plan = _latest_locked_plan(study_id)
|
|
40
|
+
cox_models = plan.get("cox_ph_models", [])
|
|
41
|
+
if cox_models:
|
|
42
|
+
return cox_models[0].get("primary_treatment_col")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_flowchart_data(study_id: str) -> FlowchartData:
|
|
47
|
+
conn = get_connection(study_id)
|
|
48
|
+
raw = f"raw_{study_id}"
|
|
49
|
+
|
|
50
|
+
study_name = ""
|
|
51
|
+
study_type = ""
|
|
52
|
+
row = conn.execute("SELECT name, study_type FROM studies WHERE id=?", (study_id,)).fetchone()
|
|
53
|
+
if row:
|
|
54
|
+
study_name = row[0] or ""
|
|
55
|
+
study_type = row[1] or ""
|
|
56
|
+
|
|
57
|
+
stages: list[FlowStage] = []
|
|
58
|
+
|
|
59
|
+
# Stage 1: Assessed for eligibility
|
|
60
|
+
total_ingested = conn.execute(f'SELECT COUNT(*) FROM {raw}').fetchone()[0]
|
|
61
|
+
stages.append(FlowStage(
|
|
62
|
+
name="Assessed for eligibility",
|
|
63
|
+
total=total_ingested,
|
|
64
|
+
excluded=0,
|
|
65
|
+
remaining=total_ingested,
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
# Stage 2: Excluded at ingest (duplicate patient IDs)
|
|
69
|
+
dupes = []
|
|
70
|
+
try:
|
|
71
|
+
from core.ingestion.csv_loader import find_duplicate_patient_ids
|
|
72
|
+
dupes = find_duplicate_patient_ids(study_id)
|
|
73
|
+
except Exception:
|
|
74
|
+
dupes = []
|
|
75
|
+
n_dupes = sum(cnt - 1 for _, cnt in dupes) if dupes else 0
|
|
76
|
+
remaining_after_ingest = total_ingested - n_dupes
|
|
77
|
+
stages.append(FlowStage(
|
|
78
|
+
name="Excluded at ingest (duplicate patient IDs)",
|
|
79
|
+
total=total_ingested,
|
|
80
|
+
excluded=n_dupes,
|
|
81
|
+
remaining=remaining_after_ingest,
|
|
82
|
+
details={"duplicates": dupes} if dupes else {},
|
|
83
|
+
note="Duplicates are retained in analysis; verify they are genuine repeat records" if dupes else None,
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
# Stage 3: Eligible cohort
|
|
87
|
+
stages.append(FlowStage(
|
|
88
|
+
name="Eligible cohort",
|
|
89
|
+
total=remaining_after_ingest,
|
|
90
|
+
excluded=0,
|
|
91
|
+
remaining=remaining_after_ingest,
|
|
92
|
+
note="No row exclusions at classification step; purely a labeling step",
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
# Stage 4: Allocated to each arm/group
|
|
96
|
+
arm_col = _get_plan_arm_col(study_id)
|
|
97
|
+
arm_counts = {}
|
|
98
|
+
if arm_col:
|
|
99
|
+
rows = conn.execute(f'SELECT "{arm_col}", COUNT(*) as cnt FROM {raw} GROUP BY "{arm_col}"').fetchall()
|
|
100
|
+
for r in rows:
|
|
101
|
+
val = r[0] if r[0] is not None else "missing"
|
|
102
|
+
arm_counts[str(val)] = r[1]
|
|
103
|
+
stages.append(FlowStage(
|
|
104
|
+
name="Allocated to each arm/group",
|
|
105
|
+
total=remaining_after_ingest,
|
|
106
|
+
excluded=0,
|
|
107
|
+
remaining=remaining_after_ingest,
|
|
108
|
+
details={"arm_counts": arm_counts} if arm_counts else {},
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
# Stage 5: Analyzed
|
|
112
|
+
# Determine primary analysis label from plan
|
|
113
|
+
primary_analysis_label = "Primary Endpoints"
|
|
114
|
+
plan = _latest_locked_plan(study_id)
|
|
115
|
+
if plan:
|
|
116
|
+
try:
|
|
117
|
+
cox_models = plan.get("cox_ph_models", [])
|
|
118
|
+
if cox_models:
|
|
119
|
+
mn = cox_models[0].get("model_name") or ""
|
|
120
|
+
if mn and mn.lower() not in ("pfs_multivariable", "cox_ph_model", "pfs_model"):
|
|
121
|
+
primary_analysis_label = f"Primary Endpoints ({_format_label(mn)})"
|
|
122
|
+
else:
|
|
123
|
+
primary_analysis_label = "Primary Endpoints"
|
|
124
|
+
else:
|
|
125
|
+
all_tests = plan.get("planned_tests", []) + plan.get("post_hoc_tests", [])
|
|
126
|
+
if all_tests:
|
|
127
|
+
tn = all_tests[0].get("test_name", "")
|
|
128
|
+
if tn:
|
|
129
|
+
primary_analysis_label = f"Primary Endpoints ({_format_label(tn)})"
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
analyzed_n = remaining_after_ingest
|
|
134
|
+
excluded_from_analysis = 0
|
|
135
|
+
latest_result = conn.execute("""
|
|
136
|
+
SELECT sample_counts_json
|
|
137
|
+
FROM analysis_results
|
|
138
|
+
WHERE study_id=? AND test_name='cox_ph_model'
|
|
139
|
+
AND id NOT IN (
|
|
140
|
+
SELECT COALESCE(superseded_previous_result_id, -1)
|
|
141
|
+
FROM analysis_results
|
|
142
|
+
WHERE study_id=? AND test_name='cox_ph_model'
|
|
143
|
+
AND superseded_previous_result_id IS NOT NULL
|
|
144
|
+
)
|
|
145
|
+
ORDER BY id DESC LIMIT 1
|
|
146
|
+
""", (study_id, study_id)).fetchone()
|
|
147
|
+
|
|
148
|
+
if latest_result and latest_result[0]:
|
|
149
|
+
sc = json.loads(latest_result[0])
|
|
150
|
+
analyzed_n = sc.get("n_analyzed", remaining_after_ingest)
|
|
151
|
+
excluded_from_analysis = sc.get("n_excluded", 0)
|
|
152
|
+
|
|
153
|
+
stages.append(FlowStage(
|
|
154
|
+
name="Analyzed (primary analysis: Cox PH model)",
|
|
155
|
+
total=remaining_after_ingest,
|
|
156
|
+
excluded=excluded_from_analysis,
|
|
157
|
+
remaining=analyzed_n,
|
|
158
|
+
details={"n_total": remaining_after_ingest, "n_analyzed": analyzed_n, "n_excluded": excluded_from_analysis},
|
|
159
|
+
))
|
|
160
|
+
|
|
161
|
+
# Per-arm analyzed counts: rows where all Cox model columns are non-null
|
|
162
|
+
arm_analyzed_counts: dict[str, int] = {}
|
|
163
|
+
if arm_col and analyzed_n > 0:
|
|
164
|
+
plan = _latest_locked_plan(study_id)
|
|
165
|
+
cox_cols = [arm_col]
|
|
166
|
+
try:
|
|
167
|
+
m = plan.get("cox_ph_models", [{}])[0]
|
|
168
|
+
if m.get("survival_time_col"):
|
|
169
|
+
cox_cols.append(m["survival_time_col"])
|
|
170
|
+
if m.get("event_col"):
|
|
171
|
+
cox_cols.append(m["event_col"])
|
|
172
|
+
for c in m.get("covariate_cols", []):
|
|
173
|
+
cox_cols.append(c)
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
nonnull_conditions = " AND ".join(f'"{c}" IS NOT NULL' for c in set(cox_cols))
|
|
177
|
+
rows = conn.execute(
|
|
178
|
+
f'SELECT "{arm_col}", COUNT(*) as cnt FROM {raw} WHERE {nonnull_conditions} GROUP BY "{arm_col}"'
|
|
179
|
+
).fetchall()
|
|
180
|
+
for r in rows:
|
|
181
|
+
val = r[0] if r[0] is not None else "missing"
|
|
182
|
+
arm_analyzed_counts[str(val)] = r[1]
|
|
183
|
+
|
|
184
|
+
# Stage 6: Final analyzed set per arm
|
|
185
|
+
final_arm_counts = {}
|
|
186
|
+
if arm_col and analyzed_n > 0:
|
|
187
|
+
rows = conn.execute(f'SELECT "{arm_col}", COUNT(*) as cnt FROM {raw} GROUP BY "{arm_col}"').fetchall()
|
|
188
|
+
for r in rows:
|
|
189
|
+
val = r[0] if r[0] is not None else "missing"
|
|
190
|
+
final_arm_counts[str(val)] = r[1]
|
|
191
|
+
|
|
192
|
+
stages.append(FlowStage(
|
|
193
|
+
name="Final analyzed set per arm",
|
|
194
|
+
total=analyzed_n,
|
|
195
|
+
excluded=0,
|
|
196
|
+
remaining=analyzed_n,
|
|
197
|
+
details={"arm_counts": final_arm_counts} if final_arm_counts else {},
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
return FlowchartData(
|
|
203
|
+
study_id=study_id,
|
|
204
|
+
stages=stages,
|
|
205
|
+
study_name=study_name,
|
|
206
|
+
study_type=study_type,
|
|
207
|
+
arm_column=arm_col,
|
|
208
|
+
arm_counts=arm_counts,
|
|
209
|
+
arm_analyzed_counts=arm_analyzed_counts,
|
|
210
|
+
primary_analysis_label=primary_analysis_label,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── SVG renderer ─────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _draw_box(parts: list, x: int, y: int, w: int, h: int,
|
|
219
|
+
stroke: str = "#1F4E78", fill: str = "#F8F9FA") -> None:
|
|
220
|
+
parts.append(f' <rect x="{x}" y="{y}" width="{w}" height="{h}" '
|
|
221
|
+
f'rx="6" ry="6" fill="{fill}" stroke="{stroke}" stroke-width="2" />')
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _draw_text(parts: list, x: int, y: int, text: str, font_size: int = 13,
|
|
225
|
+
fill: str = "#333", bold: bool = False,
|
|
226
|
+
italic: bool = False, anchor: str | None = None) -> None:
|
|
227
|
+
attrs = f'font-size="{font_size}" fill="{fill}"'
|
|
228
|
+
if bold:
|
|
229
|
+
attrs += ' font-weight="bold"'
|
|
230
|
+
if italic:
|
|
231
|
+
attrs += ' font-style="italic"'
|
|
232
|
+
if anchor:
|
|
233
|
+
attrs += f' text-anchor="{anchor}"'
|
|
234
|
+
parts.append(f' <text x="{x}" y="{y}" {attrs}>{_svg_escape(text)}</text>')
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _draw_arrow(parts: list, x1: int, y1: int, x2: int, y2: int) -> None:
|
|
238
|
+
parts.append(f' <line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
|
|
239
|
+
f'stroke="#1F4E78" stroke-width="2" marker-end="url(#arrowhead)" />')
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _draw_line(parts: list, x1: int, y1: int, x2: int, y2: int) -> None:
|
|
243
|
+
parts.append(f' <line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
|
|
244
|
+
f'stroke="#1F4E78" stroke-width="2" />')
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _draw_pill(parts: list, x: int, y: int, w: int, h: int, label: str) -> None:
|
|
248
|
+
"""Rounded pill-box for CONSORT phase labels."""
|
|
249
|
+
ry = h // 2
|
|
250
|
+
parts.append(f' <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{ry}" ry="{ry}" '
|
|
251
|
+
f'fill="#D6EAF8" stroke="#85C1E9" stroke-width="1" />')
|
|
252
|
+
cx = x + w // 2
|
|
253
|
+
cy = y + h // 2 + 4 # vertical center + font baseline offset
|
|
254
|
+
parts.append(f' <text x="{cx}" y="{cy}" font-size="9" fill="#1F4E78" font-weight="bold" '
|
|
255
|
+
f'text-anchor="middle">{label}</text>')
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render_svg(data: FlowchartData, output_path: str,
|
|
259
|
+
show_title: bool = True, show_watermark: bool = False,
|
|
260
|
+
verbose: bool = False,
|
|
261
|
+
show_study_name: bool = False) -> None:
|
|
262
|
+
box_h = 65
|
|
263
|
+
main_w = 340
|
|
264
|
+
arm_w = 240
|
|
265
|
+
excl_w = 185
|
|
266
|
+
excl_h = 42
|
|
267
|
+
vgap = 30
|
|
268
|
+
hgap = 25
|
|
269
|
+
col_gap = 35
|
|
270
|
+
pill_w = 125
|
|
271
|
+
pill_h = 22
|
|
272
|
+
pill_x = 8
|
|
273
|
+
header_h = 80
|
|
274
|
+
margin = 40
|
|
275
|
+
|
|
276
|
+
stages = data.stages
|
|
277
|
+
arms = list(data.arm_counts.keys())
|
|
278
|
+
n_arms = len(arms)
|
|
279
|
+
has_arms = n_arms > 0
|
|
280
|
+
|
|
281
|
+
needs_final_row = False
|
|
282
|
+
if has_arms:
|
|
283
|
+
for a in arms:
|
|
284
|
+
if data.arm_analyzed_counts.get(a, 0) != data.arm_counts.get(a, 0):
|
|
285
|
+
needs_final_row = True
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
# Compute arm column positions first — these are the centering reference
|
|
289
|
+
if has_arms:
|
|
290
|
+
total_arm_w = n_arms * arm_w + (n_arms - 1) * col_gap
|
|
291
|
+
else:
|
|
292
|
+
total_arm_w = main_w
|
|
293
|
+
|
|
294
|
+
# SVG width: arms centered, with room for pill labels + exclusion overflow
|
|
295
|
+
svg_w = max(total_arm_w + 2 * (pill_x + pill_w + 15), margin + main_w + hgap + excl_w + margin, 700)
|
|
296
|
+
|
|
297
|
+
# Center arms in svg_w
|
|
298
|
+
arm_start_x = (svg_w - total_arm_w) // 2
|
|
299
|
+
arm_xs = [arm_start_x + i * (arm_w + col_gap) for i in range(n_arms)]
|
|
300
|
+
arm_cx = [x + arm_w // 2 for x in arm_xs]
|
|
301
|
+
|
|
302
|
+
# Enrollment centered on arm midpoint, exclusion overflows right
|
|
303
|
+
if has_arms:
|
|
304
|
+
main_cx = (arm_cx[0] + arm_cx[-1]) // 2
|
|
305
|
+
else:
|
|
306
|
+
main_cx = svg_w // 2
|
|
307
|
+
main_x = main_cx - main_w // 2
|
|
308
|
+
excl_x = main_x + main_w + hgap
|
|
309
|
+
|
|
310
|
+
# Expand SVG if exclusion box overflows
|
|
311
|
+
need_w = excl_x + excl_w + margin
|
|
312
|
+
if need_w > svg_w:
|
|
313
|
+
svg_w = need_w
|
|
314
|
+
|
|
315
|
+
# Y positions
|
|
316
|
+
y0 = margin + header_h # Enrollment
|
|
317
|
+
y3 = y0 + box_h + vgap # Arm row
|
|
318
|
+
y4 = y3 + box_h + vgap # Analyzed
|
|
319
|
+
y5 = y4 + box_h + vgap # Final (if needed)
|
|
320
|
+
excl_center_y = y0 + box_h // 2
|
|
321
|
+
svg_h = (y5 + box_h + 40) if (has_arms and needs_final_row) else (y4 + box_h + 40)
|
|
322
|
+
|
|
323
|
+
# Phase label Y — center of each phase row
|
|
324
|
+
pill_enroll_y = y0 + box_h // 2 - pill_h // 2
|
|
325
|
+
pill_alloc_y = y3 + box_h // 2 - pill_h // 2
|
|
326
|
+
pill_analy_y = y4 + box_h // 2 - pill_h // 2
|
|
327
|
+
|
|
328
|
+
parts: list[str] = []
|
|
329
|
+
parts.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" '
|
|
330
|
+
f'font-family="Arial, Helvetica, sans-serif" font-size="13">')
|
|
331
|
+
|
|
332
|
+
# ── Title ──────────────────────────────────────────────────────────
|
|
333
|
+
# CONSORT for RCTs, STROBE for observational studies
|
|
334
|
+
if data.study_type == "rct":
|
|
335
|
+
flow_label = "CONSORT Participant Flow Diagram"
|
|
336
|
+
else:
|
|
337
|
+
flow_label = "STROBE Participant Flow Diagram"
|
|
338
|
+
if show_title:
|
|
339
|
+
_draw_text(parts, main_cx, margin + 22, flow_label,
|
|
340
|
+
18, "#1F4E78", bold=True, anchor="middle")
|
|
341
|
+
if show_study_name and data.study_name:
|
|
342
|
+
_draw_text(parts, main_cx, margin + 40, data.study_name,
|
|
343
|
+
12, "#777", italic=True, anchor="middle")
|
|
344
|
+
|
|
345
|
+
# Phase pills (left margin) — CONSORT for RCTs, STROBE for observational
|
|
346
|
+
if data.study_type == "rct":
|
|
347
|
+
_draw_pill(parts, pill_x, pill_enroll_y, pill_w, pill_h, "ENROLLMENT")
|
|
348
|
+
_draw_pill(parts, pill_x, pill_alloc_y, pill_w, pill_h, "ALLOCATION")
|
|
349
|
+
_draw_pill(parts, pill_x, pill_analy_y, pill_w, pill_h, "ANALYSIS")
|
|
350
|
+
else:
|
|
351
|
+
_draw_pill(parts, pill_x, pill_enroll_y, pill_w, pill_h, "ELIGIBILITY")
|
|
352
|
+
_draw_pill(parts, pill_x, pill_alloc_y, pill_w, pill_h, "COHORT ASSIGNMENT")
|
|
353
|
+
_draw_pill(parts, pill_x, pill_analy_y, pill_w, pill_h, "ANALYSIS")
|
|
354
|
+
|
|
355
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
356
|
+
# Stage 0: Enrollment (assessed + eligible merged — no exclusion here)
|
|
357
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
358
|
+
s0 = stages[0]
|
|
359
|
+
_draw_box(parts, main_x, y0, main_w, box_h)
|
|
360
|
+
_draw_text(parts, main_cx, y0 + 22, "Assessed for eligibility", 14, "#1F4E78", bold=True, anchor="middle")
|
|
361
|
+
_draw_text(parts, main_cx, y0 + 42, f"n = {s0.remaining}", 13, "#333", anchor="middle")
|
|
362
|
+
|
|
363
|
+
# Exclusion side box (stage 1) — branches off enrollment
|
|
364
|
+
s1 = stages[1]
|
|
365
|
+
_draw_box(parts, excl_x, y0, excl_w, excl_h, "#bbb", "#fafafa")
|
|
366
|
+
_draw_text(parts, excl_x + 8, y0 + 16, f"Excluded (n = {s1.excluded})", 11, "#1F4E78", bold=True)
|
|
367
|
+
_draw_text(parts, excl_x + 8, y0 + 33, f"• Duplicate patient ID (n = {s1.excluded})", 10, "#333")
|
|
368
|
+
|
|
369
|
+
_draw_arrow(parts, main_x + main_w, excl_center_y, excl_x, excl_center_y)
|
|
370
|
+
|
|
371
|
+
# Enrollment → arm split (straight vertical arrows)
|
|
372
|
+
if has_arms and n_arms > 1:
|
|
373
|
+
arm_split_y = y0 + box_h + vgap // 2
|
|
374
|
+
_draw_line(parts, main_cx, y0 + box_h, main_cx, arm_split_y)
|
|
375
|
+
_draw_line(parts, arm_cx[0], arm_split_y, arm_cx[-1], arm_split_y)
|
|
376
|
+
for cx in arm_cx:
|
|
377
|
+
_draw_arrow(parts, cx, arm_split_y, cx, y3)
|
|
378
|
+
else:
|
|
379
|
+
_draw_arrow(parts, main_cx, y0 + box_h, main_cx, y3)
|
|
380
|
+
|
|
381
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
382
|
+
# Arm columns
|
|
383
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
384
|
+
if has_arms:
|
|
385
|
+
for i, arm_name in enumerate(arms):
|
|
386
|
+
ax = arm_xs[i]
|
|
387
|
+
cx = arm_cx[i]
|
|
388
|
+
arm_cnt = data.arm_counts.get(arm_name, 0)
|
|
389
|
+
arm_analyzed = data.arm_analyzed_counts.get(arm_name, 0)
|
|
390
|
+
analy_label = data.primary_analysis_label
|
|
391
|
+
|
|
392
|
+
# Arm label: raw value from data, no synthetic "Arm " prefix
|
|
393
|
+
_draw_box(parts, ax, y3, arm_w, box_h)
|
|
394
|
+
cx_box = ax + arm_w // 2
|
|
395
|
+
_draw_text(parts, cx_box, y3 + 28, f"{arm_name} (n = {arm_cnt})", 14, "#1F4E78",
|
|
396
|
+
bold=True, anchor="middle")
|
|
397
|
+
|
|
398
|
+
# Arrow: arm label → analyzed
|
|
399
|
+
_draw_arrow(parts, cx, y3 + box_h, cx, y4)
|
|
400
|
+
|
|
401
|
+
when_analyzed_eq_allocated = (arm_analyzed == arm_cnt)
|
|
402
|
+
|
|
403
|
+
if when_analyzed_eq_allocated:
|
|
404
|
+
# Two-line: bold "Analyzed", subtext "PFS Multivariable (n = 10)"
|
|
405
|
+
_draw_box(parts, ax, y4, arm_w, box_h)
|
|
406
|
+
_draw_text(parts, cx_box, y4 + 22, "Analyzed", 14, "#1F4E78", bold=True, anchor="middle")
|
|
407
|
+
_draw_text(parts, cx_box, y4 + 42, f"{analy_label} (n = {arm_analyzed})",
|
|
408
|
+
12, "#555", anchor="middle")
|
|
409
|
+
else:
|
|
410
|
+
# Two distinct boxes: analyzed + final
|
|
411
|
+
_draw_box(parts, ax, y4, arm_w, box_h)
|
|
412
|
+
_draw_text(parts, cx_box, y4 + 22, "Analyzed", 14, "#1F4E78", bold=True, anchor="middle")
|
|
413
|
+
_draw_text(parts, cx_box, y4 + 42, f"{analy_label} (n = {arm_analyzed})",
|
|
414
|
+
12, "#555", anchor="middle")
|
|
415
|
+
|
|
416
|
+
_draw_arrow(parts, cx, y4 + box_h, cx, y5)
|
|
417
|
+
|
|
418
|
+
_draw_box(parts, ax, y5, arm_w, box_h)
|
|
419
|
+
_draw_text(parts, cx_box, y5 + 22, "Final analyzed set", 14, "#1F4E78",
|
|
420
|
+
bold=True, anchor="middle")
|
|
421
|
+
_draw_text(parts, cx_box, y5 + 42, f"n = {arm_cnt}",
|
|
422
|
+
12, "#555", anchor="middle")
|
|
423
|
+
|
|
424
|
+
else:
|
|
425
|
+
# No arm data — single column
|
|
426
|
+
_draw_arrow(parts, main_cx, y0 + box_h, main_cx, y3)
|
|
427
|
+
_draw_box(parts, main_x, y3, main_w, box_h)
|
|
428
|
+
_draw_text(parts, main_x + 15, y3 + 22, stages[4].name, 14, "#1F4E78", bold=True)
|
|
429
|
+
_draw_text(parts, main_x + 15, y3 + 42, f"N = {stages[4].remaining}")
|
|
430
|
+
|
|
431
|
+
_draw_arrow(parts, main_cx, y3 + box_h, main_cx, y4)
|
|
432
|
+
_draw_box(parts, main_x, y4, main_w, box_h)
|
|
433
|
+
_draw_text(parts, main_x + 15, y4 + 22, stages[5].name, 14, "#1F4E78", bold=True)
|
|
434
|
+
_draw_text(parts, main_x + 15, y4 + 42, f"N = {stages[5].remaining}")
|
|
435
|
+
|
|
436
|
+
# ── Watermark (opt-in) ───────────────────────────────────────────────
|
|
437
|
+
if show_watermark:
|
|
438
|
+
wm_y = (y5 + box_h + 25) if has_arms and needs_final_row else (y4 + box_h + 25)
|
|
439
|
+
_draw_text(parts, margin, wm_y, "Generated by research-tool flowchart command", 11, "#aaa")
|
|
440
|
+
|
|
441
|
+
# ── Arrowhead marker ─────────────────────────────────────────────────
|
|
442
|
+
parts.append(' <defs>')
|
|
443
|
+
parts.append(' <marker id="arrowhead" markerWidth="10" markerHeight="7" '
|
|
444
|
+
'refX="9" refY="3.5" orient="auto" markerUnits="strokeWidth">')
|
|
445
|
+
parts.append(' <path d="M0,0 L10,3.5 L0,7 Z" fill="#1F4E78" />')
|
|
446
|
+
parts.append(' </marker>')
|
|
447
|
+
parts.append(' </defs>')
|
|
448
|
+
parts.append("</svg>")
|
|
449
|
+
|
|
450
|
+
Path(output_path).write_text("\n".join(parts))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── ASCII renderer (unchanged - for terminal use) ───────────────────────────
|
|
454
|
+
|
|
455
|
+
def render_ascii(data: FlowchartData) -> str:
|
|
456
|
+
lines = []
|
|
457
|
+
box_w = 72
|
|
458
|
+
connector = "│"
|
|
459
|
+
|
|
460
|
+
title = f"Patient Flow Diagram — {data.study_name or data.study_id}"
|
|
461
|
+
lines.append(title)
|
|
462
|
+
lines.append("=" * len(title))
|
|
463
|
+
lines.append("")
|
|
464
|
+
|
|
465
|
+
for i, stage in enumerate(data.stages):
|
|
466
|
+
lines.append(f"┌{'─' * box_w}┐")
|
|
467
|
+
name = stage.name[:box_w - 2]
|
|
468
|
+
lines.append(f"│ {name.ljust(box_w - 2)} │")
|
|
469
|
+
|
|
470
|
+
if stage.excluded > 0:
|
|
471
|
+
nums = f"N = {stage.total} | Excluded = {stage.excluded} | Remaining = {stage.remaining}"
|
|
472
|
+
else:
|
|
473
|
+
nums = f"N = {stage.remaining}"
|
|
474
|
+
lines.append(f"│ {nums.ljust(box_w - 2)} │")
|
|
475
|
+
|
|
476
|
+
if stage.details.get("arm_counts"):
|
|
477
|
+
arms = stage.details["arm_counts"]
|
|
478
|
+
arm_str = " | ".join(f"{k}: {v}" for k, v in arms.items())
|
|
479
|
+
if len(arm_str) > box_w - 4:
|
|
480
|
+
arm_str = arm_str[:box_w - 7] + "..."
|
|
481
|
+
lines.append(f"│ {arm_str.ljust(box_w - 2)} │")
|
|
482
|
+
elif stage.note:
|
|
483
|
+
note = stage.note[:box_w - 4]
|
|
484
|
+
lines.append(f"│ {note.ljust(box_w - 2)} │")
|
|
485
|
+
else:
|
|
486
|
+
lines.append(f"│{' ' * box_w}│")
|
|
487
|
+
|
|
488
|
+
lines.append(f"└{'─' * box_w}┘")
|
|
489
|
+
|
|
490
|
+
if i < len(data.stages) - 1:
|
|
491
|
+
lines.append(f" {connector}")
|
|
492
|
+
lines.append(f" {connector}")
|
|
493
|
+
lines.append("")
|
|
494
|
+
|
|
495
|
+
return "\n".join(lines)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ── Entry point ──────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
def render_flowchart(study_id: str, output_path: str | None = None,
|
|
501
|
+
ascii: bool = False,
|
|
502
|
+
show_title: bool = True, show_watermark: bool = False,
|
|
503
|
+
verbose: bool = False,
|
|
504
|
+
show_study_name: bool = False) -> Path | str:
|
|
505
|
+
data = load_flowchart_data(study_id)
|
|
506
|
+
if ascii:
|
|
507
|
+
return render_ascii(data)
|
|
508
|
+
path = output_path or str(DATA_ROOT / study_id / "flowchart.svg")
|
|
509
|
+
render_svg(data, path, show_title=show_title, show_watermark=show_watermark,
|
|
510
|
+
verbose=verbose, show_study_name=show_study_name)
|
|
511
|
+
return Path(path)
|