Coverage for nexusLIMS/extractors/plugins/preview_generators/tofwerk_pfib_preview.py: 100%

269 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1"""Tofwerk fibTOF pFIB-ToF-SIMS preview generator plugin.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from typing import TYPE_CHECKING, ClassVar 

7 

8import h5py 

9import matplotlib as mpl 

10import numpy as np 

11 

12mpl.use("Agg") 

13import matplotlib.pyplot as plt 

14from matplotlib import gridspec 

15from matplotlib.patches import Patch 

16from mpl_toolkits.axes_grid1 import make_axes_locatable 

17 

18from nexusLIMS.extractors.plugins.preview_generators.image_preview import _pad_to_square 

19 

20if TYPE_CHECKING: 

21 from pathlib import Path 

22 

23 from nexusLIMS.extractors.base import ExtractionContext 

24 

25_logger = logging.getLogger(__name__) 

26 

27_RGB_COLORS = ["#e41a1c", "#4daf4a", "#377eb8"] 

28 

29# --------------------------------------------------------------------------- 

30# Ion mass lookup tables (monoisotopic masses) 

31# --------------------------------------------------------------------------- 

32 

33_IONS_POSITIVE = [ 

34 (1.00782, "H\u207a"), 

35 (2.01565, "H\u2082\u207a"), 

36 (3.02347, "H\u2083\u207a"), 

37 (7.01600, "Li\u207a"), 

38 (9.01218, "Be\u207a"), 

39 (11.00931, "B\u207a"), 

40 (12.00000, "C\u207a"), 

41 (13.00782, "CH\u207a"), 

42 (14.00307, "N\u207a"), 

43 (14.01565, "CH\u2082\u207a"), 

44 (15.02348, "CH\u2083\u207a"), 

45 (15.99491, "O\u207a"), 

46 (17.00274, "OH\u207a"), 

47 (18.01057, "H\u2082O\u207a"), 

48 (18.99840, "F\u207a"), 

49 (22.98977, "Na\u207a"), 

50 (23.98504, "Mg\u207a"), 

51 (26.98154, "Al\u207a"), 

52 (27.97693, "Si\u207a"), 

53 (30.97376, "P\u207a"), 

54 (31.97207, "S\u207a"), 

55 (34.96885, "\u00b3\u2075Cl\u207a"), 

56 (36.96590, "\u00b3\u2077Cl\u207a"), 

57 (38.96371, "K\u207a"), 

58 (39.96259, "Ca\u207a"), 

59 (42.97645, "AlO\u207a"), 

60 (43.97184, "SiO\u207a"), 

61 (44.95592, "Sc\u207a"), 

62 (45.95263, "\u2074\u2076Ti\u207a"), 

63 (46.95176, "\u2074\u2077Ti\u207a"), 

64 (47.94795, "Ti\u207a"), 

65 (48.94787, "\u2074\u2079Ti\u207a"), 

66 (49.94479, "\u2075\u2070Ti\u207a"), 

67 (50.94396, "V\u207a"), 

68 (51.94051, "Cr\u207a"), 

69 (53.93882, "\u2075\u2074Cr\u207a"), 

70 (53.93961, "\u2075\u2074Fe\u207a"), 

71 (54.93805, "Mn\u207a"), 

72 (55.93494, "Fe\u207a"), 

73 (57.93535, "\u2075\u2078Ni\u207a"), 

74 (58.93320, "Co\u207a"), 

75 (59.93078, "\u2076\u2070Ni\u207a"), 

76 (62.92960, "Cu\u207a"), 

77 (63.92914, "Zn\u207a"), 

78 (64.92784, "\u2076\u2075Cu\u207a"), 

79 (65.92603, "\u2076\u2076Zn\u207a"), 

80 (68.92558, "\u2076\u2079Ga\u207a"), 

81 (70.92470, "\u2077\u00b9Ga\u207a"), 

82 (73.92118, "Ge\u207a"), 

83 (74.92160, "As\u207a"), 

84 (78.91834, "Br\u207a"), 

85 (79.91652, "Se\u207a"), 

86 (83.91151, "Kr\u207a"), 

87 (84.91179, "Rb\u207a"), 

88 (87.90561, "Sr\u207a"), 

89 (88.90585, "Y\u207a"), 

90 (89.90470, "Zr\u207a"), 

91 (92.90638, "Nb\u207a"), 

92 (97.90541, "Mo\u207a"), 

93 (102.90550, "Rh\u207a"), 

94 (106.90509, "Ag\u207a"), 

95 (113.90336, "Cd\u207a"), 

96 (114.90388, "In\u207a"), 

97 (119.90220, "Sn\u207a"), 

98 (120.90381, "Sb\u207a"), 

99 (126.90447, "I\u207a"), 

100 (132.90543, "Cs\u207a"), 

101 (137.90524, "Ba\u207a"), 

102 (138.90635, "La\u207a"), 

103 (139.90543, "Ce\u207a"), 

104 (157.92410, "Gd\u207a"), 

105 (179.94655, "Hf\u207a"), 

106 (183.95093, "W\u207a"), 

107 (194.96479, "Pt\u207a"), 

108 (196.96655, "Au\u207a"), 

109 (207.97665, "Pb\u207a"), 

110] 

111 

112_IONS_NEGATIVE = [ 

113 (1.00782, "H\u207b"), 

114 (11.00931, "B\u207b"), 

115 (12.00000, "C\u207b"), 

116 (13.00782, "CH\u207b"), 

117 (14.00307, "N\u207b"), 

118 (15.99491, "O\u207b"), 

119 (17.00274, "OH\u207b"), 

120 (18.99840, "F\u207b"), 

121 (25.97926, "CN\u207b"), 

122 (26.98154, "Al\u207b"), 

123 (27.97693, "Si\u207b"), 

124 (30.97376, "P\u207b"), 

125 (31.97207, "S\u207b"), 

126 (34.96885, "\u00b3\u2075Cl\u207b"), 

127 (36.96590, "\u00b3\u2077Cl\u207b"), 

128 (47.94795, "Ti\u207b"), 

129 (51.94051, "Cr\u207b"), 

130 (55.93494, "Fe\u207b"), 

131 (62.92960, "Cu\u207b"), 

132 (78.91834, "Br\u207b"), 

133 (106.90509, "Ag\u207b"), 

134 (126.90447, "I\u207b"), 

135 (194.96479, "Pt\u207b"), 

136 (196.96655, "Au\u207b"), 

137 (207.97665, "Pb\u207b"), 

138] 

139 

140 

141# --------------------------------------------------------------------------- 

142# Helpers 

143# --------------------------------------------------------------------------- 

144 

145 

146def _norm_channel(arr: np.ndarray) -> np.ndarray: 

147 """Normalize 2-D array to [0,1] with 1st/99th-percentile clipping.""" 

148 lo, hi = np.percentile(arr, 1), np.percentile(arr, 99) 

149 if hi == lo: 

150 return np.zeros_like(arr, dtype=float) 

151 return np.clip((arr - lo) / (hi - lo), 0.0, 1.0) 

152 

153 

154def _read_attr_scalar(obj, key: str, default=None): 

155 """Return a scalar attribute value, decoding bytes if needed.""" 

156 if key not in obj.attrs: 

157 return default 

158 val = np.asarray(obj.attrs[key]).flat[0] 

159 if isinstance(val, (bytes, np.bytes_)): 

160 val = val.decode() 

161 return val 

162 

163 

164def _build_ion_lookup(ions: list) -> dict: 

165 """Build a dict round(mass) -> [(exact_mass, label)] for fast lookup.""" 

166 lookup: dict = {} 

167 for mass, label in ions: 

168 lookup.setdefault(round(mass), []).append((mass, label)) 

169 return lookup 

170 

171 

172def _ion_label(mz: float, lookup: dict, tol: float = 0.35) -> str | None: 

173 """Return the label of the nearest ion within tol Da, or None.""" 

174 key = round(mz) 

175 candidates = [] 

176 for k in (key - 1, key, key + 1): 

177 candidates.extend(lookup.get(k, [])) 

178 if not candidates: 

179 return None 

180 best_mass, best_lbl = min(candidates, key=lambda x: abs(x[0] - mz)) 

181 return best_lbl if abs(best_mass - mz) <= tol else None 

182 

183 

184_MARKER_THRESHOLD = 50 

185_MIN_INTERIOR_PIXELS = 100 

186_MIN_PEAK_SEPARATION_DA = 2.0 

187_N_RGB_CHANNELS = 3 

188_MAX_XTICK_WRITES = 30 

189_MILLION = 1e6 

190_THOUSAND = 1e3 

191 

192 

193def _depth_plot_style(nwrites: int): 

194 """Return (fmt, lw, ms, mew) suitable for the write count.""" 

195 if nwrites <= _MARKER_THRESHOLD: 

196 return "o-", 1.8, 5, 1.5 

197 return "-", 1.4, 0, 0 

198 

199 

200def _tic_display_limits( 

201 tic_map: np.ndarray, 

202 border_frac: float = 0.06, 

203 lo_pct: float = 2, 

204 hi_pct: float = 98, 

205) -> tuple[float, float]: 

206 """ 

207 Compute robust vmin/vmax for TIC map by sampling only interior pixels. 

208 

209 Excludes a border fraction to avoid edge-enhancement artefacts inflating 

210 the percentile clipping. 

211 """ 

212 h, w = tic_map.shape 

213 bord = max(1, int(min(h, w) * border_frac)) 

214 interior = tic_map[bord:-bord, bord:-bord] 

215 if interior.size < _MIN_INTERIOR_PIXELS: 

216 interior = tic_map 

217 return float(np.percentile(interior, lo_pct)), float( 

218 np.percentile(interior, hi_pct) 

219 ) 

220 

221 

222def _add_colorbar(fig, ax, im, label: str, extend: str = "neither"): 

223 div = make_axes_locatable(ax) 

224 cax = div.append_axes("right", size="5%", pad=0.05) 

225 cb = fig.colorbar(im, cax=cax, extend=extend) 

226 cb.set_label(label, fontsize=7) 

227 cb.ax.tick_params(labelsize=6) 

228 return cb 

229 

230 

231def _compute_tic_from_eventlist(el: h5py.Dataset) -> tuple[np.ndarray, np.ndarray]: 

232 """ 

233 Compute TIC map (nsegs x nx) and per-write depth counts from EventList. 

234 

235 Reads one write at a time to avoid loading the full ragged array into memory. 

236 

237 Parameters 

238 ---------- 

239 el 

240 EventList HDF5 dataset of shape (nwrites, nsegs, nx), object dtype 

241 

242 Returns 

243 ------- 

244 tic_map 

245 2-D int64 array of shape (nsegs, nx) 

246 depth_counts 

247 1-D int64 array of shape (nwrites,) 

248 """ 

249 nwrites, nsegs, nx = el.shape 

250 tic_map = np.zeros((nsegs, nx), dtype=np.int64) 

251 depth_counts = np.zeros(nwrites, dtype=np.int64) 

252 vec_len = np.frompyfunc(len, 1, 1) 

253 for w in range(nwrites): 

254 counts_2d = vec_len(el[w, :, :]).astype(np.int64) 

255 tic_map += counts_2d 

256 depth_counts[w] = counts_2d.sum() 

257 return tic_map, depth_counts 

258 

259 

260# --------------------------------------------------------------------------- 

261# Preview generator class 

262# --------------------------------------------------------------------------- 

263 

264 

265class TofwerkPfibPreviewGenerator: 

266 """ 

267 Preview generator for Tofwerk fibTOF pFIB-ToF-SIMS HDF5 files. 

268 

269 Generates a composite preview image showing: 

270 

271 - **Raw files:** FIB SE image | TIC map | Depth profile (row 0); 

272 Sum mass spectrum (row 1, full width) 

273 - **Opened files:** FIB SE image | TIC map | RGB composite (row 0); 

274 Sum spectrum | Depth profiles (row 1) 

275 """ 

276 

277 name = "tofwerk_pfib_preview" 

278 priority = 150 

279 supported_extensions: ClassVar = {"h5"} 

280 

281 def supports(self, context: ExtractionContext) -> bool: 

282 """ 

283 Check if this generator supports the given file. 

284 

285 Performs content sniffing identical to the extractor to confirm this is a 

286 Tofwerk fibTOF FIB-SIMS HDF5 file. 

287 

288 Parameters 

289 ---------- 

290 context 

291 The extraction context containing file information 

292 

293 Returns 

294 ------- 

295 bool 

296 True if this is a Tofwerk fibTOF FIB-SIMS HDF5 file 

297 """ 

298 if context.file_path.suffix.lower() != ".h5": 

299 return False 

300 try: 

301 with h5py.File(context.file_path, "r") as f: 

302 return ( 

303 "FullSpectra/SumSpectrum" in f 

304 and "FIBParams" in f 

305 and "FIBImages" in f 

306 and "TofDAQ Version" in f.attrs 

307 ) 

308 except Exception: 

309 return False 

310 

311 def generate(self, context: ExtractionContext, output_path: Path) -> bool: 

312 """ 

313 Generate a composite preview image for a Tofwerk fibTOF HDF5 file. 

314 

315 Parameters 

316 ---------- 

317 context 

318 The extraction context containing file information 

319 output_path 

320 Path where the preview PNG should be saved 

321 

322 Returns 

323 ------- 

324 bool 

325 True if preview was successfully generated, False otherwise 

326 """ 

327 try: 

328 output_path.parent.mkdir(parents=True, exist_ok=True) 

329 _generate_preview(str(context.file_path), output_path) 

330 _pad_to_square(output_path, new_width=1500) 

331 except Exception: 

332 _logger.exception("Failed to generate preview for %s", context.file_path) 

333 return False 

334 else: 

335 return True 

336 

337 

338# --------------------------------------------------------------------------- 

339# Core preview generation (module-level for easier testing) 

340# --------------------------------------------------------------------------- 

341 

342 

343def _generate_preview( # noqa: PLR0912, PLR0915 

344 h5_path: str, output_path: Path, min_mass: float = 5.0 

345) -> None: 

346 """ 

347 Generate and save a composite preview image for a Tofwerk fibTOF HDF5 file. 

348 

349 Parameters 

350 ---------- 

351 h5_path 

352 Path to the input HDF5 file 

353 output_path 

354 Path where the output PNG will be saved 

355 min_mass 

356 Minimum m/z threshold; peaks/spectrum below this mass are ignored 

357 """ 

358 with h5py.File(h5_path, "r") as f: 

359 has_peaks = "PeakData/PeakData" in f 

360 

361 # FIB image (first/original surface image) 

362 fib_keys = sorted(f["FIBImages"].keys()) 

363 if not fib_keys: 

364 msg = "No FIBImages found in file" 

365 raise ValueError(msg) 

366 fib_image = f[f"FIBImages/{fib_keys[0]}/Data"][:] 

367 fib_h, fib_w = fib_image.shape 

368 

369 # Mass axis and sum spectrum 

370 mass_axis = f["FullSpectra/MassAxis"][:] 

371 sum_spec = f["FullSpectra/SumSpectrum"][:] 

372 

373 # Root metadata for title 

374 voltage_kv = float(_read_attr_scalar(f["FIBParams"], "Voltage", 0)) / 1000.0 

375 ion_mode = _read_attr_scalar(f, "IonMode", "unknown") 

376 acq_time = _read_attr_scalar(f, "HDF5 File Creation Time", "") 

377 fib_hw = _read_attr_scalar(f["FIBParams"], "FibHardware", "unknown") 

378 nbr_writes = int(_read_attr_scalar(f, "NbrWrites", 0)) 

379 

380 if has_peaks: 

381 peak_data = f["PeakData/PeakData"][:] 

382 peak_table = f["PeakData/PeakTable"][:] 

383 else: 

384 el = f["FullSpectra/EventList"] 

385 tic_map, depth_counts = _compute_tic_from_eventlist(el) 

386 tic_h, tic_w = tic_map.shape 

387 

388 ion_lookup = _build_ion_lookup( 

389 _IONS_POSITIVE if str(ion_mode).lower() == "positive" else _IONS_NEGATIVE 

390 ) 

391 

392 # Trim spectrum to min_mass 

393 valid = mass_axis >= min_mass 

394 mass_v = mass_axis[valid] 

395 spec_v = sum_spec[valid] 

396 

397 # Derived arrays for opened files 

398 if has_peaks: 

399 nwrites_pk, ny, nx, npeaks = peak_data.shape 

400 per_mass = peak_data.sum(axis=(0, 1, 2)) 

401 spatial = peak_data.sum(axis=0) 

402 tic_map = spatial.sum(axis=2) 

403 depth_prof = peak_data.sum(axis=(1, 2)) 

404 

405 peak_masses = np.array([float(peak_table[i]["mass"]) for i in range(npeaks)]) 

406 above_min = np.where(peak_masses >= min_mass)[0] 

407 n_top = min(3, len(above_min)) 

408 top_idx = above_min[np.argsort(per_mass[above_min])[::-1][:n_top]] 

409 top_masses = [float(peak_table[i]["mass"]) for i in top_idx] 

410 

411 def _fmt_peak_label(i): 

412 raw_lbl = peak_table[i]["label"] 

413 if isinstance(raw_lbl, (bytes, np.bytes_)): 

414 raw_lbl = raw_lbl.decode().strip() 

415 if raw_lbl and raw_lbl.lower() not in ("", "nominal"): 

416 return raw_lbl 

417 matched = _ion_label(float(peak_table[i]["mass"]), ion_lookup) 

418 return matched if matched else f"{float(peak_table[i]['mass']):.0f} Da" 

419 

420 top_labels = [_fmt_peak_label(i) for i in top_idx] 

421 rgb_channels = [] 

422 for i in range(n_top): 

423 rgb_channels.append(_norm_channel(spatial[:, :, top_idx[i]])) 

424 # Pad to 3 channels if fewer than 3 peaks above min_mass 

425 _zero_channel = np.zeros((ny, nx), dtype=float) 

426 while len(rgb_channels) < _N_RGB_CHANNELS: 

427 rgb_channels.append( 

428 np.zeros_like(rgb_channels[0]) if rgb_channels else _zero_channel 

429 ) 

430 rgb = np.stack(rgb_channels, axis=2) 

431 writes = np.arange(nwrites_pk) + 1 

432 mode_str = "Processed" 

433 else: 

434 writes = np.arange(nbr_writes) + 1 

435 mode_str = "Raw" 

436 

437 # Annotate top peaks in sum spectrum (spaced >= 2 Da apart) 

438 annotated = [] 

439 for idx in np.argsort(spec_v)[::-1]: 

440 mz = mass_v[idx] 

441 if all(abs(mz - m) > _MIN_PEAK_SEPARATION_DA for m in annotated): 

442 annotated.append(mz) 

443 if len(annotated) >= (5 if has_peaks else 6): 

444 break 

445 

446 # Figure layout 

447 if has_peaks: 

448 fig = plt.figure(figsize=(13, 9)) 

449 fig.patch.set_facecolor("white") 

450 gs = gridspec.GridSpec( 

451 2, 

452 3, 

453 figure=fig, 

454 left=0.06, 

455 right=0.97, 

456 top=0.88, 

457 bottom=0.08, 

458 hspace=0.44, 

459 wspace=0.40, 

460 ) 

461 ax_fib = fig.add_subplot(gs[0, 0]) 

462 ax_tic = fig.add_subplot(gs[0, 1]) 

463 ax_rgb = fig.add_subplot(gs[0, 2]) 

464 ax_spec = fig.add_subplot(gs[1, :2]) 

465 ax_dep = fig.add_subplot(gs[1, 2]) 

466 else: 

467 fig = plt.figure(figsize=(12, 9)) 

468 fig.patch.set_facecolor("white") 

469 gs = gridspec.GridSpec( 

470 2, 

471 3, 

472 figure=fig, 

473 left=0.07, 

474 right=0.97, 

475 top=0.88, 

476 bottom=0.09, 

477 hspace=0.42, 

478 wspace=0.38, 

479 ) 

480 ax_fib = fig.add_subplot(gs[0, 0]) 

481 ax_tic = fig.add_subplot(gs[0, 1]) 

482 ax_dep = fig.add_subplot(gs[0, 2]) 

483 ax_spec = fig.add_subplot(gs[1, :]) 

484 

485 # Panel: FIB SE image 

486 im_fib = ax_fib.imshow(fib_image, cmap="gray", aspect="equal") 

487 ax_fib.set_title( 

488 f"FIB Secondary Electron Image\n({fib_w}\xd7{fib_h} px)", fontsize=9 

489 ) 

490 ax_fib.set_xlabel("X pixel", fontsize=8) 

491 ax_fib.set_ylabel("Y pixel", fontsize=8) 

492 ax_fib.tick_params(labelsize=7) 

493 _add_colorbar(fig, ax_fib, im_fib, "SE Intensity") 

494 

495 # Panel: TIC map 

496 vmin, vmax = _tic_display_limits(tic_map) 

497 tic_h2, tic_w2 = tic_map.shape 

498 im_tic = ax_tic.imshow( 

499 tic_map, 

500 cmap="inferno", 

501 aspect="equal", 

502 vmin=vmin, 

503 vmax=vmax, 

504 origin="upper", 

505 ) 

506 if has_peaks: 

507 tic_title = f"Total Ion Count Map\n({npeaks} peaks, {tic_w2}\xd7{tic_h2} px)" 

508 tic_cb_label = "Integrated counts" 

509 else: 

510 tic_title = f"Total Ion Count Map\n({nbr_writes} slices, {tic_w}\xd7{tic_h} px)" 

511 tic_cb_label = "Ion events" 

512 ax_tic.set_title(tic_title, fontsize=9) 

513 ax_tic.set_xlabel("X pixel", fontsize=8) 

514 ax_tic.set_ylabel("Y pixel", fontsize=8) 

515 ax_tic.tick_params(labelsize=7) 

516 _add_colorbar(fig, ax_tic, im_tic, tic_cb_label, extend="max") 

517 

518 # Panel: RGB composite (opened only) 

519 if has_peaks: 

520 ax_rgb.imshow(rgb, aspect="equal", origin="upper") 

521 ax_rgb.set_title( 

522 "False-Color RGB Composite\n(peak-integrated spatial maps)", fontsize=9 

523 ) 

524 ax_rgb.set_xlabel("X pixel", fontsize=8) 

525 ax_rgb.set_ylabel("Y pixel", fontsize=8) 

526 ax_rgb.tick_params(labelsize=7) 

527 legend_elements = [ 

528 Patch( 

529 facecolor=_RGB_COLORS[i], 

530 label=f"{'RGB'[i]}: {top_labels[i]} ({top_masses[i]:.0f} Da)", 

531 ) 

532 for i in range(n_top) 

533 ] 

534 ax_rgb.legend( 

535 handles=legend_elements, 

536 loc="lower right", 

537 fontsize=6.5, 

538 framealpha=0.75, 

539 handlelength=1.0, 

540 ) 

541 

542 # Panel: Depth profile 

543 fmt, lw, ms, mew = _depth_plot_style(len(writes)) 

544 if has_peaks: 

545 for color, pidx, mass_c, lbl in zip( 

546 _RGB_COLORS, top_idx, top_masses, top_labels 

547 ): 

548 kw: dict = { 

549 "color": color, 

550 "linewidth": lw, 

551 "label": f"{lbl} ({mass_c:.0f} Da)", 

552 } 

553 if ms: 

554 kw.update(markersize=ms, markerfacecolor="white", markeredgewidth=mew) 

555 ax_dep.plot(writes, depth_prof[:, pidx], fmt, **kw) 

556 ax_dep.set_title("Depth Profiles\n(top 3 mass channels)", fontsize=9) 

557 ax_dep.set_ylabel("Integrated counts", fontsize=8) 

558 if any( 

559 a.get_label() and not a.get_label().startswith("_") 

560 for a in ax_dep.get_lines() 

561 ): 

562 ax_dep.legend(fontsize=7, framealpha=0.7) 

563 else: 

564 kw = {"color": "steelblue", "linewidth": lw} 

565 if ms: 

566 kw.update(markersize=ms, markerfacecolor="white", markeredgewidth=mew) 

567 ax_dep.plot(writes, depth_counts, fmt, **kw) 

568 ax_dep.set_title( 

569 "Depth Profile\n(total ion events per milling step)", fontsize=9 

570 ) 

571 ax_dep.set_ylabel("Total ion events", fontsize=8) 

572 

573 ax_dep.set_xlabel("Milling step (write)", fontsize=8) 

574 ax_dep.tick_params(labelsize=7) 

575 if len(writes) <= _MAX_XTICK_WRITES: 

576 ax_dep.set_xticks(writes) 

577 ax_dep.yaxis.set_major_formatter( 

578 plt.FuncFormatter( 

579 lambda x, _: ( 

580 f"{x / _MILLION:.1f}M" 

581 if x >= _MILLION 

582 else (f"{x / _THOUSAND:.0f}k" if x >= _THOUSAND else f"{x:.0f}") 

583 ) 

584 ) 

585 ) 

586 ax_dep.grid(visible=True, linestyle="--", alpha=0.4) 

587 if len(writes) > 0: 

588 ax_dep.set_xlim(writes[0] - 0.5, writes[-1] + 0.5) 

589 if has_peaks and len(top_idx) > 0: 

590 all_counts = np.concatenate([depth_prof[:, i] for i in top_idx]) 

591 elif has_peaks: 

592 all_counts = depth_prof.sum(axis=1) 

593 else: 

594 all_counts = depth_counts 

595 if len(all_counts) > 0: 

596 ylo, yhi = np.percentile(all_counts, 2), np.percentile(all_counts, 98) 

597 pad = (yhi - ylo) * 0.1 or yhi * 0.05 or 1.0 

598 ax_dep.set_ylim(ylo - pad, yhi + pad) 

599 

600 # Panel: Sum mass spectrum 

601 ax_spec.plot(mass_v, spec_v, color="#2c7bb6", linewidth=0.7, alpha=0.9) 

602 ax_spec.fill_between(mass_v, spec_v, alpha=0.15, color="#2c7bb6") 

603 if has_peaks: 

604 for color, pidx, lbl in zip(_RGB_COLORS, top_idx, top_labels): 

605 lo = float(peak_table[pidx]["lower integration limit"]) 

606 hi = float(peak_table[pidx]["upper integration limit"]) 

607 mc = float(peak_table[pidx]["mass"]) 

608 ax_spec.axvspan( 

609 lo, hi, alpha=0.18, color=color, label=f"{lbl} ({mc:.0f} Da)" 

610 ) 

611 ax_spec.axvline(mc, color=color, linewidth=0.8, linestyle="--", alpha=0.7) 

612 ax_spec.set_title( 

613 "Summed Mass Spectrum -- top 3 peak integration windows highlighted", 

614 fontsize=9, 

615 ) 

616 if any( 

617 a.get_label() and not a.get_label().startswith("_") 

618 for a in ax_spec.get_lines() 

619 ): # pragma: no cover 

620 ax_spec.legend(fontsize=7, framealpha=0.7) 

621 else: 

622 ax_spec.set_title( 

623 "Summed Mass Spectrum (all pixels, all depth slices)", fontsize=9 

624 ) 

625 

626 ax_spec.set_yscale("log") 

627 ax_spec.set_xlabel("m/z (Da)", fontsize=8) 

628 ax_spec.set_ylabel("Ion counts (log)", fontsize=8) 

629 ax_spec.tick_params(labelsize=7) 

630 if len(mass_v) > 0: 

631 ax_spec.set_xlim(mass_v.min(), mass_v.max()) 

632 ax_spec.grid(visible=True, linestyle="--", alpha=0.3, which="both") 

633 

634 for mz in annotated: 

635 idx = int(np.argmin(np.abs(mass_v - mz))) 

636 lbl = _ion_label(mz, ion_lookup) 

637 annot = f"{lbl}\n({mz:.1f})" if lbl else f"{mz:.1f} Da" 

638 ax_spec.annotate( 

639 annot, 

640 xy=(mz, spec_v[idx]), 

641 xytext=(0, 10), 

642 textcoords="offset points", 

643 fontsize=6.5, 

644 ha="center", 

645 color="#1a4d7a", 

646 arrowprops={"arrowstyle": "->", "color": "gray", "lw": 0.6}, 

647 ) 

648 

649 # Title and disclaimer 

650 if has_peaks: 

651 dim_str = f"{nwrites_pk} depth slices, {nx}\xd7{ny} px, {npeaks} peaks" 

652 else: 

653 dim_str = f"{nbr_writes} depth slices, {tic_w}\xd7{tic_h} px" 

654 

655 fig.suptitle( 

656 f"pFIB-ToF-SIMS Preview ({mode_str}) | " 

657 f"{fib_hw} FIB, {voltage_kv:.0f} kV, {ion_mode} mode | " 

658 f"{dim_str} | {acq_time}", 

659 fontsize=10, 

660 fontweight="bold", 

661 y=0.97, 

662 ) 

663 fig.text( 

664 0.5, 

665 0.01, 

666 "\u26a0 Peak identifications are preliminary best-guess assignments based on " 

667 "monoisotopic mass matching (\xb10.35 Da). Verify with high-resolution data.", 

668 ha="center", 

669 va="bottom", 

670 fontsize=6.5, 

671 color="#666666", 

672 style="italic", 

673 ) 

674 

675 fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white") 

676 plt.close(fig)