Skip to content

Commit 6a666fe

Browse files
committed
timezone aware
1 parent 383525d commit 6a666fe

File tree

4 files changed

+202
-22
lines changed

4 files changed

+202
-22
lines changed

heudiconv/bids.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
remove_suffix,
3232
save_json,
3333
set_readonly,
34-
strptime_micr,
34+
strptime_bids,
3535
update_json,
3636
)
3737

@@ -952,17 +952,16 @@ def select_fmap_from_compatible_groups(
952952
k for k, v in acq_times_fmaps.items() if v == first_acq_time
953953
][0]
954954
elif criterion == "Closest":
955-
json_acq_time = strptime_micr(
955+
json_acq_time = strptime_bids(
956956
acq_times[
957957
# remove session folder and '.json', add '.nii.gz':
958958
remove_suffix(remove_prefix(json_file, sess_folder + op.sep), ".json")
959959
+ ".nii.gz"
960-
],
961-
"%Y-%m-%dT%H:%M:%S[.%f]",
960+
]
962961
)
963962
# differences in acquisition time (abs value):
964963
diff_fmaps_acq_times = {
965-
k: abs(strptime_micr(v, "%Y-%m-%dT%H:%M:%S[.%f]") - json_acq_time)
964+
k: abs(strptime_bids(v) - json_acq_time)
966965
for k, v in acq_times_fmaps.items()
967966
}
968967
min_diff_acq_times = sorted(diff_fmaps_acq_times.values())[0]

heudiconv/dicoms.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
get_typed_attr,
3333
load_json,
3434
set_readonly,
35-
strptime_micr,
35+
strptime_dcm_da_tm,
36+
strptime_dcm_dt
3637
)
3738

3839
if TYPE_CHECKING:
@@ -535,19 +536,12 @@ def get_datetime_from_dcm(dcm_data: dcm.FileDataset) -> Optional[datetime.dateti
535536
3. SeriesDate & SeriesTime (0008,0021); (0008,0031)
536537
537538
"""
538-
acq_date = dcm_data.get("AcquisitionDate", "").strip()
539-
acq_time = dcm_data.get("AcquisitionTime", "").strip()
540-
if acq_date and acq_time:
541-
return strptime_micr(acq_date + acq_time, "%Y%m%d%H%M%S[.%f]")
542-
543-
acq_dt = dcm_data.get("AcquisitionDateTime", "").strip()
544-
if acq_dt:
545-
return strptime_micr(acq_dt, "%Y%m%d%H%M%S[.%f]")
546-
547-
series_date = dcm_data.get("SeriesDate", "").strip()
548-
series_time = dcm_data.get("SeriesTime", "").strip()
549-
if series_date and series_time:
550-
return strptime_micr(series_date + series_time, "%Y%m%d%H%M%S[.%f]")
539+
if "AcquisitionDate" in dcm_data and "AcquisitionTime" in dcm_data:
540+
return strptime_dcm_da_tm(dcm_data, "AcquisitionDate", "AcquisitionTime")
541+
if "AcquisitionDateTime" in dcm_data:
542+
return strptime_dcm_dt(dcm_data, "AcquisitionDateTime")
543+
if "SeriesDate" in dcm_data and "SeriesTime" in dcm_data:
544+
return strptime_dcm_da_tm(dcm_data, "SeriesDate", "SeriesTime")
551545
return None
552546

553547

heudiconv/tests/test_utils.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import IO, Any
1010
from unittest.mock import patch
1111

12+
import pydicom as dcm
1213
import pytest
1314

1415
from heudiconv.utils import (
@@ -23,6 +24,9 @@
2324
remove_suffix,
2425
save_json,
2526
strptime_micr,
27+
strptime_bids,
28+
strptime_dcm_da_tm,
29+
strptime_dcm_dt,
2630
update_json,
2731
)
2832

@@ -188,6 +192,89 @@ def test_strptime_micr(dt: str, fmt: str) -> None:
188192
)
189193

190194

195+
@pytest.mark.parametrize(
196+
"dt, fmt",
197+
[
198+
("2023-04-02T11:47:09", "%Y-%m-%dT%H:%M:%S"),
199+
("2023-04-02T11:47:09.0", "%Y-%m-%dT%H:%M:%S.%f"),
200+
("2023-04-02T11:47:09.000000", "%Y-%m-%dT%H:%M:%S.%f"),
201+
("2023-04-02T11:47:09.1", "%Y-%m-%dT%H:%M:%S.%f"),
202+
("2023-04-02T11:47:09-0900", "%Y-%m-%dT%H:%M:%S%z"),
203+
("2023-04-02T11:47:09.1-0900", "%Y-%m-%dT%H:%M:%S.%f%z"),
204+
],
205+
)
206+
def test_strptime_bids(dt: str, fmt: str) -> None:
207+
target = datetime.strptime(dt, fmt)
208+
assert strptime_bids(dt) == target
209+
210+
211+
@pytest.mark.parametrize(
212+
"tm, tm_fmt",
213+
[
214+
("114709.1", "%H%M%S.%f"),
215+
("114709", "%H%M%S"),
216+
("1147", "%H%M"),
217+
("11", "%H"),
218+
],
219+
)
220+
@pytest.mark.parametrize(
221+
"offset, offset_fmt",
222+
[
223+
("-0900", "%z"),
224+
('', ''),
225+
],
226+
)
227+
def test_strptime_dcm_da_tm(tm: str, tm_fmt: str, offset: str, offset_fmt: str) -> None:
228+
da = "20230402"
229+
da_fmt = "%Y%m%d"
230+
target = datetime.strptime(da + tm + offset, da_fmt + tm_fmt + offset_fmt)
231+
ds = dcm.dataset.Dataset()
232+
ds["AcquisitionDate"] = dcm.DataElement("AcquisitionDate","DA",da)
233+
ds["AcquisitionTime"] = dcm.DataElement("AcquisitionTime", "TM", tm)
234+
if offset:
235+
ds[(0x0008, 0x0201)] = dcm.DataElement((0x0008, 0x0201), "SH", offset)
236+
assert strptime_dcm_da_tm(ds, "AcquisitionDate", "AcquisitionTime") == target
237+
238+
239+
@pytest.mark.parametrize(
240+
"dt, dt_fmt",
241+
[
242+
("20230402114709.1-0400", "%Y%m%d%H%M%S.%f%z"),
243+
("20230402114709-0400", "%Y%m%d%H%M%S%z"),
244+
("202304021147-0400", "%Y%m%d%H%M%z"),
245+
("2023040211-0400", "%Y%m%d%H%z"),
246+
("20230402-0400", "%Y%m%d%z"),
247+
("202304-0400", "%Y%m%z"),
248+
("2023-0400", "%Y%z"),
249+
("20230402114709.1", "%Y%m%d%H%M%S.%f"),
250+
("20230402114709", "%Y%m%d%H%M%S"),
251+
("202304021147", "%Y%m%d%H%M"),
252+
("2023040211", "%Y%m%d%H"),
253+
("20230402", "%Y%m%d"),
254+
("202304", "%Y%m"),
255+
("2023", "%Y"),
256+
],
257+
)
258+
@pytest.mark.parametrize(
259+
"offset, offset_fmt",
260+
[
261+
("-0900", "%z"),
262+
('', ''),
263+
],
264+
)
265+
def test_strptime_dcm_dt(dt: str, dt_fmt: str, offset: str, offset_fmt: str) -> None:
266+
target = None
267+
if dt_fmt[-2:] == "%z" and offset:
268+
target = datetime.strptime(dt, dt_fmt)
269+
else:
270+
target = datetime.strptime(dt + offset, dt_fmt + offset_fmt)
271+
ds = dcm.dataset.Dataset()
272+
ds["AcquisitionDateTime"] = dcm.DataElement("AcquisitionDateTime","DT", dt)
273+
if offset:
274+
ds[(0x0008, 0x0201)] = dcm.DataElement((0x0008, 0x0201), "SH", offset)
275+
assert strptime_dcm_dt(ds, "AcquisitionDateTime") == target
276+
277+
191278
def test_remove_suffix() -> None:
192279
"""
193280
Test utils.remove_suffix()

heudiconv/utils.py

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections.abc import Callable
55
from collections.abc import Mapping as MappingABC
66
import copy
7-
from datetime import datetime
7+
import datetime
88
from glob import glob
99
import hashlib
1010
import json
@@ -13,6 +13,8 @@
1313
import os
1414
import os.path as op
1515
from pathlib import Path
16+
import pydicom as dcm
17+
from pydicom.tag import TagType
1618
import re
1719
import shutil
1820
import stat
@@ -662,7 +664,7 @@ def get_datetime(date: str, time: str, *, microseconds: bool = True) -> str:
662664
# add dummy microseconds if not available for strptime to parse
663665
time += ".000000"
664666
td = time + ":" + date
665-
datetime_str = datetime.strptime(td, "%H%M%S.%f:%Y%m%d").isoformat()
667+
datetime_str = datetime.datetime.strptime(td, "%H%M%S.%f:%Y%m%d").isoformat()
666668
if not microseconds:
667669
datetime_str = datetime_str.split(".", 1)[0]
668670
return datetime_str
@@ -686,7 +688,105 @@ def strptime_micr(date_string: str, fmt: str) -> datetime:
686688
fmt = fmt[: -len(optional_micr)]
687689
if re.search(r"\.\d+$", date_string):
688690
fmt += ".%f"
689-
return datetime.strptime(date_string, fmt)
691+
return datetime.datetime.strptime(date_string, fmt)
692+
693+
694+
def datetime_utc_offset(datetime_obj: datetime, utc_offset: str):
695+
"""set the datetime's tzinfo by parsing an utc offset string"""
696+
sign, hours, minutes = re.match(r"([+\-]?)(\d{2})(\d{2})", utc_offset).groups()
697+
sign = -1 if sign == '-' else 1
698+
hours, minutes = int(hours), int(minutes)
699+
tzinfo = datetime.timezone(sign * datetime.timedelta(hours=hours, minutes=minutes))
700+
return datetime_obj.replace(tzinfo=tzinfo)
701+
702+
def strptime(datetime_string: str, fmts: list[str]) -> datetime:
703+
r"""
704+
Try datetime.strptime on a list of formats returning the first successful attempt.
705+
706+
Parameters
707+
----------
708+
datetime_string: str
709+
Datetime string to parse
710+
fmts: list[str]
711+
List of format strings
712+
"""
713+
datetime_str = datetime_string.strip()
714+
for fmt in fmts:
715+
try:
716+
#return datetime.datetime.strptime(datetime_str, fmt)
717+
retval = datetime.datetime.strptime(datetime_str, fmt)
718+
print(retval)
719+
return retval
720+
except ValueError:
721+
pass
722+
raise ValueError(f"Unable to parse datetime string: {datetime_str}")
723+
724+
def strptime_bids(datetime_string: str) -> datetime:
725+
r"""
726+
Create a datetime object from a bids datetime string.
727+
728+
Parameters
729+
----------
730+
date_string: str
731+
Datetime string to parse
732+
"""
733+
# https://bids-specification.readthedocs.io/en/stable/common-principles.html#units
734+
fmts = ["%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"]
735+
datetime_obj = strptime(datetime_string, fmts)
736+
return datetime_obj
737+
738+
def strptime_dcm_da_tm(dcm_data: dcm.Dataset, da_tag: TagType, tm_tag: TagType) -> datetime:
739+
r"""
740+
Create a datetime object from a dicom DA tag and TM tag.
741+
742+
Parameters
743+
----------
744+
dcm_data : dcm.FileDataset
745+
DICOM with header, e.g., as read by pydicom.dcmread.
746+
Objects with __getitem__ and have those keys with values properly formatted may also work
747+
da_tag: str
748+
Dicom tag with DA value representation
749+
tm_tag: str
750+
Dicom tag with TM value representation
751+
"""
752+
# https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html
753+
date_str = dcm_data[da_tag].value
754+
fmts = ["%Y%m%d",]
755+
date = strptime(date_str, fmts)
756+
757+
time_str = dcm_data[tm_tag].value
758+
fmts = ["%H", "%H%M", "%H%M%S", "%H%M%S.%f"]
759+
time = strptime(time_str, fmts)
760+
761+
datetime_obj = datetime.datetime.combine(date.date(), time.time())
762+
763+
if (0x0008, 0x0201) in dcm_data:
764+
utc_offset = dcm_data[0x0008, 0x0201].value
765+
datetime_obj = datetime_utc_offset(datetime_obj, utc_offset) if utc_offset else datetime_obj
766+
return datetime_obj
767+
768+
def strptime_dcm_dt(dcm_data: dcm.Dataset, dt_tag: TagType) -> datetime:
769+
r"""
770+
Create a datetime object from a dicom DT tag.
771+
772+
Parameters
773+
----------
774+
dcm_data : dcm.FileDataset
775+
DICOM with header, e.g., as read by pydicom.dcmread.
776+
Objects with __getitem__ and have those keys with values properly formatted may also work
777+
da_tag: str
778+
Dicom tag with DT value representation
779+
"""
780+
# https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html
781+
datetime_str = dcm_data.get(dt_tag)
782+
fmts = ["%Y%z", "%Y%m%z", "%Y%m%d%z", "%Y%m%d%H%z", "%Y%m%d%H%M%z", "%Y%m%d%H%M%S%z", "%Y%m%d%H%M%S.%f%z",
783+
"%Y", "%Y%m", "%Y%m%d", "%Y%m%d%H", "%Y%m%d%H%M", "%Y%m%d%H%M%S", "%Y%m%d%H%M%S.%f"]
784+
datetime_obj = strptime(datetime_str, fmts)
785+
786+
if not datetime_obj.tzinfo and (0x0008, 0x0201) in dcm_data:
787+
utc_offset = dcm_data[0x0008, 0x0201].value
788+
datetime_obj = datetime_utc_offset(datetime_obj, utc_offset) if utc_offset else datetime_obj
789+
return datetime_obj
690790

691791

692792
def remove_suffix(s: str, suf: str) -> str:

0 commit comments

Comments
 (0)