Coverage for src/instawell/cli.py: 0%

200 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-07 15:47 -0600

1""" 

2Command-line interface for InstaWell. 

3 

4Provides an intuitive CLI for running DSF data analysis pipelines. 

5""" 

6 

7import json 

8import sys 

9from pathlib import Path 

10 

11import click 

12 

13from instawell import ( 

14 StepFiles, 

15 average_accross_replicates, 

16 calculate_derivative, 

17 filter_wells, 

18 find_min_temperature, 

19 ingest_data, 

20 load_experiment_context, 

21 min_max_scale, 

22 setup_experiment, 

23 subtract_background, 

24) 

25 

26 

27@click.group() 

28@click.version_option(package_name="instawell") 

29def cli(): 

30 """ 

31 InstaWell - Differential Scanning Fluorimetry (DSF) Data Analysis. 

32 

33 A powerful toolkit for processing thermal shift assay experiments. 

34 

35 \b 

36 Quick Start: 

37 1. Create experiment: instawell init my_exp --raw data.csv --layout layout.csv 

38 2. Run full pipeline: instawell run my_exp --all 

39 3. View results: instawell info my_exp 

40 

41 \b 

42 Common Workflows: 

43 # Run specific steps 

44 instawell run my_exp --steps ingest,filter,average 

45 

46 # Filter wells interactively 

47 instawell filter my_exp --wells A1,B2,C3 

48 

49 # List all experiments 

50 instawell list 

51 """ 

52 pass 

53 

54 

55@cli.command() 

56@click.argument("experiment_name") 

57@click.option("--raw", "-r", "raw_data_path", required=True, type=click.Path(exists=True), 

58 help="Path to raw fluorescence data CSV file") 

59@click.option("--layout", "-l", "layout_path", required=True, type=click.Path(exists=True), 

60 help="Path to plate layout CSV file") 

61@click.option("--root", default="experiments", type=click.Path(), 

62 help="Root directory for experiments (default: ./experiments)") 

63@click.option("--separator", default="_", type=str, 

64 help="Condition separator character (default: _)") 

65@click.option("--fields", default="concentration,ligand,protein,buffer", type=str, 

66 help="Comma-separated field order (default: concentration,ligand,protein,buffer)") 

67@click.option("--temp-col", default="Temperature", type=str, 

68 help="Temperature column name (default: Temperature)") 

69def init(experiment_name, raw_data_path, layout_path, root, separator, fields, temp_col): 

70 """ 

71 Initialize a new experiment. 

72 

73 Creates experiment directory and copies data files. 

74 

75 \b 

76 Example: 

77 instawell init TSA_001 --raw raw.csv --layout layout.csv 

78 instawell init TSA_002 -r data.csv -l plate.csv --separator '|' 

79 """ 

80 click.echo(f"🔬 Initializing experiment: {click.style(experiment_name, fg='cyan', bold=True)}") 

81 

82 try: 

83 # Parse fields 

84 fields_tuple = tuple(f.strip() for f in fields.split(",")) 

85 

86 # Validate separator 

87 if len(separator) != 1: 

88 raise click.BadParameter("Separator must be exactly one character") 

89 

90 # Setup experiment 

91 ctx = setup_experiment( 

92 experiment_name=experiment_name, 

93 raw_data_path=raw_data_path, 

94 layout_data_path=layout_path, 

95 experiments_root=root, 

96 condition_separator=separator, 

97 fields=fields_tuple, 

98 temperature_column=temp_col, 

99 ) 

100 

101 click.echo(f"✅ Experiment created at: {click.style(str(ctx.experiment_dir), fg='green')}") 

102 click.echo(f" 📁 Raw data: {ctx.raw_data_path.name}") 

103 click.echo(f" 📁 Layout: {ctx.layout_data_path.name}") 

104 click.echo(f" ⚙️ Separator: '{separator}'") 

105 click.echo(f" ⚙️ Fields: {', '.join(fields_tuple)}") 

106 click.echo() 

107 click.echo(f"💡 Next step: Run pipeline with 'instawell run {experiment_name} --all'") 

108 

109 except Exception as e: 

110 click.echo(f"❌ Error: {e}", err=True) 

111 sys.exit(1) 

112 

113 

114@cli.command() 

115@click.argument("experiment_name") 

116@click.option("--all", "-a", "run_all", is_flag=True, 

117 help="Run all pipeline steps") 

118@click.option("--steps", "-s", type=str, 

119 help="Comma-separated list of steps: ingest,filter,average,background,scale,derivative,min_temp") 

120@click.option("--filter-wells", type=str, 

121 help="Comma-separated list of wells to filter (e.g., A1,B2)") 

122@click.option("--root", default="experiments", type=click.Path(), 

123 help="Root directory for experiments") 

124def run(experiment_name, run_all, steps, filter_wells, root): 

125 """ 

126 Run processing pipeline steps. 

127 

128 \b 

129 Steps: 

130 ingest - Parse layout and organize raw data 

131 filter - Remove problematic wells 

132 average - Average technical replicates 

133 background - Subtract background signal 

134 scale - Normalize to 0-1 range 

135 derivative - Calculate -dY/dT 

136 min_temp - Find melting temperatures 

137 

138 \b 

139 Examples: 

140 # Run entire pipeline 

141 instawell run TSA_001 --all 

142 

143 # Run specific steps 

144 instawell run TSA_001 --steps ingest,filter,average 

145 

146 # Run with well filtering 

147 instawell run TSA_001 --all --filter-wells A1,B2,G20 

148 """ 

149 try: 

150 # Load experiment 

151 ctx = load_experiment_context(experiment_name, experiments_root=root) 

152 click.echo(f"🔬 Running pipeline for: {click.style(experiment_name, fg='cyan', bold=True)}") 

153 

154 # Parse filter wells 

155 wells_to_filter = [] 

156 if filter_wells: 

157 wells_to_filter = [w.strip() for w in filter_wells.split(",")] 

158 

159 # Determine which steps to run 

160 if run_all: 

161 step_names = ["ingest", "filter", "average", "background", "scale", "derivative", "min_temp"] 

162 elif steps: 

163 step_names = [s.strip() for s in steps.split(",")] 

164 else: 

165 click.echo("❌ Error: Must specify --all or --steps", err=True) 

166 sys.exit(1) 

167 

168 # Step mapping 

169 step_map = { 

170 "ingest": ("Ingesting data", lambda: ingest_data(ctx)), 

171 "filter": ("Filtering wells", lambda: filter_wells(ctx, wells_to_filter=wells_to_filter)), 

172 "average": ("Averaging replicates", lambda: average_accross_replicates(ctx)), 

173 "background": ("Subtracting background", lambda: subtract_background(ctx)), 

174 "scale": ("Min-max scaling", lambda: min_max_scale(ctx)), 

175 "derivative": ("Calculating derivative", lambda: calculate_derivative(ctx)), 

176 "min_temp": ("Finding min temperatures", lambda: find_min_temperature(ctx)), 

177 } 

178 

179 # Run steps 

180 click.echo() 

181 for step_name in step_names: 

182 if step_name not in step_map: 

183 click.echo(f"⚠️ Warning: Unknown step '{step_name}', skipping", err=True) 

184 continue 

185 

186 description, func = step_map[step_name] 

187 click.echo(f" ▶️ {description}...", nl=False) 

188 

189 try: 

190 func() 

191 click.echo(click.style(" ✓", fg="green")) 

192 except Exception as e: 

193 click.echo(click.style(f" ✗ ({e})", fg="red")) 

194 raise 

195 

196 click.echo() 

197 click.echo(click.style("✅ Pipeline completed successfully!", fg="green", bold=True)) 

198 click.echo(f" 📁 Results: {ctx.experiment_dir}") 

199 click.echo() 

200 click.echo(f"💡 View results with: instawell info {experiment_name}") 

201 

202 except FileNotFoundError: 

203 click.echo(f"❌ Error: Experiment '{experiment_name}' not found in {root}", err=True) 

204 click.echo(f" Run 'instawell list' to see available experiments", err=True) 

205 sys.exit(1) 

206 except Exception as e: 

207 click.echo(f"❌ Error: {e}", err=True) 

208 sys.exit(1) 

209 

210 

211@cli.command() 

212@click.option("--root", default="experiments", type=click.Path(), 

213 help="Root directory for experiments") 

214def list(root): 

215 """ 

216 List all experiments. 

217 

218 Shows all initialized experiments in the experiments directory. 

219 

220 \b 

221 Example: 

222 instawell list 

223 instawell list --root /path/to/experiments 

224 """ 

225 experiments_root = Path(root) 

226 

227 if not experiments_root.exists(): 

228 click.echo(f"📁 No experiments directory found at: {experiments_root}") 

229 click.echo(f" Create your first experiment with 'instawell init'") 

230 return 

231 

232 # Find all experiment directories (ones with experiment.json) 

233 experiments = [] 

234 for item in experiments_root.iterdir(): 

235 if item.is_dir() and (item / "experiment.json").exists(): 

236 experiments.append(item) 

237 

238 if not experiments: 

239 click.echo(f"📁 No experiments found in: {experiments_root}") 

240 click.echo(f" Create your first experiment with 'instawell init'") 

241 return 

242 

243 # Display experiments 

244 click.echo(f"📊 Experiments in {click.style(str(experiments_root), fg='cyan')}:") 

245 click.echo() 

246 

247 for exp_dir in sorted(experiments): 

248 # Load metadata 

249 try: 

250 with open(exp_dir / "experiment.json") as f: 

251 metadata = json.load(f) 

252 

253 name = metadata.get("experiment_name", exp_dir.name) 

254 created = metadata.get("created_at_iso", "Unknown") 

255 

256 # Check which steps have been completed 

257 completed_steps = [] 

258 if (exp_dir / StepFiles.INGESTED_DATA).exists(): 

259 completed_steps.append("ingest") 

260 if (exp_dir / StepFiles.FILTERED_DATA).exists(): 

261 completed_steps.append("filter") 

262 if (exp_dir / StepFiles.AVERAGED_DATA).exists(): 

263 completed_steps.append("average") 

264 if (exp_dir / StepFiles.MIN_TEMPERATURES_DATA).exists(): 

265 completed_steps.append("complete") 

266 

267 status = " → ".join(completed_steps) if completed_steps else "initialized" 

268 

269 click.echo(f" • {click.style(name, fg='cyan', bold=True)}") 

270 click.echo(f" Created: {created}") 

271 click.echo(f" Status: {status}") 

272 click.echo() 

273 

274 except Exception as e: 

275 click.echo(f" • {exp_dir.name} (error reading metadata: {e})") 

276 click.echo() 

277 

278 

279@cli.command() 

280@click.argument("experiment_name") 

281@click.option("--root", default="experiments", type=click.Path(), 

282 help="Root directory for experiments") 

283@click.option("--verbose", "-v", is_flag=True, 

284 help="Show detailed information") 

285def info(experiment_name, root, verbose): 

286 """ 

287 Show experiment information. 

288 

289 Displays configuration, status, and output files. 

290 

291 \b 

292 Example: 

293 instawell info TSA_001 

294 instawell info TSA_001 --verbose 

295 """ 

296 try: 

297 ctx = load_experiment_context(experiment_name, experiments_root=root) 

298 

299 click.echo(f"🔬 Experiment: {click.style(experiment_name, fg='cyan', bold=True)}") 

300 click.echo(f" 📁 Location: {ctx.experiment_dir}") 

301 click.echo() 

302 

303 # Configuration 

304 click.echo(click.style("⚙️ Configuration:", fg="yellow", bold=True)) 

305 click.echo(f" Separator: '{ctx.condition_separator}'") 

306 click.echo(f" Fields: {', '.join(ctx.fields)}") 

307 click.echo(f" Temperature column: {ctx.temperature_column}") 

308 click.echo(f" NPC marker: {ctx.non_protein_control_marker}") 

309 click.echo() 

310 

311 # Files 

312 click.echo(click.style("📄 Output Files:", fg="yellow", bold=True)) 

313 step_files = [ 

314 (StepFiles.INGESTED_DATA, "Raw organized data"), 

315 (StepFiles.FILTERED_DATA, "Filtered data"), 

316 (StepFiles.AVERAGED_DATA, "Averaged replicates"), 

317 (StepFiles.BG_SUB_DATA, "Background subtracted"), 

318 (StepFiles.MIN_MAX_SCALED_DATA, "Min-max scaled"), 

319 (StepFiles.DERIVATIVE_DATA, "Derivative"), 

320 (StepFiles.MIN_TEMPERATURES_DATA, "Min temperatures"), 

321 ] 

322 

323 for file_name, description in step_files: 

324 file_path = ctx.experiment_dir / file_name 

325 if file_path.exists(): 

326 size = file_path.stat().st_size / 1024 # KB 

327 status = click.style("✓", fg="green") 

328 size_str = f"({size:.1f} KB)" 

329 else: 

330 status = click.style("✗", fg="red") 

331 size_str = "" 

332 

333 click.echo(f" {status} {description:<25} {size_str}") 

334 

335 # Filtered wells 

336 filtered_wells_path = ctx.experiment_dir / StepFiles.FILTERED_WELLS 

337 if filtered_wells_path.exists(): 

338 with open(filtered_wells_path) as f: 

339 wells = [w.strip() for w in f.readlines() if w.strip()] 

340 if wells: 

341 click.echo() 

342 click.echo(click.style("🚫 Filtered Wells:", fg="yellow", bold=True)) 

343 click.echo(f" {', '.join(wells)}") 

344 

345 # Verbose: show min temperatures summary 

346 if verbose: 

347 min_temp_path = ctx.experiment_dir / StepFiles.MIN_TEMPERATURES_DATA 

348 if min_temp_path.exists(): 

349 import pandas as pd 

350 

351 df = pd.read_csv(min_temp_path) 

352 click.echo() 

353 click.echo(click.style("🌡️ Min Temperatures Summary:", fg="yellow", bold=True)) 

354 click.echo(f" Total conditions: {len(df)}") 

355 click.echo(f" Temp range: {df['min_temperature'].min():.1f}°C - {df['min_temperature'].max():.1f}°C") 

356 click.echo(f" Mean Tm: {df['min_temperature'].mean():.1f}°C") 

357 

358 except FileNotFoundError: 

359 click.echo(f"❌ Error: Experiment '{experiment_name}' not found", err=True) 

360 sys.exit(1) 

361 except Exception as e: 

362 click.echo(f"❌ Error: {e}", err=True) 

363 sys.exit(1) 

364 

365 

366@cli.command() 

367@click.argument("experiment_name") 

368@click.argument("wells", nargs=-1) 

369@click.option("--root", default="experiments", type=click.Path(), 

370 help="Root directory for experiments") 

371def filter(experiment_name, wells, root): 

372 """ 

373 Filter wells from an experiment. 

374 

375 Add wells to the filter list and re-filter the data. 

376 

377 \b 

378 Examples: 

379 instawell filter TSA_001 A1 B2 G20 

380 instawell filter TSA_001 C3 

381 """ 

382 try: 

383 ctx = load_experiment_context(experiment_name, experiments_root=root) 

384 

385 # Check if data has been ingested 

386 if not (ctx.experiment_dir / StepFiles.INGESTED_DATA).exists(): 

387 click.echo("❌ Error: Data not ingested yet. Run 'instawell run ... --steps ingest' first", err=True) 

388 sys.exit(1) 

389 

390 if not wells: 

391 click.echo("❌ Error: No wells specified", err=True) 

392 sys.exit(1) 

393 

394 # Convert wells to list 

395 wells_to_filter = list(wells) 

396 

397 click.echo(f"🚫 Filtering wells from {click.style(experiment_name, fg='cyan', bold=True)}") 

398 click.echo(f" Wells: {', '.join(wells_to_filter)}") 

399 

400 # Run filter 

401 filter_wells(ctx, wells_to_filter=wells_to_filter) 

402 

403 click.echo(click.style("✅ Wells filtered successfully!", fg="green")) 

404 click.echo(f" 📁 Filtered data: {ctx.experiment_dir / StepFiles.FILTERED_DATA}") 

405 

406 except FileNotFoundError: 

407 click.echo(f"❌ Error: Experiment '{experiment_name}' not found", err=True) 

408 sys.exit(1) 

409 except Exception as e: 

410 click.echo(f"❌ Error: {e}", err=True) 

411 sys.exit(1) 

412 

413 

414def main(): 

415 """Entry point for the CLI.""" 

416 cli() 

417 

418 

419if __name__ == "__main__": 

420 main()