Skip to content

Commit c1848a3

Browse files
authored
fix: Fix datetime extraction (#226)
1 parent 0a46b98 commit c1848a3

File tree

4 files changed

+40
-36
lines changed

4 files changed

+40
-36
lines changed

src/nd2/_ome.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def nd2_ome_metadata(
5656
rdr = cast("ModernReader", f._rdr)
5757
meta = f.metadata
5858
images = []
59-
acquisition_date = rdr._acquisition_date()
59+
acquisition_date = rdr._acquisition_datetime()
6060
uuid_ = f"urn:uuid:{uuid.uuid4()}"
6161
sizes = dict(f.sizes)
6262
n_positions = sizes.pop(AXIS.POSITION, 1)

src/nd2/_util.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import math
44
import re
5+
from contextlib import suppress
56
from datetime import datetime, timezone
67
from itertools import product
78
from typing import TYPE_CHECKING, BinaryIO, NamedTuple, cast
@@ -79,12 +80,18 @@ def is_new_format(path: str) -> bool:
7980
return fh.read(4) == NEW_HEADER_MAGIC
8081

8182

82-
def jdn_to_datetime(jdn: float, tz: timezone = timezone.utc) -> datetime:
83-
return datetime.fromtimestamp((jdn - 2440587.5) * 86400.0, tz)
83+
JDN_UNIX_EPOCH = 2440587.5
84+
SECONDS_PER_DAY = 86400
8485

8586

86-
def rgb_int_to_tuple(rgb: int) -> tuple[int, int, int]:
87-
return ((rgb & 255), (rgb >> 8 & 255), (rgb >> 16 & 255))
87+
def jdn_to_datetime(jdn: float, tz: timezone = timezone.utc) -> datetime:
88+
seconds_since_epoch = (jdn - JDN_UNIX_EPOCH) * SECONDS_PER_DAY
89+
# very negative values can cause OverflowError on Windows, and are meaningless
90+
dt = datetime.fromtimestamp(max(seconds_since_epoch, 0), tz)
91+
with suppress(ValueError, OSError):
92+
# astimezone() without arguments will use the system's local timezone
93+
return dt.astimezone()
94+
return dt
8895

8996

9097
# these are used has headers in the events() table
@@ -133,15 +140,6 @@ class VoxelSize(NamedTuple):
133140
]
134141

135142

136-
def parse_time(time_str: str) -> datetime:
137-
for fmt_str in TIME_FMT_STRINGS:
138-
try:
139-
return datetime.strptime(time_str, fmt_str)
140-
except ValueError:
141-
continue
142-
raise ValueError(f"Could not parse {time_str}") # pragma: no cover
143-
144-
145143
def convert_records_to_dict_of_lists(
146144
records: ListOfDicts, null_val: Any = float("nan")
147145
) -> DictOfLists:

src/nd2/index.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,22 @@
77
from concurrent.futures import ThreadPoolExecutor
88
from datetime import datetime
99
from pathlib import Path
10-
from typing import Any, Iterable, Iterator, Sequence, TypedDict, cast, no_type_check
10+
from typing import (
11+
TYPE_CHECKING,
12+
Any,
13+
Iterable,
14+
Iterator,
15+
Sequence,
16+
TypedDict,
17+
cast,
18+
no_type_check,
19+
)
1120

1221
import nd2
1322

23+
if TYPE_CHECKING:
24+
from nd2.readers._modern.modern_reader import ModernReader
25+
1426
try:
1527
import rich
1628

@@ -47,26 +59,29 @@ def index_file(path: Path) -> Record:
4759
with nd2.ND2File(path) as nd:
4860
if nd.is_legacy:
4961
software: dict = {}
50-
acquired: str | None = ""
62+
acquired: datetime | None = None
5163
binary = False
5264
else:
53-
software = nd._rdr._app_info() # type: ignore
54-
acquired = nd._rdr._acquisition_date() # type: ignore
65+
rdr = cast("ModernReader", nd._rdr)
66+
software = rdr._app_info()
67+
acquired = rdr._acquisition_datetime()
5568
binary = nd.binary_data is not None
5669

5770
stat = path.stat()
5871
exp = [(x.type, x.count) for x in nd.experiment]
5972
axes, shape = zip(*nd.sizes.items())
6073
if isinstance(acquired, datetime):
61-
acquired = acquired.strftime(TIME_FORMAT)
74+
acq_str = acquired.strftime(TIME_FORMAT)
75+
else:
76+
acq_str = ""
6277

6378
return Record(
6479
{
6580
"path": str(path.resolve()),
6681
"name": path.name,
6782
"version": ".".join(map(str, nd.version)),
6883
"kb": round(stat.st_size / 1000, 2),
69-
"acquired": acquired or "",
84+
"acquired": acq_str,
7085
"experiment": ";".join([f"{t}:{c}" for t, c in exp]),
7186
"dtype": str(nd.dtype),
7287
"shape": list(shape),

src/nd2/readers/_modern/modern_reader.py

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import warnings
55
import zlib
6+
from contextlib import suppress
67
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Sequence, cast
78

89
import numpy as np
@@ -528,23 +529,13 @@ def _app_info(self) -> dict:
528529
k = b"CustomDataVar|AppInfo_V1_0!"
529530
return self._decode_chunk(k) if k in self.chunkmap else {}
530531

531-
def _acquisition_date(self) -> datetime.datetime | str | None:
532-
"""Try to extract acquisition date.
533-
534-
A best effort is made to extract a datetime object from the date string,
535-
but if that fails, the raw string is returned. Use isinstance() to
536-
be safe.
537-
"""
538-
date = self.text_info().get("date")
539-
if date:
540-
try:
541-
return _util.parse_time(date)
542-
except ValueError:
543-
return date
544-
532+
def _acquisition_datetime(self) -> datetime.datetime | None:
533+
"""Try to extract acquisition date."""
545534
time = self._cached_global_metadata().get("time", {})
546-
jdn = time.get("absoluteJulianDayNumber")
547-
return _util.jdn_to_datetime(jdn) if jdn else None
535+
if jdn := time.get("absoluteJulianDayNumber"):
536+
with suppress(ValueError):
537+
return _util.jdn_to_datetime(jdn)
538+
return None
548539

549540
def binary_data(self) -> BinaryLayers | None:
550541
from nd2._binary import BinaryLayer, BinaryLayers, decode_binary_mask

0 commit comments

Comments
 (0)