diff --git a/neo/rawio/axonrawio.py b/neo/rawio/axonrawio.py index 451aa688a..e5f2597c9 100644 --- a/neo/rawio/axonrawio.py +++ b/neo/rawio/axonrawio.py @@ -710,24 +710,41 @@ 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. 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: + 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) 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 17f522c2c..60d11c347 100644 --- a/neo/test/rawiotest/test_axonrawio.py +++ b/neo/test/rawiotest/test_axonrawio.py @@ -78,6 +78,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,