From 8c472b04b94fd2b156564c3daf6a603869bfb81e Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 10 Jul 2026 17:12:36 +0100 Subject: [PATCH 1/2] fix date parsing for first axon version --- neo/rawio/axonrawio.py | 29 ++++++++++++++++++++-------- neo/test/rawiotest/test_axonrawio.py | 9 +++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/neo/rawio/axonrawio.py b/neo/rawio/axonrawio.py index 75457ce57..6884ccb28 100644 --- a/neo/rawio/axonrawio.py +++ b/neo/rawio/axonrawio.py @@ -677,24 +677,37 @@ def _parse_abf_v1(f, header_description): header["sProtocolPath"] = header["sProtocolPath"].replace(b"\\", b"/") # date and time - # lFileStartDate is a YYYYMMDD-packed integer, parsed the same way as uFileStartDate in ABF2. # A "no date" sentinel means there is no date to build, so fall back to rec_datetime=None. The - # field is signed, so the all-bits-set 0xFFFFFFFF sentinel is read as -1, and 0 is the unset - # value. Any other value is trusted and left to raise if genuinely out of range, so a real - # parsing error surfaces rather than being masked. + # lFileStartDate field is signed, so the all-bits-set 0xFFFFFFFF sentinel is read as -1, and 0 + # is the unset value. Any other value is trusted; a genuinely out-of-range date is left to + # raise rather than being masked. no_date_sentinels = (0, -1) if header["lFileStartDate"] in no_date_sentinels: header["rec_datetime"] = None else: - YY = int(header["lFileStartDate"] / 10000) - MM = int((header["lFileStartDate"] - YY * 10000) / 100) - DD = int(header["lFileStartDate"] - YY * 10000 - MM * 100) + # lFileStartDate packs the calendar date as decimal digits, but the packing changed across + # ABF1 versions: + # - newer files use YYYYMMDD (4-digit year), e.g. 20050611 -> 2005-06-11 + # - older files use YYMMDD (2-digit year), e.g. 180618 -> 2018-06-18 + # Detect the old form from the value rather than the version number (whose exact cutoff we + # cannot pin down): a real 4-digit year is >= 1000, so a year field below 100 is a 2-digit + # year. We assume such years are in the 2000s, as no ABF recording predates 2000. + date_as_integer = header["lFileStartDate"] + # The digits are laid out as [year][MM][DD], so month and day occupy the low four decimal + # places: dividing by 10_000 discards MMDD and leaves the year. + year = date_as_integer // 10_000 + year_is_two_digit = year < 100 + if year_is_two_digit: + date_as_integer += 2000 * 10_000 # shift a 2-digit year into the 2000s: 180618 -> 20180618 + year = date_as_integer // 10_000 + month = (date_as_integer // 100) % 100 # drop the two DD digits, keep the two MM digits + day = date_as_integer % 100 # the last two digits hh = int(header["lFileStartTime"] / 3600.0) mm = int((header["lFileStartTime"] - hh * 3600) / 60) ss = header["lFileStartTime"] - hh * 3600 - mm * 60 ms = int(np.mod(ss, 1) * 1e6) ss = int(ss) - header["rec_datetime"] = datetime.datetime(YY, MM, DD, hh, mm, ss, ms) + header["rec_datetime"] = datetime.datetime(year, month, day, hh, mm, ss, ms) return header diff --git a/neo/test/rawiotest/test_axonrawio.py b/neo/test/rawiotest/test_axonrawio.py index cb1f07a6f..dd3aa80d6 100644 --- a/neo/test/rawiotest/test_axonrawio.py +++ b/neo/test/rawiotest/test_axonrawio.py @@ -40,6 +40,15 @@ def test_v1_reads_real_acquisition_date(self): # microsecond is a rounding artifact, not a meaningful value to assert. self.assertEqual(rec_datetime.replace(microsecond=0), expected_datetime) + def test_v1_reads_two_digit_year_date(self): + # Old ABF1 files (before ~v1.65) pack the date as YYMMDD (2-digit year) rather than + # YYYYMMDD. lFileStartDate = 180618 is 2018-06-18, not year 18, so the 2-digit year must be + # expanded to the 2000s. + expected_datetime = datetime.datetime(2018, 6, 18, 17, 34, 27) + header = parse_axon_soup(self.get_local_path("axon/intracellular_data/abf1_episodic_empty_channel_name.abf")) + rec_datetime = header["rec_datetime"] + self.assertEqual(rec_datetime.replace(microsecond=0), expected_datetime) + def test_invalid_date_falls_back_to_none(self): # Some ABF files store an out-of-range / "no date" sentinel (e.g. 0xFFFFFFFF) # in the acquisition date header fields. The date is non-essential annotation, From d17bec82eea850bd1fa899f341aa8bd9cf07a9f4 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 14 Jul 2026 17:51:09 -0600 Subject: [PATCH 2/2] zach review --- neo/rawio/axonrawio.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/neo/rawio/axonrawio.py b/neo/rawio/axonrawio.py index a52d530bf..e5f2597c9 100644 --- a/neo/rawio/axonrawio.py +++ b/neo/rawio/axonrawio.py @@ -724,15 +724,19 @@ def _parse_abf_v1(f, header_description): # - older files use YYMMDD (2-digit year), e.g. 180618 -> 2018-06-18 # Detect the old form from the value rather than the version number (whose exact cutoff we # cannot pin down): a real 4-digit year is >= 1000, so a year field below 100 is a 2-digit - # year. We assume such years are in the 2000s, as no ABF recording predates 2000. + # year. The century is then ambiguous, so we disambiguate with the one hard invariant we + # have: a recording cannot be in the future. Map the 2-digit year to the 2000s, and fall + # back to the 1900s only if that lands past the current year. date_as_integer = header["lFileStartDate"] # The digits are laid out as [year][MM][DD], so month and day occupy the low four decimal # places: dividing by 10_000 discards MMDD and leaves the year. year = date_as_integer // 10_000 year_is_two_digit = year < 100 if year_is_two_digit: - date_as_integer += 2000 * 10_000 # shift a 2-digit year into the 2000s: 180618 -> 20180618 - year = date_as_integer // 10_000 + year += 2000 + if year > datetime.datetime.now().year: + year -= 100 # e.g. 98 -> 1998 rather than a not-yet-happened 2098 + date_as_integer = year * 10_000 + date_as_integer % 10_000 month = (date_as_integer // 100) % 100 # drop the two DD digits, keep the two MM digits day = date_as_integer % 100 # the last two digits hh = int(header["lFileStartTime"] / 3600.0)