Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Versions follow [Semantic Versioning](https://semver.org) (`<major>.<minor>.<pat
- `report` accepts a `*_perievent.csv` written by `process peri-event` and writes `<stem>_report.html`, an interactive peri-event report: per-region traces as mean ± 95% CI or individual trials, filtered by `sdt_type` and optionally split by trial type, a clickable Allen CCF top view for choosing the region, and an all-regions grid. When the `*_metrics.csv` and `*_metrics-session.csv` from `process metrics` are stored together, the report also marks the onset, peak and offset of the mean trace with the per-trial mean ± SD or median and IQR of each time alongside (per-trial markers in the individual-trials view), plots per-trial metrics for the selected region, colours the atlas by a session metric and lists the session table. `-t/--trials` supplies `sdt_type` for peri-event files written without it ([#162](https://github.com/DuguidLab/mesoscopy/issues/162)).
- [Reports](https://docs.mesoscopy.org/how-to/reports/) how-to page covering the preprocessing, registration and peri-event reports ([#162](https://github.com/DuguidLab/mesoscopy/issues/162)).
- `report.perievent` module with `report_payload`, `perievent_cube`, `trial_types`, `atlas_outlines`, `metrics_paths`, `pack_float32` and `unpack_float32` ([#162](https://github.com/DuguidLab/mesoscopy/issues/162)).
- `start-time` command and `io.read_start_time`, printing a recording's start time as stored: the first `/timestamps` entry of an HDF5 file, or `session_start_time` of an NWB file ([#170](https://github.com/DuguidLab/mesoscopy/issues/170)).

## [0.13.1] - 2026-09-18

Expand Down
16 changes: 16 additions & 0 deletions src/mesoscopy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"register": ("mesoscopy.register:register_cmd", "Register recordings to an anatomical template."),
"report": ("mesoscopy.report:report_cmd", "Generate an HTML report for a mesoscopy processing step."),
"sample": ("mesoscopy:sample", "Sample an image frame from an HDF5 file and export it as a PNG."),
"start-time": ("mesoscopy:start_time", "Print a recording's start time."),
}


Expand Down Expand Up @@ -146,3 +147,18 @@ def sample(path, out_dir, index, crop=0, vmin=0, vmax=255, key="frames"):
else:
plt.imsave(outpath, d[index], vmin=vmin, vmax=vmax, cmap="jet")
click.echo(f"Saved sample at {outpath}")


@click.command("start-time")
@click.argument("path", type=click.Path(exists=True, dir_okay=False))
def start_time(path: str) -> None:
"""Print a recording's start time.

This is the first /timestamps entry of an HDF5 file, or the session_start_time of an NWB file, as stored.
""" # noqa: DOC501
from mesoscopy import io

try:
click.echo(io.read_start_time(path))
except ValueError as err:
raise click.ClickException(str(err)) from err
24 changes: 24 additions & 0 deletions src/mesoscopy/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,30 @@ def write_timestamps_aligned(path: str, timestamps: npt.ArrayLike, attrs: dict[s
return path


def read_start_time(path: str) -> str:
"""Read a recording's start time, as stored in the file.

For HDF5 files this is the first `/timestamps` entry; for NWB files it is `/session_start_time`.

Args:
path (str): Path to the HDF5 or NWB file.

Returns:
str: The start time.

Raises:
ValueError: If the file has no start time.
"""
key = "/session_start_time" if path.endswith(".nwb") else "/timestamps"
with h5py.File(path, "r") as f:
if key not in f or f[key].size == 0:
msg = f"{path} has no {key} to read a start time from."
raise ValueError(msg)
dataset = f[key]
value = dataset[()] if dataset.ndim == 0 else dataset[0]
return value.decode("utf-8") if isinstance(value, bytes) else str(value)


def read_points(path: str) -> dict[str, tuple[float, float]]:
"""Read a landmark points file.

Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import sys

import click
import h5py
import pytest
from click.testing import CliRunner

Expand Down Expand Up @@ -88,3 +89,32 @@ def test_no_eager_subcommands():

def test_unknown_subcommand_is_none():
assert mesoscopy.cli.get_command(click.Context(mesoscopy.cli), "nope") is None


def test_start_time_prints_first_timestamp(tmp_path):
path = str(tmp_path / "rec.h5")
with h5py.File(path, "w") as f:
f.create_dataset("/timestamps", data=[b"2025-05-20T14:42:39.6422784", b"2025-05-20T14:42:39.6622784"])

result = CliRunner().invoke(mesoscopy.cli, ["start-time", path])

assert result.exit_code == 0
assert result.output == "2025-05-20T14:42:39.6422784\n"


def test_start_time_nwb(nwbfile):
result = CliRunner().invoke(mesoscopy.cli, ["start-time", nwbfile])

assert result.exit_code == 0
assert result.output.startswith("2024-01-01T14:00:00")


def test_start_time_missing_timestamps_fails(tmp_path):
path = str(tmp_path / "rec.h5")
with h5py.File(path, "w") as f:
f.create_dataset("/F", data=[0.0])

result = CliRunner().invoke(mesoscopy.cli, ["start-time", path])

assert result.exit_code == 1
assert "no /timestamps" in result.output
38 changes: 38 additions & 0 deletions tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from uuid import uuid4

import dask.array as da
import h5py
import imageio.v2 as iio
import numpy as np
import pytest
Expand All @@ -26,6 +27,43 @@ def test_h5_write(tmp_path):
assert io.read_h5(path)["dataset2"][0][0] == 1


def test_read_start_time_raw_h5(raw_h5):
with h5py.File(raw_h5, "r") as f:
expected = f["/timestamps"][0].decode("utf-8")

assert io.read_start_time(raw_h5) == expected


def test_read_start_time_keeps_stored_string(tmp_path):
path = str(tmp_path / "rec_preprocessed.h5")
with h5py.File(path, "w") as f:
f.create_dataset("/timestamps", data=[b"2025-05-20T14:42:39.64227", b"2025-05-20T14:42:39.68227"], dtype="S25")

assert io.read_start_time(path) == "2025-05-20T14:42:39.64227"


def test_read_start_time_nwb(nwbfile):
assert io.read_start_time(nwbfile).startswith("2024-01-01T14:00:00")


def test_read_start_time_missing_timestamps_raises(tmp_path):
path = str(tmp_path / "rec.h5")
with h5py.File(path, "w") as f:
f.create_dataset("/F", data=np.zeros((2, 2, 2)))

with pytest.raises(ValueError, match="/timestamps"):
io.read_start_time(path)


def test_read_start_time_empty_timestamps_raises(tmp_path):
path = str(tmp_path / "rec.h5")
with h5py.File(path, "w") as f:
f.create_dataset("/timestamps", shape=(0,), dtype="S25")

with pytest.raises(ValueError, match="/timestamps"):
io.read_start_time(path)


def test_read_nwb(nwbfile):
_ = io.read_nwb(nwbfile)

Expand Down
Loading