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
verbatim.
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
linear_tof axis. Both halves matter.
Why linear_tof. Channels are laid out at a constant flight-time step, and
m/z goes as the square of time, so the m/z spacing grows as sqrt(m/z) -- the
linear_tof law exactly. Measured on the reference file: 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.
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).
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. A linear_tof axis of 0.01 Da at a reference of m/z 500 gives 189,191
bins over the full range and 2.3 to 4.3 samples per FWHM, which keeps CN⁻ and
C₂H₂⁻ -- 12.6 mDa apart, or 1.94 FWHM, genuinely resolved by this instrument --
5.5 bins apart.
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.
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.