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
« 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."""
3from __future__ import annotations
5import logging
6from typing import TYPE_CHECKING, ClassVar
8import h5py
9import matplotlib as mpl
10import numpy as np
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
18from nexusLIMS.extractors.plugins.preview_generators.image_preview import _pad_to_square
20if TYPE_CHECKING:
21 from pathlib import Path
23 from nexusLIMS.extractors.base import ExtractionContext
25_logger = logging.getLogger(__name__)
27_RGB_COLORS = ["#e41a1c", "#4daf4a", "#377eb8"]
29# ---------------------------------------------------------------------------
30# Ion mass lookup tables (monoisotopic masses)
31# ---------------------------------------------------------------------------
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]
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]
141# ---------------------------------------------------------------------------
142# Helpers
143# ---------------------------------------------------------------------------
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)
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
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
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
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
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
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.
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 )
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
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.
235 Reads one write at a time to avoid loading the full ragged array into memory.
237 Parameters
238 ----------
239 el
240 EventList HDF5 dataset of shape (nwrites, nsegs, nx), object dtype
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
260# ---------------------------------------------------------------------------
261# Preview generator class
262# ---------------------------------------------------------------------------
265class TofwerkPfibPreviewGenerator:
266 """
267 Preview generator for Tofwerk fibTOF pFIB-ToF-SIMS HDF5 files.
269 Generates a composite preview image showing:
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 """
277 name = "tofwerk_pfib_preview"
278 priority = 150
279 supported_extensions: ClassVar = {"h5"}
281 def supports(self, context: ExtractionContext) -> bool:
282 """
283 Check if this generator supports the given file.
285 Performs content sniffing identical to the extractor to confirm this is a
286 Tofwerk fibTOF FIB-SIMS HDF5 file.
288 Parameters
289 ----------
290 context
291 The extraction context containing file information
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
311 def generate(self, context: ExtractionContext, output_path: Path) -> bool:
312 """
313 Generate a composite preview image for a Tofwerk fibTOF HDF5 file.
315 Parameters
316 ----------
317 context
318 The extraction context containing file information
319 output_path
320 Path where the preview PNG should be saved
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
338# ---------------------------------------------------------------------------
339# Core preview generation (module-level for easier testing)
340# ---------------------------------------------------------------------------
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.
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
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
369 # Mass axis and sum spectrum
370 mass_axis = f["FullSpectra/MassAxis"][:]
371 sum_spec = f["FullSpectra/SumSpectrum"][:]
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))
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
388 ion_lookup = _build_ion_lookup(
389 _IONS_POSITIVE if str(ion_mode).lower() == "positive" else _IONS_NEGATIVE
390 )
392 # Trim spectrum to min_mass
393 valid = mass_axis >= min_mass
394 mass_v = mass_axis[valid]
395 spec_v = sum_spec[valid]
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))
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]
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"
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"
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
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, :])
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")
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")
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 )
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)
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)
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 )
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")
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 )
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"
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 )
675 fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
676 plt.close(fig)