PHI ToF-SIMS Notes¶
Thyra reads PHI (Physical Electronics) SmartSoft-TOF .raw files from nanoTOF
instruments directly -- there is no vendor SDK for this format, so the layout
below was established by reverse engineering and verified against the
instrument software's own exports.
Everything on this page was measured on a 512x512 negative-mode acquisition,
148 MB, 18.4 million ion events. The verification is not incidental: PHI's
software can export a total-ion image and peak images as .bif6 files, and
reconstructing the total ion image from Thyra's parse reproduces the vendor's
export bit-exactly -- 262,144 of 262,144 pixels identical.
File layout¶
[0, HeaderSize) ASCII key/value header, CRLF lines, SOFH ... EOFH,
zero-padded out to the size it declares itself
[HeaderSize, EOF) block-structured stream
The header is plain text. Top-level entries are Key: value; entries under a
[Section] marker are Key=value. HeaderSize (20000 in the reference file)
says where the binary begins.
Blocks¶
Everything after the header is a chain of blocks, each introduced by a 16-byte header:
| offset | type | meaning |
|---|---|---|
| 0 | uint16 | block id |
| 2 | 10 bytes | not interpreted |
| 12 | uint32 | record size |
| id | meaning |
|---|---|
| 1 | ion events (8 bytes each); 8 instead when MS/MS is active |
| 2 | end of frame, no payload |
| 6 | mosaic tile origin: two uint32 tile indices |
| 14 | appended ASCII info block, e.g. a post-hoc recalibration |
| 0 | end of stream |
Any other id is skipped using its record size. The chain tiles the payload
exactly, which is what makes it trustworthy: a parse that ends anywhere other
than end-of-file has gone wrong. In the reference file the walk covers 32,179
blocks and lands precisely on the final byte, and the 100 end-of-frame blocks
match the header's NoFrames: 100.
Events¶
Each event is a little-endian uint64:
| bits | meaning |
|---|---|
| 31 | set on a valid event |
| 0-30 | flight time in picoseconds (27 bits used) |
| 32-42 | X pixel index |
| 43-53 | Y pixel index |
| 58-63 | clear on a valid event |
Records failing that test are dropped. The reference file carries 64,493 of
them among 18.5 million (0.35%), many holding the 0xADADADAD uninitialised
-heap fill. Excluding them is what makes the reconstruction exact.
Do not parse the payload as a flat event array
Because block headers happen to be 16 bytes -- exactly two event slots -- and fail the validity test, a naive flat parse appears to work on single-tile data. It silently collapses every mosaic tile onto the same grid, because it never sees the block 6 tile origins.
Mass calibration¶
The relation is the standard time-of-flight one:
The header calibration may be stale¶
The acquisition header carries Mass/Time and MassOffset, and often
Calibrated: no alongside them. In the reference file those coefficients are
left over from a positive-mode session -- its Calibration: line lists C+H,
C₂, C₃, C₄ although the acquisition is negative-mode -- and they place known
ions about +3 mDa high:
| ion | theoretical | header calibration | error |
|---|---|---|---|
| CH₂⁻ | 14.01565 | 14.01851 | +2.86 mDa |
| CN⁻ | 26.00307 | 26.00676 | +3.69 mDa |
| CNO⁻ | 41.99799 | 42.00144 | +3.45 mDa |
| PO₂⁻ | 62.96343 | 62.96569 | +2.26 mDa |
| PO₃⁻ | 78.95834 | 78.96093 | +2.59 mDa |
Mean +3.02 mDa, standard deviation 0.46 -- a consistent bias, not scatter.
The real calibration is appended to the file¶
A recalibration performed after acquisition is not written back into the
header. It is appended near the end of the file as a block 14
AsciiInfoBlock01:
AppendedBlockType: AsciiInfoBlock01
BlockAppendedDate: 07/27/2026 17:29:40
Calibration: 4 (26.003016, C+N, 26.003099) (41.998143, C+N+O, 41.998001) ...
Mass/Time: 0.382674
MassOffset: -1.598302
Thyra prefers it automatically. The difference is not cosmetic -- measured against the instrument's own peak-image exports:
| calibration | peak count recovery | mean correlation |
|---|---|---|
| header | 83.4% | 0.852 |
| appended | 99.7% | 0.985 |
Pass use_appended_calibration=False to reproduce a legacy conversion that
ignored it. Whichever is used, uns["raw_metadata"]["calibration"] records
both sets of coefficients, which one was applied, and the appended block as
the file states it -- less any field that names a person, and with a path
reduced to its file name, as for the header (see
Output Format).
The time axis, and why it is binned¶
The instrument measures flight time; m/z is derived. Thyra therefore keeps
the flight time in var["tof_us"] next to var["mz"], so the calibration is
reversible without re-reading the raw file:
import spatialdata as sd
table = next(iter(sd.read_zarr("out.zarr").tables.values()))
cal = table.uns["raw_metadata"]["calibration"]
tof = table.var["tof_us"].to_numpy()
mz = (cal["mass_slope_used"] * tof + cal["mass_offset_used"]) ** 2 # == var["mz"]
# a corrected calibration needs only the stored times
better = (0.382674 * tof - 1.598302) ** 2
What binning costs¶
Events are binned onto the detector's time-channel grid, whose width the header
gives as SpecBinSize (0.128 ns = 128 ps). Individual flight times are
recorded more finely than that -- SpecBinIncr is 1 ps -- and in a
mass-shift-corrected file they are scattered off the native grid entirely (only
0.77% of times are multiples of 128 ps).
So it is fair to ask what binning to 128 ps throws away. Measured across bin widths on the reference file, against the vendor's peak images:
| bin (ps) | channels | non-zeros | axis size | peak recovery | correlation |
|---|---|---|---|---|---|
| 1 | 116,578,841 | 16,712,840 | 932.6 MB | 99.7% | 0.9846 |
| 8 | 14,572,356 | 16,712,840 | 116.6 MB | 99.7% | 0.9851 |
| 32 | 3,643,089 | 16,712,840 | 29.1 MB | 99.8% | 0.9869 |
| 128 | 910,773 | 16,712,608 | 7.3 MB | 100.4% | 0.9937 |
| 512 | 227,694 | 14,453,296 | 1.8 MB | 100.8% | 0.9820 |
| 2048 | 56,924 | 11,935,480 | 0.5 MB | 76.3% | -- |
Going from 1 ps to 128 ps merges 232 cells out of 16.7 million and shrinks the mass axis 128-fold. The data is so sparse that events essentially never share a bin: 18.4 million events occupy 16.7 million distinct (pixel, channel) cells regardless of how fine the bins are.
Agreement with the vendor does not merely survive the coarsening, it improves -- 0.9846 to 0.9937 -- because 128 ps is the grid the instrument software itself works on. Finer bins place events at positions that then fall differently against peak-window edges.
Below 128 ps you pay 128x the storage for nothing. Above it you start destroying data: at 512 ps a fifth of the cells merge, and at 2048 ps a quarter of all counts fall outside their peak windows and some peaks vanish entirely.
The rounding itself is small against any real peak. Events sit a mean 32 ps from their bin centre, worst case 64 ps, which at m/z 79 is 4.7 ppm or 0.37 mDa -- against a vendor integration window for PO₃⁻ that is 49.3 mDa wide.
What is not stored
The output holds the binned axis, not the 18.4 million individual events.
Per-event data -- for instance the exact arrival time of one ion, or
re-binning at a width Thyra did not choose -- requires the original .raw.
Keep it.
Resampling onto a common axis¶
Converting a single .raw needs no resampling -- the reader's own axis is the
output axis. Resampling only comes up when several samples have to share one
mass axis, and PHI is an awkward case for it.
InstrumentDetectorChain recognises the format (via the
format_specific["format"] stamp) and answers nearest_neighbor on a tof
axis -- the two-term width law FWHM(m) = sqrt(A m + B m^2) with
A = 0.454, B = 0.0284, laid at three bins per peak width. Both halves
matter.
Why the two-term law and not linear_tof. Two different laws are in play
here and it is easy to reach for the wrong one. The channels are laid out at
a constant flight-time step, and m/z goes as the square of time, so their
spacing grows as sqrt(m/z) -- the linear_tof law exactly, measured on the
reference file as 9.8e-5 u per channel at m/z 1 against 5.0e-4 at m/z 26, a
ratio of 5.1 where sqrt(26) is 5.099. That is the source grid, and
source_grid_law still reports it.
A target axis has a different job: its bins should track how wide a peak is, and the peaks do not follow the digitiser's law. Measured across 311 isolated peaks from twelve acquisitions, resolving power climbs from about 2,200 at m/z 10 and then levels off:
| m/z band | peaks | median R | median FWHM |
|---|---|---|---|
| 5--15 | 14 | 2,185 | 6.09 mDa |
| 15--25 | 29 | 2,399 | 7.98 mDa |
| 25--40 | 56 | 3,215 | 9.94 mDa |
| 40--60 | 74 | 4,007 | 12.88 mDa |
| 60--100 | 70 | 4,183 | 18.74 mDa |
| 100--200 | 60 | 4,334 | 29.93 mDa |
| 200--500 | 8 | 4,243 | 70.00 mDa |
A rise then a plateau is neither single-term limit: linear_tof has R climbing
as sqrt(m) without end, reflector_tof has it flat from the start. It is the
knee a two-term law describes, with the constant-time term governing below
m/z 40 and the proportional term above it. Fitting a single power law to this
is what makes the log-log exponent unstable -- subsets of the same peaks give
anything from 0.76 to 1.21, none of them near linear_tof's 0.5.
Inheriting the generic linear_tof bin width of 17 mDa at m/z 300 cost real
fidelity: 42% of those peaks landed under two bins per width, and the reference
acquisition's base peak got exactly one. See the
detector table note for the measurement
against the vendor's own peak-image export, and
the two-term TOF law for why the pair is
fitted at the narrow edge of the corpus rather than through its middle.
Why not tic_preserving. The method interpolates onto the target axis and
rescales the result back to the source TIC. A PHI pixel is not a profile
spectrum -- it holds only the channels that happened to fire, a median of 44
points spread across m/z 0.5--1850. np.interp draws a straight line between
consecutive points, so every bin in those gaps comes back with an intensity
nothing measured, and the rescale then normalises the total to the pixel's true
TIC. The fabrication is invisible to any total-ion check: on the reference
file the total came out at 70.3704 against a true 70.3707, while the average
spectrum went from 56% hard zeros to 0.1% -- empty regions such as m/z 200
sitting at 44% of the base peak. Use nearest_neighbor, or if the method is
forced, set ResamplingConfig.gap_tolerance_da (see
Resampling and thyra.resampling.gaps).
Forcing it does not happen silently: an explicit --resample-method that
contradicts the detector is warned about, and the warning names
--resample-gap-tolerance. The detector alone was never enough,
because a detector only steers auto.
The first and last channel. mass_range here is literally
(axis.mz[0], axis.mz[-1]) -- the first and last detector channel, not an
acquisition setting. A physics axis stores bin centres, which stop half a bin
short of the range they were built across, so testing membership against the
axis points would discard both channels in every pixel: measured on the mock
fixture, that stored 12 counts against the source's 14 on all six resampled
variants, where --no-resample stored all 14. The test is therefore the
declared range, so a peak on either bound lands in the edge bin. See
What "in range" means.
Choosing a bin width¶
The instrument's measured resolving power on the reference file is R ~ 4,000 (median), which is normal for a nanoTOF:
| ion | m/z | FWHM | R |
|---|---|---|---|
| C⁻ | 12.000 | 5.02 mDa | 2,393 |
| CN⁻ | 26.003 | 5.35 mDa | 4,861 |
| CNO⁻ | 41.998 | 8.90 mDa | 4,717 |
| PO₃⁻ | 78.958 | 17.12 mDa | 4,613 |
Note that this is resolving power, not axis spacing -- the 128 ps grid samples
each peak 11 to 20 times. A target axis only has to keep a few samples per
FWHM, and the default does exactly that: the tof axis lays bins at
FWHM(m) / 3 everywhere, which on the reference file's declared range is
103,070 bins at 1.9 mDa near m/z 27 and 4.9 mDa near m/z 79. CN⁻ and C₂H₂⁻ --
12.6 mDa apart, or 1.94 FWHM, genuinely resolved by this instrument -- come out
7 bins apart, and the 6 mDa window the acquisition's own peak list uses to
separate C¹⁵N⁻ from ¹³CN⁻ spans 3.
You should not normally need to set a width by hand. If you do, note that
--resample-width-at-mz on a tof axis sets the bins per peak width rather
than a fixed spacing: whatever k puts a bin of that width at
--resample-reference-mz.
A constant 0.1 Da axis destroys this data
It is roughly 19x the peak width at m/z 26, so CN⁻ and C₂H₂⁻ land in the same bin along with everything else between them. Constant-width axes suit profile MALDI-TOF; they do not suit ToF-SIMS.
A C60 primary beam resolves about five times worse
The law is fitted to Bi3 LMIG acquisitions, which dominate the corpus at R 1,700--7,100. C60 acquisitions in the same archive measure R 280--1,200. They are converted correctly -- they simply get around fifteen bins across a peak instead of three, so the store is larger than it needs to be. That is the safe direction, and the one the fit was deliberately placed on: a peak narrower than the law loses shape, a peak broader than it only costs bytes.
Previewing without decoding events¶
preview_msi promises that no spectra are decoded, and passes
metadata_only=True to the reader to keep that promise. It matters here:
counting the occupied channels, occupied_channel_counts(), walks every 8-byte
event in the file -- 0.16 s for a 16 MB acquisition with 2.02 M events, 0.14 s
for a 14 MB one, linear in size -- so a preview that counted them would cost
what a conversion costs.
PhiReader(path, metadata_only=True) answers from the acquisition header
and the block chain alone. Everything header-derived is still exact --
dimensions, coordinate bounds, m/z range, pixel size, and both detector
verdicts. What it cannot answer is which pixels carry an event, because that is
a property of the stream rather than the header, so n_spectra comes back as 0
with n_spectra_counted=False and preview_msi reports n_pixels as None.
It is reported as unknown rather than as n_x * n_y on purpose. Every other
reader's n_spectra counts spectra present, cheaply -- imzML the length of
its coordinate list, Bruker a SQL count -- and filling this one in with the
raster size would make the same field mean two different things. The raster is
already reported, as grid_dims.
A reader built for conversion is unaffected and still counts every pixel.
Pixel size¶
Derived as Raster Size (um) / ImagePixels, which for the reference file is
512 µm over 512 pixels: 1.00 µm, matching what the operator selected.
The header also carries Raster Size Calibration (1.550 here). Thyra
deliberately does not apply it: it trims the scan generator so the raster
achieves the requested pitch, rather than scaling the field of view.
Multiplying by it would inflate every coordinate by 55%. Override with
pixel_size_um= if a future instrument configuration needs it; the source is
recorded in uns["format_specific"]["pixel_size_source"].
Acquisition characteristics¶
Some properties of this format are worth knowing before analysis:
- Extremely sparse. 18.4 million events over 262,144 pixels and 863,670 mass channels is 64 occupied channels per pixel on average -- 0.007% density. Counts are low: the mean pixel holds 70 ions, the maximum 491.
- Frames are summed.
NoFrames: 100means 100 passes over the raster, concatenated in the stream and summed into one image. Thyra does not separate them; there is no per-frame counter in the event record. - Scatter raster.
Raster Pattern=Scattermeans pixels are not acquired in raster order, which is why every event carries its own coordinates. - Empty pixels are possible. The reference file has 2 pixels that recorded
no ions at all. These are skipped by
iter_spectrarather than emitted as empty spectra.
Not yet exercised on real data¶
These paths are implemented from the format and covered by tests, but those
tests build synthetic .raw files. No real acquisition using them has been
through the reader, so treat them as untested rather than working:
- Mosaic acquisitions (block 6 tile origins). The reference file declares
Number Of Tiles X=8, Y=2but has mosaic mode off and contains no block 6, so the grid is sized from the tile origins actually encountered, not from the declared counts. - MS/MS acquisitions, where events move to block id 8.
- Depth profiling / 3D. The reference file has
NoSputCycles: 0. Thyra treats PHI data as 2D.
The gap is data, not effort: each was straightforward to write and is impossible to confirm without a file that exercises it. Everything Thyra is verified on was verified the same way — against the instrument's own exports — and the same would be done here.
Have one of these acquisitions?
Please open an issue
if you need one of these supported. A single representative file is enough
to turn any of them from untested into verified, and a .bif6 export
alongside it makes the check exact. Say which acquisition mode you used and
roughly how large the file is; the data itself does not have to be attached
to the issue.
Related exports¶
PHI's software writes .bif6 sidecar files -- a total-ion image, and a peak
list of operator-selected mass windows. Thyra does not read these, because
they are derived products: the total ion image is reproducible bit-exactly from
the .raw, and the peak images to 99.7%. They remain useful as ground truth
when validating a change to the reader.