Skip to content

Hitmaps and photon rate

A hitmap is the per-pixel occupancy of the sensor — how often each macropixel registered a photon over the acquisition. How that is measured depends on which Kelpie program wrote the data, so there are two modes, matched to what unpack actually returns.

The two modes

mode="timestamp" — the ORT program

Every frame, each macropixel reports the timestamp of the first photon it saw (from one of its four micropixels); the later photons are not read out. This is a single first-arrival time per macropixel. It is not an array-wide "who fired first" ranking, and one macropixel's timestamp is not comparable to another's.

The hitmap therefore counts, per pixel, the number of frames carrying a valid timestamp — i.e. how often that macropixel fired. Validity is time_series > 0: unpack leaves non-fired slots at <= 0, so the threshold follows from the decoding and is not a tunable.

photon_counts is not a photon count in ORT

In this mode unpack's photon_counts is not a count — those bits are the low bits of the coarse timestamp (see unpack). Validity must come from time_series, never from photon_counts.

mode="count" — the S*C / ORC programs

No timestamp is recorded; the data holds per-pixel photon counts, and the hitmap sums those over every frame. This is the same quantity DCR accumulates before dividing by the acquisition time.

Photon rate

The raw map is counts (or occupancy). The rate is that divided by the wall-clock duration of the acquisition, the same way in both modes:

hits / (nframes * n_files * cycle)

in Hz — photons per second of experiment. Every frame is one acquisition cycle, so nframes * n_files * cycle is the elapsed time of the run.

The cycle depends on the firmware

Two versions, selected with firmware_version (core.timing.resolve_cycle_time):

firmware_version cycle notes
"short_exposure" (default) always 9 µs the photon-sensitive exposure inside the cycle is 50–500 ns, typically 100 ns
"long_exposure" exp_time_s + 9 µs you set X, the firmware adds 9 µs. exp_time_s is required

"short_window" / "full_window" are accepted as aliases of the two, since that is what resolve_live_time and DCR call them.

frame_rate_cnt.txt is not used, on purpose

The acquisition exe writes a frame_rate_cnt.txt beside the data. Nothing in dapkel reads it: it is produced by an exe we do not control, so no analysis result is allowed to depend on it. The cycle length is a property of the firmware and the requested exposure, and Kelpie_run.m states it directly: exp_time_s = 0 → 9 µs, 1e-6 → 10 µs, 2e-6 → 11 µs.

It is worth knowing what the counter says, though, because it disagrees with the nominal figure. It is a 200 MHz tick count over the whole run, latched by the firmware after the acquisition reports done, and across every sample folder on disk it fits

ticks = nframes * N + 138 + window/5ns

exactly — the 138 (690 ns) a fixed per-run overhead, the exposure window appearing once per run rather than per frame, and N the frame period. N comes out at 1937–1954 ticks = 9.685–9.770 µs, so the real cycle is 685–770 ns per frame longer than the nominal 9 µs, and a rate normalised by 9 µs runs ~7.8 % high. N does not track the window (a 50 → 500 ns window moves it by 14 ticks, not 90), which is the direct evidence that the window sits inside the cycle rather than extending it. What the extra ~700 ns per frame is has not been established.

What the number means

Under timestamp mode only the first photon of a cycle is timestamped, so the map saturates at one firing per cycle — 111 kHz at a 9 µs cycle — and under-reports as it approaches that. It is a firing rate, not a corrected photon flux.

exposure_window is recorded and reported as a duty cycle but does not enter the rate. Dividing by it instead would give the rate during the exposure, which is this figure times cycle / exposure — 90× for 9 µs / 100 ns. Both are defensible quantities; this one is per second of experiment, which is what the printed line and the colorbar say.

nframes is yours to supply, not the file's

The rate is proportional to 1 / nframes, and the frame count cannot be trusted to the file. Every sample .bin is a whole power-of-two number of frames — 32 MiB (16 384) or 16 MiB (8 192) — which is not what a 10 000-frame acquisition should produce, so the relation between the number you enter and the file you get is not established; see io.frames_in_file.

Both this package's unpack and the reference kelpie_data_ddr3.m read only the first nframes frames, so passing the number you acquired with gives exactly the frames MATLAB gives. That is why nframes is a required argument everywhere and is stored in the sidecar: it is the acquisition setting, and the rate is only right if it matches what the camera actually recorded.

Compute once, plot many times

Analysis and plotting are decoupled. compute_and_save_hitmap writes the array to processed/ with a .meta.json sidecar recording the frame count, the firmware version and the resolved cycle; collect_and_plot_hitmap reads that back and never re-unpacks the binary data. Retrying a colormap costs a load, not a full re-read of the raw files. See Adding an analysis for the stage rules.

from dapkel.functions import hitmap_analysis as hm

# short-exposure firmware: 9 µs cycle, 100 ns exposure inside it
hm.compute_and_save_hitmap(path, 10_000, "timestamp", "ORT",
                           exposure_window=100e-9)

# long-exposure firmware: you set X, the firmware adds 9 µs
hm.compute_and_save_hitmap(path, 10_000, "timestamp", "ORT",
                           firmware_version="long_exposure", exp_time_s=91e-6)

hm.collect_and_plot_hitmap(path, "timestamp", "ORT")   # counts + rate figures

Because the sidecar stores the frame count and the cycle, a saved hitmap can still be turned into a rate months later without remembering the original call arguments. A hitmap saved before the sidecar carried them gets the counts figure and a note instead of a rate.

API

hitmap_analysis

Per-pixel sensor occupancy (hitmaps), in counts and in photon rate.

Two modes, matched to what 'unpack' returns:

* ``mode="timestamp"`` (``ORT``) - counts frames carrying a valid
  first-photon timestamp. At most one firing per macropixel per frame.
* ``mode="count"`` (``S*C``/``ORC``) - sums the per-pixel photon counts.

Both are turned into a photon rate the same way: divide by the total live (shutter-open) time of the acquisition, nframes * n_files * open_shutter_time_s - not the full ~9 us frame period. Only the open shutter window is photon-sensitive; normalising by the whole frame would dilute the rate by the frame/window ratio instead of reporting the flux actually seen during the exposure. See docs/guide/hitmap.md.

compute_hitmap

compute_hitmap(folder: str, nframes: int, mode: str = 'timestamp', tag: str = 'ORT', max_files: int | None = None) -> ndarray

Unpack the binary data and compute the (32, 32) hitmap.

Builds a single-readout hitmap from the '.bin' files matching tag.

Parameters:

Name Type Description Default
folder str

Path to the folder with the '.bin' data files.

required
nframes int

Number of frames stored in each '.bin' file.

required
mode str

'timestamp' for the ORT occupancy hitmap (per pixel, the number of frames with a valid first-photon timestamp), or 'count' for the accumulated photon-count hitmap (S*C / ORC count programs). The default is 'timestamp'.

'timestamp'
tag str

Filename fragment selecting the files (e.g. 'ORT', 'ORC', or a SPAD quadrant tag 'S0C'). Use '' to match every '.bin' file in the folder. The default is 'ORT'.

'ORT'
max_files int | None

Process at most this many files (after natural sorting). Useful for a quick preview over a large batch. The default is None (all files).

None

Returns:

Type Description
ndarray

The (32, 32) hitmap.

Source code in src/dapkel/functions/hitmap_analysis.py
def compute_hitmap(
    folder: str,
    nframes: int,
    mode: str = "timestamp",
    tag: str = "ORT",
    max_files: int | None = None,
) -> np.ndarray:
    """Unpack the binary data and compute the (32, 32) hitmap.

    Builds a single-readout hitmap from the '.bin' files matching ``tag``.

    Parameters
    ----------
    folder : str
        Path to the folder with the '.bin' data files.
    nframes : int
        Number of frames stored in each '.bin' file.
    mode : str, optional
        ``'timestamp'`` for the ORT occupancy hitmap (per pixel, the number
        of frames with a valid first-photon timestamp), or ``'count'`` for
        the accumulated photon-count hitmap (S*C / ORC count programs). The
        default is ``'timestamp'``.
    tag : str, optional
        Filename fragment selecting the files (e.g. ``'ORT'``, ``'ORC'``,
        or a SPAD quadrant tag ``'S0C'``). Use ``''`` to match every
        '.bin' file in the folder. The default is ``'ORT'``.
    max_files : int | None, optional
        Process at most this many files (after natural sorting). Useful
        for a quick preview over a large batch. The default is None (all
        files).

    Returns
    -------
    np.ndarray
        The (32, 32) hitmap.
    """
    files = io.find_bin_files(folder, tag)
    if max_files is not None:
        files = files[:max_files]
    return _accumulate_hitmap(
        files, mode, nframes=nframes, label=tag or mode
    )

compute_hitmap_64

compute_hitmap_64(folder: str, nframes: int, max_files: int | None = None) -> ndarray

Unpack the four S*C tags and assemble the (64, 64) count hitmap.

Accumulates the four SPAD tags' photon counts (mode='count') and interleaves them onto the full sensor. Only the count programs (S*C) tile this way; ORT/ORC are (32, 32) and use 'compute_hitmap'.

Parameters:

Name Type Description Default
folder str

Path to the folder with the S*C '.bin' data files.

required
nframes int

Number of frames stored in each '.bin' file.

required
max_files int | None

Process at most this many files per tag. The default is None.

None

Returns:

Type Description
ndarray

The (64, 64) accumulated-count hitmap for the full sensor.

Raises:

Type Description
FileNotFoundError

Raised when no '.bin' files for any of the four SPAD tags are found in the folder.

Source code in src/dapkel/functions/hitmap_analysis.py
def compute_hitmap_64(
    folder: str,
    nframes: int,
    max_files: int | None = None,
) -> np.ndarray:
    """Unpack the four S*C tags and assemble the (64, 64) count hitmap.

    Accumulates the four SPAD tags' photon counts (``mode='count'``) and
    interleaves them onto the full sensor. Only the count programs (S*C)
    tile this way; ORT/ORC are (32, 32) and use 'compute_hitmap'.

    Parameters
    ----------
    folder : str
        Path to the folder with the S*C '.bin' data files.
    nframes : int
        Number of frames stored in each '.bin' file.
    max_files : int | None, optional
        Process at most this many files per tag. The default is None.

    Returns
    -------
    np.ndarray
        The (64, 64) accumulated-count hitmap for the full sensor.

    Raises
    ------
    FileNotFoundError
        Raised when no '.bin' files for any of the four SPAD tags are
        found in the folder.
    """
    tag_files = {}
    for tag in _SPAD_LAYOUT:
        files = glob.glob(os.path.join(folder, f"*_{tag}*.bin"))
        if files:
            files = sorted(
                files,
                key=lambda fp: int(
                    "".join(filter(str.isdigit, os.path.basename(fp))) or "0"
                ),
            )
            tag_files[tag] = files[:max_files] if max_files else files
    if not tag_files:
        raise FileNotFoundError(
            "No S0C/S1C/S2C/S3C .bin files found in:\n  " + folder
        )

    quadrants = {
        tag: _accumulate_hitmap(files, "count", nframes=nframes, label=tag)
        for tag, files in tag_files.items()
    }
    return reduce.assemble_64(quadrants, _SPAD_LAYOUT)

plot_hitmap

plot_hitmap(hitmap: ndarray, cmap: str | None = None, *, clabel: str = 'hits', name: str = 'Hitmap', show_total: bool = True) -> Figure

Render a (32, 32)/(64, 64) map as a 2D image.

Used both for the raw counts/occupancy map and for the derived rate map (see 'collect_and_plot_hitmap'); the caller sets the colorbar label, title name and whether a total is meaningful.

Parameters:

Name Type Description Default
hitmap ndarray

Map to plot, e.g. from 'compute_hitmap'/'compute_hitmap_64', or a rate map (counts divided by the active acquisition time).

required
cmap str | None

Matplotlib colormap name. The default is None — use the active style's image.cmap.

None
clabel str

Colorbar label / units (e.g. 'counts', 'cps'). The default is 'hits'.

'hits'
name str

Map name used in the title (e.g. 'Hitmap', 'Photon rate'). The default is 'Hitmap'.

'Hitmap'
show_total bool

Include the summed total in the title. Meaningful for counts, not for a rate map. The default is True.

True

Returns:

Type Description
Figure

The generated figure.

Source code in src/dapkel/functions/hitmap_analysis.py
def plot_hitmap(
    hitmap: np.ndarray,
    cmap: str | None = None,
    *,
    clabel: str = "hits",
    name: str = "Hitmap",
    show_total: bool = True,
) -> plt.Figure:
    """Render a (32, 32)/(64, 64) map as a 2D image.

    Used both for the raw counts/occupancy map and for the derived rate map
    (see 'collect_and_plot_hitmap'); the caller sets the colorbar label,
    title name and whether a total is meaningful.

    Parameters
    ----------
    hitmap : np.ndarray
        Map to plot, e.g. from 'compute_hitmap'/'compute_hitmap_64', or a
        rate map (counts divided by the active acquisition time).
    cmap : str | None, optional
        Matplotlib colormap name. The default is None — use the active
        style's ``image.cmap``.
    clabel : str, optional
        Colorbar label / units (e.g. ``'counts'``, ``'cps'``). The default
        is ``'hits'``.
    name : str, optional
        Map name used in the title (e.g. ``'Hitmap'``, ``'Photon rate'``).
        The default is ``'Hitmap'``.
    show_total : bool, optional
        Include the summed total in the title. Meaningful for counts, not
        for a rate map. The default is True.

    Returns
    -------
    plt.Figure
        The generated figure.
    """
    rows, cols = hitmap.shape
    median, maximum, total = plots.map_stats(hitmap)

    stats = f"median {median:.3g}  max {maximum:.3g}"
    if show_total:
        stats += f"  total {total:.3g}"

    return plots.sensor_map(
        hitmap,
        title=f"{name}  {rows}×{cols}\n{stats}  [{clabel}]",
        clabel=clabel,
        cmap=cmap,
    )

compute_and_save_hitmap

compute_and_save_hitmap(path: str, nframes: int, mode: str = 'timestamp', tag: str = 'ORT', *, firmware_version: str = 'short_window', exp_time_s: float | None = None, open_shutter_time_s: float | None = 5e-07, max_files: int | None = None) -> str

Unpack the '.bin' files and save the (32, 32) hitmap to '.npy'.

Unpacks once and saves processed/<name>_<tag>_hitmap.npy plus a .meta.json sidecar recording the frame count and the live time, so 'collect_and_plot_hitmap' can later render the photon-rate map without re-unpacking.

Parameters:

Name Type Description Default
path str

Path to the folder with the '.bin' data files.

required
nframes int

Number of frames the acquisition was set to record per '.bin' file. Also recorded in the sidecar: the photon rate is hits / (nframes * n_files * open_shutter_time_s), so this is what sets the denominator. Do not infer it from the file size - the exe dumps a fixed, larger DDR3 region, so the file is bigger than the acquisition.

required
mode str

'timestamp' (ORT occupancy) or 'count' (S*C / ORC photon counts). See 'compute_hitmap'. The default is 'timestamp'.

'timestamp'
tag str

Filename fragment selecting the files. The default is 'ORT'.

'ORT'
firmware_version str

Which firmware took the data, i.e. how much of each ~9 µs frame is photon-sensitive: 'short_window' (the default) means only open_shutter_time_s is live; 'full_window' means the whole exp_time_s + 9 µs cycle is live. See 'core.timing.resolve_live_time'.

'short_window'
exp_time_s float | None

Legacy exposure time requested of firmware_version='full_window', in seconds; the firmware adds 9 µs to it. Required for firmware_version='full_window', ignored otherwise. The default is None.

None
open_shutter_time_s float | None

Photon-sensitive shutter window per frame, in seconds (50-500 ns on the short-window firmware). This is the rate's denominator - required for firmware_version='short_window'. The default is 500e-9 (500 ns).

5e-07
max_files int | None

Process at most this many files. The default is None (all files).

None

Returns:

Type Description
str

Path to the saved '.npy' hitmap.

Source code in src/dapkel/functions/hitmap_analysis.py
def compute_and_save_hitmap(
    path: str,
    nframes: int,
    mode: str = "timestamp",
    tag: str = "ORT",
    *,
    firmware_version: str = "short_window",
    exp_time_s: float | None = None,
    open_shutter_time_s: float | None = 500e-9,
    max_files: int | None = None,
) -> str:
    """Unpack the '.bin' files and save the (32, 32) hitmap to '.npy'.

    Unpacks once and saves ``processed/<name>_<tag>_hitmap.npy`` plus a
    ``.meta.json`` sidecar recording the frame count and the live time, so
    'collect_and_plot_hitmap' can later render the photon-rate map without
    re-unpacking.

    Parameters
    ----------
    path : str
        Path to the folder with the '.bin' data files.
    nframes : int
        Number of frames the acquisition was set to record per '.bin' file.
        Also recorded in the sidecar: the photon rate is
        ``hits / (nframes * n_files * open_shutter_time_s)``, so this is what sets the
        denominator. Do not infer it from the file size - the exe dumps a
        fixed, larger DDR3 region, so the file is bigger than the acquisition.
    mode : str, optional
        ``'timestamp'`` (ORT occupancy) or ``'count'`` (S*C / ORC photon
        counts). See 'compute_hitmap'. The default is ``'timestamp'``.
    tag : str, optional
        Filename fragment selecting the files. The default is ``'ORT'``.
    firmware_version : str, optional
        Which firmware took the data, i.e. how much of each ~9 µs frame is
        photon-sensitive: ``'short_window'`` (the default) means only
        ``open_shutter_time_s`` is live; ``'full_window'`` means the whole
        ``exp_time_s + 9 µs`` cycle is live. See
        'core.timing.resolve_live_time'.
    exp_time_s : float | None, optional
        Legacy exposure time requested of ``firmware_version='full_window'``,
        in seconds; the firmware adds 9 µs to it. Required for
        ``firmware_version='full_window'``, ignored otherwise. The default
        is None.
    open_shutter_time_s : float | None, optional
        Photon-sensitive shutter window per frame, in seconds (50-500 ns on
        the short-window firmware). This is the rate's denominator - required
        for ``firmware_version='short_window'``. The default is ``500e-9``
        (500 ns).
    max_files : int | None, optional
        Process at most this many files. The default is None (all files).

    Returns
    -------
    str
        Path to the saved '.npy' hitmap.
    """
    print(
        f"\n> > > Computing hitmap (mode='{mode}', tag='{tag or 'all'}') "
        "and saving the array < < <\n"
    )
    hitmap = compute_hitmap(path, nframes, mode, tag, max_files)
    print(
        f"  {mode} hitmap: total {hitmap.sum():.0f} hits  "
        f"median {np.median(hitmap):.0f}  max {hitmap.max():.0f}"
    )

    files = io.find_bin_files(path, tag)
    if max_files is not None:
        files = files[:max_files]
    meta = _acquisition_meta(
        files, mode, tag, nframes, firmware_version, exp_time_s, open_shutter_time_s
    )
    out_path = store.save_map(
        hitmap, path, kind=_KIND, tag=(tag or mode), meta=meta, quiet=True
    )
    print(f"\n> > > Hitmap saved to {out_path} < < <")
    return out_path

compute_and_save_hitmap_64

compute_and_save_hitmap_64(path: str, nframes: int, *, firmware_version: str = 'short_window', exp_time_s: float | None = None, open_shutter_time_s: float | None = 5e-07, max_files: int | None = None) -> str

Assemble and save the (64, 64) full-sensor count hitmap to '.npy'.

Like 'compute_and_save_hitmap' but for the four-quadrant (64, 64) accumulated-count map (see 'compute_hitmap_64'); saved as <name>_64_hitmap.npy in processed, with a .meta.json sidecar (frame count per SPAD tag; each quadrant is acquired for the same duration) for the rate map.

Parameters:

Name Type Description Default
path str

Path to the folder with the S*C '.bin' data files.

required
nframes int

Number of frames the acquisition was set to record per '.bin' file.

required
firmware_version str

'short_window' (default, only open_shutter_time_s is live) or 'full_window' (exp_time_s + 9 µs, entirely live); see 'compute_and_save_hitmap'.

'short_window'
exp_time_s float | None

Legacy exposure time requested of firmware_version='full_window', in seconds. The default is None.

None
open_shutter_time_s float | None

Photon-sensitive shutter window per frame, in seconds - the rate's denominator; see 'compute_and_save_hitmap'. The default is 500e-9 (500 ns).

5e-07
max_files int | None

Process at most this many files per tag. The default is None.

None

Returns:

Type Description
str

Path to the saved '.npy' hitmap.

Source code in src/dapkel/functions/hitmap_analysis.py
def compute_and_save_hitmap_64(
    path: str,
    nframes: int,
    *,
    firmware_version: str = "short_window",
    exp_time_s: float | None = None,
    open_shutter_time_s: float | None = 500e-9,
    max_files: int | None = None,
) -> str:
    """Assemble and save the (64, 64) full-sensor count hitmap to '.npy'.

    Like 'compute_and_save_hitmap' but for the four-quadrant (64, 64)
    accumulated-count map (see 'compute_hitmap_64'); saved as
    ``<name>_64_hitmap.npy`` in ``processed``, with a ``.meta.json``
    sidecar (frame count per SPAD tag; each quadrant is acquired for the
    same duration) for the rate map.

    Parameters
    ----------
    path : str
        Path to the folder with the S*C '.bin' data files.
    nframes : int
        Number of frames the acquisition was set to record per '.bin' file.
    firmware_version : str, optional
        ``'short_window'`` (default, only ``open_shutter_time_s`` is live) or
        ``'full_window'`` (``exp_time_s`` + 9 µs, entirely live); see
        'compute_and_save_hitmap'.
    exp_time_s : float | None, optional
        Legacy exposure time requested of ``firmware_version='full_window'``,
        in seconds. The default is None.
    open_shutter_time_s : float | None, optional
        Photon-sensitive shutter window per frame, in seconds - the rate's
        denominator; see 'compute_and_save_hitmap'. The default is ``500e-9``
        (500 ns).
    max_files : int | None, optional
        Process at most this many files per tag. The default is None.

    Returns
    -------
    str
        Path to the saved '.npy' hitmap.
    """
    print("\n> > > Computing (64, 64) full-sensor hitmap and saving < < <\n")
    hitmap = compute_hitmap_64(path, nframes, max_files)
    print(
        f"  counts hitmap: total {hitmap.sum():.0f} hits  "
        f"median {np.median(hitmap):.0f}  max {hitmap.max():.0f}"
    )

    # Frame count from one SPAD tag (all four span the same duration).
    tag_files: list[str] = []
    for tag in _SPAD_LAYOUT:
        tag_files = io.find_bin_files(path, f"_{tag}")
        if tag_files:
            break
    if max_files is not None:
        tag_files = tag_files[:max_files]
    meta = _acquisition_meta(
        tag_files,
        "count",
        "64",
        nframes,
        firmware_version,
        exp_time_s,
        open_shutter_time_s,
    )
    out_path = store.save_map(
        hitmap, path, kind=_KIND, tag="64", meta=meta, quiet=True
    )
    print(f"\n> > > Hitmap saved to {out_path} < < <")
    return out_path

collect_and_plot_hitmap

collect_and_plot_hitmap(path: str | None = None, mode: str = 'timestamp', tag: str = 'ORT', *, npy_path: str | None = None, cmap: str | None = None, rate: bool = True) -> ndarray

Load a saved hitmap '.npy' and save its hitmap plot(s).

Reads the saved array - no re-unpacking - and writes two figures to results/hitmap: the counts/occupancy map and the photon-rate map, hits / (nframes * n_files * open_shutter_time_s) in Hz. Both modes normalise the same way, by the live (shutter-open) time of the acquisition; the derivation is in docs/guide/hitmap.md. The frame count and live time come from the '.meta.json' sidecar written at stage 1.

Locate the '.npy' from path or directly via npy_path, which ignores path.

Parameters:

Name Type Description Default
path str | None

Data-folder path used to locate the saved '.npy'. May be None when npy_path is given. Required otherwise.

None
mode str

Used with tag to build the default '.npy' name (the suffix is tag or mode) and to label the plots. The default is 'timestamp'.

'timestamp'
tag str

Used to build the default '.npy' name. The default is 'ORT'.

'ORT'
npy_path str | None

Explicit path to a saved hitmap '.npy' to plot directly; when given, path / mode / tag are ignored (mode/units then come from the sidecar). The default is None.

None
cmap str | None

Matplotlib colormap name for the hitmaps. The default is None — use the active style's image.cmap.

None
rate bool

Also save the photon-rate hitmap. The default is True.

True

Returns:

Type Description
ndarray

The loaded hitmap (counts/occupancy).

Raises:

Type Description
FileNotFoundError

Raised when the '.npy' hitmap cannot be found.

ValueError

Raised when neither path nor npy_path is given.

Source code in src/dapkel/functions/hitmap_analysis.py
def collect_and_plot_hitmap(
    path: str | None = None,
    mode: str = "timestamp",
    tag: str = "ORT",
    *,
    npy_path: str | None = None,
    cmap: str | None = None,
    rate: bool = True,
) -> np.ndarray:
    """Load a saved hitmap '.npy' and save its hitmap plot(s).

    Reads the saved array - no re-unpacking - and writes two figures to
    ``results/hitmap``: the counts/occupancy map and the **photon-rate** map,
    ``hits / (nframes * n_files * open_shutter_time_s)`` in Hz. Both modes normalise the
    same way, by the live (shutter-open) time of the acquisition; the
    derivation is in ``docs/guide/hitmap.md``. The frame count and live time
    come from the '.meta.json' sidecar written at stage 1.

    Locate the '.npy' from ``path`` or directly via ``npy_path``, which
    ignores ``path``.

    Parameters
    ----------
    path : str | None, optional
        Data-folder path used to locate the saved '.npy'. May be None when
        ``npy_path`` is given. Required otherwise.
    mode : str, optional
        Used with ``tag`` to build the default '.npy' name (the suffix is
        ``tag or mode``) and to label the plots. The default is
        ``'timestamp'``.
    tag : str, optional
        Used to build the default '.npy' name. The default is ``'ORT'``.
    npy_path : str | None, optional
        Explicit path to a saved hitmap '.npy' to plot directly; when given,
        ``path`` / ``mode`` / ``tag`` are ignored (mode/units then come from
        the sidecar). The default is None.
    cmap : str | None, optional
        Matplotlib colormap name for the hitmaps. The default is None —
        use the active style's ``image.cmap``.
    rate : bool, optional
        Also save the photon-rate hitmap. The default is True.

    Returns
    -------
    np.ndarray
        The loaded hitmap (counts/occupancy).

    Raises
    ------
    FileNotFoundError
        Raised when the '.npy' hitmap cannot be found.
    ValueError
        Raised when neither ``path`` nor ``npy_path`` is given.
    """
    if npy_path is not None:
        npy_path = os.path.abspath(npy_path)
        # <root>/processed/<name>.npy -> the figures belong under <root>.
        root = os.path.dirname(os.path.dirname(npy_path))
        base = os.path.basename(npy_path).removesuffix(".npy")
    else:
        if path is None:
            raise ValueError(
                "Provide either path (to locate the saved hitmap) or npy_path."
            )
        root = path
        base = store.map_file_name(
            os.path.basename(os.path.normpath(path)), _KIND, tag or mode
        ).removesuffix(".npy")

    hitmap, meta = store.load_map(
        path, kind=_KIND, tag=(tag or mode), npy_path=npy_path
    )
    mode_eff = meta.get("mode", mode)
    counts_clabel = "photon counts" if mode_eff == "count" else "frames fired"
    print(
        f"  hitmap {hitmap.shape[0]}×{hitmap.shape[1]}: total "
        f"{hitmap.sum():.0f}  median {np.median(hitmap):.0f}  "
        f"max {hitmap.max():.0f}  [{counts_clabel}]"
    )

    # Figures go to 'results/hitmap'; the array stays in 'processed/'.
    results_dir = store.results_dir(root, _KIND)

    # 1) counts / occupancy map
    fig_counts = plot_hitmap(hitmap, cmap=cmap, clabel=counts_clabel)
    store.save_figure(fig_counts, results_dir, f"{base}.png")
    plt.close(fig_counts)

    # 2) photon-rate map: hits / live seconds, i.e.
    #    hits / (nframes * n_files * open_shutter_time_s). Only the acquisition window
    #    inside each ~9 us frame is photon-sensitive (short-window firmware),
    #    so this is the photon flux during the exposure, not diluted by the
    #    readout dead time, for both modes.
    #
    #    In 'timestamp' mode only the *first* photon of a frame is
    #    timestamped, so the map saturates at one firing per open_shutter_time_s - e.g.
    #    2 MHz at a 500 ns window - and under-reports as it approaches that.
    if rate:
        live_time_s, frame_live_s = _live_seconds(meta)
        if live_time_s:
            rate_map = hitmap / live_time_s
            print(
                f"  photon rate: {meta.get('total_frames')} frames x "
                f"{frame_live_s * 1e9:.0f} ns shutter = "
                f"{live_time_s * 1e3:.3f} ms live time  "
                f"median {np.median(rate_map):.3g}  "
                f"max {rate_map.max():.3g} Hz"
            )
            fig_rate = plot_hitmap(
                rate_map,
                cmap=cmap,
                clabel="Hz",
                name="Photon rate",
                show_total=False,
            )
            store.save_figure(fig_rate, results_dir, f"{base}_rate.png")
            # plt.close(fig_rate)  # left open on purpose, for interactive use
        else:
            print(
                "  (no rate map — the sidecar has no frame count; recompute "
                "with compute_and_save_hitmap(..., nframes=<frames>))"
            )

    return hitmap