Skip to content

Commit 0970b86

Browse files
authored
Merge pull request #780 from AlanKuurstra/timezone_aware
timezone aware
2 parents 92c49f0 + 84a0cfa commit 0970b86

File tree

4 files changed

+263
-31
lines changed

4 files changed

+263
-31
lines changed

heudiconv/bids.py

Lines changed: 4 additions & 6 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,18 +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)
966-
for k, v in acq_times_fmaps.items()
964+
k: abs(strptime_bids(v) - json_acq_time) for k, v in acq_times_fmaps.items()
967965
}
968966
min_diff_acq_times = sorted(diff_fmaps_acq_times.values())[0]
969967
selected_fmap_key = [

heudiconv/dicoms.py

Lines changed: 12 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:
@@ -539,19 +540,16 @@ def get_datetime_from_dcm(dcm_data: dcm.FileDataset) -> Optional[datetime.dateti
539540
3. SeriesDate & SeriesTime (0008,0021); (0008,0031)
540541
541542
"""
542-
acq_date = dcm_data.get("AcquisitionDate", "").strip()
543-
acq_time = dcm_data.get("AcquisitionTime", "").strip()
544-
if acq_date and acq_time:
545-
return strptime_micr(acq_date + acq_time, "%Y%m%d%H%M%S[.%f]")
546-
547-
acq_dt = dcm_data.get("AcquisitionDateTime", "").strip()
548-
if acq_dt:
549-
return strptime_micr(acq_dt, "%Y%m%d%H%M%S[.%f]")
550-
551-
series_date = dcm_data.get("SeriesDate", "").strip()
552-
series_time = dcm_data.get("SeriesTime", "").strip()
553-
if series_date and series_time:
554-
return strptime_micr(series_date + series_time, "%Y%m%d%H%M%S[.%f]")
543+
544+
def check_tag(x: str) -> bool:
545+
return x in dcm_data and dcm_data[x].value.strip()
546+
547+
if check_tag("AcquisitionDate") and check_tag("AcquisitionTime"):
548+
return strptime_dcm_da_tm(dcm_data, "AcquisitionDate", "AcquisitionTime")
549+
if check_tag("AcquisitionDateTime"):
550+
return strptime_dcm_dt(dcm_data, "AcquisitionDateTime")
551+
if check_tag("SeriesDate") and check_tag("SeriesTime"):
552+
return strptime_dcm_da_tm(dcm_data, "SeriesDate", "SeriesTime")
555553
return None
556554

557555

heudiconv/tests/test_utils.py

Lines changed: 95 additions & 7 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 (
@@ -22,6 +23,9 @@
2223
remove_prefix,
2324
remove_suffix,
2425
save_json,
26+
strptime_bids,
27+
strptime_dcm_da_tm,
28+
strptime_dcm_dt,
2529
strptime_micr,
2630
update_json,
2731
)
@@ -178,14 +182,98 @@ def test_get_datetime() -> None:
178182
],
179183
)
180184
def test_strptime_micr(dt: str, fmt: str) -> None:
185+
with pytest.warns(DeprecationWarning):
186+
target = datetime.strptime(dt, fmt)
187+
assert strptime_micr(dt, fmt) == target
188+
assert strptime_micr(dt, fmt + "[.%f]") == target
189+
assert strptime_micr(dt + ".0", fmt + "[.%f]") == target
190+
assert strptime_micr(dt + ".000000", fmt + "[.%f]") == target
191+
assert strptime_micr(dt + ".1", fmt + "[.%f]") == datetime.strptime(
192+
dt + ".1", fmt + ".%f"
193+
)
194+
195+
196+
@pytest.mark.parametrize(
197+
"dt, fmt",
198+
[
199+
("2023-04-02T11:47:09", "%Y-%m-%dT%H:%M:%S"),
200+
("2023-04-02T11:47:09.0", "%Y-%m-%dT%H:%M:%S.%f"),
201+
("2023-04-02T11:47:09.000000", "%Y-%m-%dT%H:%M:%S.%f"),
202+
("2023-04-02T11:47:09.1", "%Y-%m-%dT%H:%M:%S.%f"),
203+
("2023-04-02T11:47:09-0900", "%Y-%m-%dT%H:%M:%S%z"),
204+
("2023-04-02T11:47:09.1-0900", "%Y-%m-%dT%H:%M:%S.%f%z"),
205+
],
206+
)
207+
def test_strptime_bids(dt: str, fmt: str) -> None:
181208
target = datetime.strptime(dt, fmt)
182-
assert strptime_micr(dt, fmt) == target
183-
assert strptime_micr(dt, fmt + "[.%f]") == target
184-
assert strptime_micr(dt + ".0", fmt + "[.%f]") == target
185-
assert strptime_micr(dt + ".000000", fmt + "[.%f]") == target
186-
assert strptime_micr(dt + ".1", fmt + "[.%f]") == datetime.strptime(
187-
dt + ".1", fmt + ".%f"
188-
)
209+
assert strptime_bids(dt) == target
210+
211+
212+
@pytest.mark.parametrize(
213+
"tm, tm_fmt",
214+
[
215+
("114709.1", "%H%M%S.%f"),
216+
("114709", "%H%M%S"),
217+
("1147", "%H%M"),
218+
("11", "%H"),
219+
],
220+
)
221+
@pytest.mark.parametrize(
222+
"offset, offset_fmt",
223+
[
224+
("-0900", "%z"),
225+
("", ""),
226+
],
227+
)
228+
def test_strptime_dcm_da_tm(tm: str, tm_fmt: str, offset: str, offset_fmt: str) -> None:
229+
da = "20230402"
230+
da_fmt = "%Y%m%d"
231+
target = datetime.strptime(da + tm + offset, da_fmt + tm_fmt + offset_fmt)
232+
ds = dcm.dataset.Dataset()
233+
ds["AcquisitionDate"] = dcm.DataElement("AcquisitionDate", "DA", da)
234+
ds["AcquisitionTime"] = dcm.DataElement("AcquisitionTime", "TM", tm)
235+
if offset:
236+
ds[(0x0008, 0x0201)] = dcm.DataElement((0x0008, 0x0201), "SH", offset)
237+
assert strptime_dcm_da_tm(ds, "AcquisitionDate", "AcquisitionTime") == target
238+
239+
240+
@pytest.mark.parametrize(
241+
"dt, dt_fmt",
242+
[
243+
("20230402114709.1-0400", "%Y%m%d%H%M%S.%f%z"),
244+
("20230402114709-0400", "%Y%m%d%H%M%S%z"),
245+
("202304021147-0400", "%Y%m%d%H%M%z"),
246+
("2023040211-0400", "%Y%m%d%H%z"),
247+
("20230402-0400", "%Y%m%d%z"),
248+
("202304-0400", "%Y%m%z"),
249+
("2023-0400", "%Y%z"),
250+
("20230402114709.1", "%Y%m%d%H%M%S.%f"),
251+
("20230402114709", "%Y%m%d%H%M%S"),
252+
("202304021147", "%Y%m%d%H%M"),
253+
("2023040211", "%Y%m%d%H"),
254+
("20230402", "%Y%m%d"),
255+
("202304", "%Y%m"),
256+
("2023", "%Y"),
257+
],
258+
)
259+
@pytest.mark.parametrize(
260+
"offset, offset_fmt",
261+
[
262+
("-0900", "%z"),
263+
("", ""),
264+
],
265+
)
266+
def test_strptime_dcm_dt(dt: str, dt_fmt: str, offset: str, offset_fmt: str) -> None:
267+
target = None
268+
if dt_fmt[-2:] == "%z" and offset:
269+
target = datetime.strptime(dt, dt_fmt)
270+
else:
271+
target = datetime.strptime(dt + offset, dt_fmt + offset_fmt)
272+
ds = dcm.dataset.Dataset()
273+
ds["AcquisitionDateTime"] = dcm.DataElement("AcquisitionDateTime", "DT", dt)
274+
if offset:
275+
ds[(0x0008, 0x0201)] = dcm.DataElement((0x0008, 0x0201), "SH", offset)
276+
assert strptime_dcm_dt(ds, "AcquisitionDateTime") == target
189277

190278

191279
def test_remove_suffix() -> None:

heudiconv/utils.py

Lines changed: 152 additions & 4 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
@@ -35,6 +35,10 @@
3535
cast,
3636
overload,
3737
)
38+
import warnings
39+
40+
import pydicom as dcm
41+
from pydicom.tag import TagType
3842

3943
lgr = logging.getLogger(__name__)
4044

@@ -662,13 +666,13 @@ def get_datetime(date: str, time: str, *, microseconds: bool = True) -> str:
662666
# add dummy microseconds if not available for strptime to parse
663667
time += ".000000"
664668
td = time + ":" + date
665-
datetime_str = datetime.strptime(td, "%H%M%S.%f:%Y%m%d").isoformat()
669+
datetime_str = datetime.datetime.strptime(td, "%H%M%S.%f:%Y%m%d").isoformat()
666670
if not microseconds:
667671
datetime_str = datetime_str.split(".", 1)[0]
668672
return datetime_str
669673

670674

671-
def strptime_micr(date_string: str, fmt: str) -> datetime:
675+
def strptime_micr(date_string: str, fmt: str) -> datetime.datetime:
672676
r"""
673677
Decorate strptime while supporting optional [.%f] in the format at the end
674678
@@ -681,12 +685,156 @@ def strptime_micr(date_string: str, fmt: str) -> datetime:
681685
'.\d+' regex and not if it does not.
682686
"""
683687

688+
warnings.warn(
689+
"strptime_micr() is deprecated, please use strptime() instead.",
690+
DeprecationWarning,
691+
stacklevel=2,
692+
)
684693
optional_micr = "[.%f]"
685694
if fmt.endswith(optional_micr):
686695
fmt = fmt[: -len(optional_micr)]
687696
if re.search(r"\.\d+$", date_string):
688697
fmt += ".%f"
689-
return datetime.strptime(date_string, fmt)
698+
return datetime.datetime.strptime(date_string, fmt)
699+
700+
701+
def datetime_utc_offset(
702+
datetime_obj: datetime.datetime, utc_offset: str
703+
) -> datetime.datetime:
704+
"""set the datetime's tzinfo by parsing an utc offset string"""
705+
# https://dicom.innolitics.com/ciods/electromyogram/sop-common/00080201
706+
extract_offset = re.match(r"([+\-]?)(\d{2})(\d{2})", utc_offset)
707+
if extract_offset is None:
708+
raise ValueError(f"utc offset {utc_offset} is not valid")
709+
sign, hours, minutes = extract_offset.groups()
710+
sign = -1 if sign == "-" else 1
711+
hours, minutes = int(hours), int(minutes)
712+
tzinfo = datetime.timezone(sign * datetime.timedelta(hours=hours, minutes=minutes))
713+
return datetime_obj.replace(tzinfo=tzinfo)
714+
715+
716+
def strptime(datetime_string: str, fmts: list[str]) -> datetime.datetime:
717+
"""
718+
Try datetime.strptime on a list of formats returning the first successful attempt.
719+
720+
Parameters
721+
----------
722+
datetime_string: str
723+
Datetime string to parse
724+
fmts: list[str]
725+
List of format strings
726+
"""
727+
datetime_str = datetime_string
728+
for fmt in fmts:
729+
try:
730+
return datetime.datetime.strptime(datetime_str, fmt)
731+
except ValueError:
732+
pass
733+
raise ValueError(f"Unable to parse datetime string: {datetime_str}")
734+
735+
736+
def strptime_bids(datetime_string: str) -> datetime.datetime:
737+
"""
738+
Create a datetime object from a bids datetime string.
739+
740+
Parameters
741+
----------
742+
date_string: str
743+
Datetime string to parse
744+
"""
745+
# https://bids-specification.readthedocs.io/en/stable/common-principles.html#units
746+
fmts = [
747+
"%Y-%m-%dT%H:%M:%S.%f%z",
748+
"%Y-%m-%dT%H:%M:%S%z",
749+
"%Y-%m-%dT%H:%M:%S.%f",
750+
"%Y-%m-%dT%H:%M:%S",
751+
]
752+
datetime_obj = strptime(datetime_string, fmts)
753+
return datetime_obj
754+
755+
756+
def strptime_dcm_da_tm(
757+
dcm_data: dcm.Dataset, da_tag: TagType, tm_tag: TagType
758+
) -> datetime.datetime:
759+
"""
760+
Create a datetime object from a dicom DA tag and TM tag.
761+
762+
Parameters
763+
----------
764+
dcm_data : dcm.Dataset
765+
DICOM with header, e.g., as read by pydicom.dcmread.
766+
da_tag: str
767+
Dicom tag with DA value representation
768+
tm_tag: str
769+
Dicom tag with TM value representation
770+
"""
771+
# https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html
772+
date_str = dcm_data[da_tag].value
773+
fmts = [
774+
"%Y%m%d",
775+
]
776+
date = strptime(date_str, fmts)
777+
778+
time_str = dcm_data[tm_tag].value
779+
fmts = ["%H", "%H%M", "%H%M%S", "%H%M%S.%f"]
780+
time = strptime(time_str, fmts)
781+
782+
datetime_obj = datetime.datetime.combine(date.date(), time.time())
783+
784+
if utc_offset_dcm := dcm_data.get((0x0008, 0x0201)):
785+
utc_offset = utc_offset_dcm.value
786+
datetime_obj = (
787+
datetime_utc_offset(datetime_obj, utc_offset)
788+
if utc_offset
789+
else datetime_obj
790+
)
791+
return datetime_obj
792+
793+
794+
def strptime_dcm_dt(dcm_data: dcm.Dataset, dt_tag: TagType) -> datetime.datetime:
795+
"""
796+
Create a datetime object from a dicom DT tag.
797+
798+
Parameters
799+
----------
800+
dcm_data : dcm.FileDataset
801+
DICOM with header, e.g., as read by pydicom.dcmread.
802+
Objects with __getitem__ and have those keys with values properly formatted may also work
803+
da_tag: str
804+
Dicom tag with DT value representation
805+
"""
806+
# https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html
807+
datetime_str = dcm_data[dt_tag].value
808+
fmts = [
809+
"%Y%z",
810+
"%Y%m%z",
811+
"%Y%m%d%z",
812+
"%Y%m%d%H%z",
813+
"%Y%m%d%H%M%z",
814+
"%Y%m%d%H%M%S%z",
815+
"%Y%m%d%H%M%S.%f%z",
816+
"%Y",
817+
"%Y%m",
818+
"%Y%m%d",
819+
"%Y%m%d%H",
820+
"%Y%m%d%H%M",
821+
"%Y%m%d%H%M%S",
822+
"%Y%m%d%H%M%S.%f",
823+
]
824+
datetime_obj = strptime(datetime_str, fmts)
825+
826+
if utc_offset_dcm := dcm_data.get((0x0008, 0x0201)):
827+
if utc_offset := utc_offset_dcm.value:
828+
datetime_obj2 = datetime_utc_offset(datetime_obj, utc_offset)
829+
if datetime_obj.tzinfo and datetime_obj2 != datetime_obj:
830+
lgr.warning(
831+
"Unexpectedly previously parsed datetime %s contains zoneinfo which is different from the one obtained from DICOMs UTFOffset field: %s",
832+
datetime_obj,
833+
datetime_obj2,
834+
)
835+
else:
836+
datetime_obj = datetime_obj2
837+
return datetime_obj
690838

691839

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

0 commit comments

Comments
 (0)