diff --git a/neo/rawio/axonrawio.py b/neo/rawio/axonrawio.py index 451aa688a..774b280c3 100644 --- a/neo/rawio/axonrawio.py +++ b/neo/rawio/axonrawio.py @@ -232,15 +232,22 @@ def _parse_header(self): signal_channels = [] adc_nums = [] for chan_index, chan_id in enumerate(channel_ids): + # Name fields are fixed-width and right-padded with spaces (v1) or already trimmed (v2). + # Strip the padding but keep interior spaces (e.g. "IN 1"); errors="replace" so an odd + # byte can never crash the read. if version < 2.0: - name = info["sADCChannelName"][chan_id].replace(b" ", b"") + name = info["sADCChannelName"][chan_id].decode("utf-8", errors="replace").strip() units = safe_decode_units(info["sADCUnits"][chan_id]) adc_num = info["nADCPtoLChannelMap"][chan_id] elif version >= 2.0: ADCInfo = info["listADCInfo"][chan_id] - name = ADCInfo["ADCChNames"].replace(b" ", b"") + name = ADCInfo["ADCChNames"].decode("utf-8", errors="replace").strip() units = safe_decode_units(ADCInfo["ADCChUnits"]) adc_num = ADCInfo["nADCNum"] + if not name: + # A blank name leaves the channel unaddressable; fall back to a positional name so + # every channel keeps a usable id. + name = f"ch{chan_id}" adc_nums.append(adc_num) if info["nDataFormat"] == 0: @@ -705,9 +712,10 @@ def _parse_abf_v1(f, header_description): listTag.append(tag) header["listTag"] = listTag - # protocol name formatting - header["sProtocolPath"] = clean_string(header["sProtocolPath"]) - header["sProtocolPath"] = header["sProtocolPath"].replace(b"\\", b"/") + # protocol name formatting. Decode to str (like the channel names) so consumers get a plain + # string rather than a bytes value, whose str() would bake the "b'...'" repr into the path. + header["sProtocolPath"] = clean_string(header["sProtocolPath"]).decode("utf-8", errors="replace") + header["sProtocolPath"] = header["sProtocolPath"].replace("\\", "/") # date and time # lFileStartDate is a YYYYMMDD-packed integer, parsed the same way as uFileStartDate in ABF2. @@ -995,7 +1003,8 @@ def _parse_abf_v2(f, header_description): else: protocol[key] = np.array(val) header["protocol"] = protocol - header["sProtocolPath"] = strings[header["uProtocolPathIndex"]] + # Decode to str (like the channel names) so consumers get a plain string, not raw bytes. + header["sProtocolPath"] = strings[header["uProtocolPathIndex"]].decode("utf-8", errors="replace") # tags listTag = [] diff --git a/neo/test/rawiotest/test_axonrawio.py b/neo/test/rawiotest/test_axonrawio.py index 17f522c2c..963587113 100644 --- a/neo/test/rawiotest/test_axonrawio.py +++ b/neo/test/rawiotest/test_axonrawio.py @@ -30,6 +30,33 @@ def test_read_raw_protocol(self): reader.read_raw_protocol() + def test_empty_channel_name_gets_fallback(self): + # Some ABF files store a blank ADC channel name, which collapses to "" after space + # stripping and leaves the channel unaddressable by name. A positional fallback (ch{id}) + # must be used instead so every channel keeps a usable name. + path = self.get_local_path("axon/intracellular_data/abf1_episodic_empty_channel_name.abf") + reader = AxonRawIO(filename=path) + reader.parse_header() + names = list(reader.header["signal_channels"]["name"]) + self.assertNotIn("", names) + self.assertEqual(names, ["ch0"]) + + def test_channel_name_keeps_interior_space(self): + # Channel names are stripped of padding but keep interior spaces (e.g. "IN 1", not "IN1") + # and are returned as str. + reader = AxonRawIO(filename=self.get_local_path("axon/File_axon_7.abf")) + reader.parse_header() + names = list(reader.header["signal_channels"]["name"]) + self.assertEqual(names, ["IN 1"]) + + def test_protocol_path_decoded_to_str(self): + # String header fields should be decoded to str, not left as raw bytes; otherwise a caller + # doing str(value) gets the "b'...'" byte-literal repr baked into the path. + for fixture in ["axon/File_axon_1.abf", "axon/File_axon_2.abf"]: # v2 and v1 + reader = AxonRawIO(filename=self.get_local_path(fixture)) + reader.parse_header() + self.assertIsInstance(reader._axon_info["sProtocolPath"], str) + def test_integer_overflow_size_raises(self): # An ABF header that claims more samples than the file can hold must raise a # clear error instead of silently returning an overflowed signal size.