Skip to content

Commit 63c987f

Browse files
committed
fix datetime conversion
1 parent 652d869 commit 63c987f

File tree

6 files changed

+525
-90
lines changed

6 files changed

+525
-90
lines changed

demo.ipynb

Lines changed: 371 additions & 31 deletions
Large diffs are not rendered by default.

tests/test_io.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,13 @@ def test_4b(self):
272272
assert record.__eq__(record_pb)
273273
assert record.__eq__(record_write)
274274

275-
# Format 12 multi-samples per frame and skew/Selected Duration/Selected Channels/Physical
276-
# Target file created with: rdsamp -r sample-data/03700181 -f 8 -t 128 -s 0
277-
# 2 -P | cut -f 2- > io-4c
275+
278276
def test_4c(self):
277+
"""
278+
Format 12 multi-samples per frame and skew/Selected Duration/Selected Channels/Physical
279+
Target file created with: rdsamp -r sample-data/03700181 -f 8 -t 128 -s 0
280+
2 -P | cut -f 2- > io-4c
281+
"""
279282
sig, fields = wfdb.rdsamp('sample-data/03700181',
280283
channels=[0, 2], sampfrom=1000, sampto=16000)
281284
sig_round = np.round(sig, decimals=8)

wfdb/io/_header.py

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import pdb
1212
int_types = (int, np.int64, np.int32, np.int16, np.int8)
13-
float_types = int_types + (float, np.float64, np.float32)
13+
float_types = (float, np.float64, np.float32) + int_types
1414

1515
"""
1616
WFDB field specifications for each field. The indexes are the field
@@ -123,7 +123,7 @@
123123
"(?P<n_sig>\d+)[ \t]*",
124124
"(?P<fs>\d*\.?\d*)/*(?P<counterfs>\d*\.?\d*)\(?(?P<base_counter>\d*\.?\d*)\)?[ \t]*",
125125
"(?P<sig_len>\d*)[ \t]*",
126-
"(?P<base_time>\d*:?\d{,2}:?\d{,2}\.?\d*)[ \t]*",
126+
"(?P<base_time>\d{,2}:?\d{,2}:?\d{,2}\.?\d{,6})[ \t]*",
127127
"(?P<base_date>\d{,2}/?\d{,2}/?\d{,4})"]))
128128

129129
# Signal Line Fields
@@ -422,7 +422,8 @@ def check_field_cohesion(self, rec_write_fields, sig_write_fields):
422422

423423
def wr_header_file(self, rec_write_fields, sig_write_fields, write_dir):
424424
"""
425-
Write a header file using the specified fields
425+
Write a header file using the specified fields. Converts Record
426+
attributes into appropriate wfdb format strings.
426427
427428
Parameters
428429
----------
@@ -442,12 +443,21 @@ def wr_header_file(self, rec_write_fields, sig_write_fields, write_dir):
442443
for field in RECORD_SPECS.index:
443444
# If the field is being used, add it with its delimiter
444445
if field in rec_write_fields:
445-
stringfield = str(getattr(self, field))
446-
# If fs is float, check whether it as an integer
446+
string_field = str(getattr(self, field))
447+
448+
# Certain fields need extra processing
449+
447450
if field == 'fs' and isinstance(self.fs, float):
448451
if round(self.fs, 8) == float(int(self.fs)):
449-
stringfield = str(int(self.fs))
450-
record_line += RECORD_SPECS.loc[field, 'delimiter'] + stringfield
452+
string_field = str(int(self.fs))
453+
elif field == 'base_time' and '.' in string_field:
454+
string_field = string_field.rstrip('0')
455+
elif field == 'base_date':
456+
string_field = '/'.join((string_field[8:],
457+
string_field[5:7],
458+
string_field[:4]))
459+
460+
record_line += RECORD_SPECS.loc[field, 'delimiter'] + string_field
451461

452462
header_lines = [record_line]
453463

@@ -478,17 +488,24 @@ def wr_header_file(self, rec_write_fields, sig_write_fields, write_dir):
478488

479489
class MultiHeaderMixin(BaseHeaderMixin):
480490
"""
481-
Mixin class with multi-segment header methods. Inherited by MultiRecord class.
491+
Mixin class with multi-segment header methods. Inherited by
492+
MultiRecord class.
482493
"""
483494

484-
# Set defaults for fields needed to write the header if they have defaults.
485-
# This is NOT called by rdheader. It is only called by the gateway wrsamp for convenience.
486-
# It is also not called by wrhea (this may be changed in the future) since
487-
# it is supposed to be an explicit function.
488-
489-
# Not responsible for initializing the
490-
# attribute. That is done by the constructor.
491495
def set_defaults(self):
496+
"""
497+
Set defaults for fields needed to write the header if they have
498+
defaults.
499+
500+
This is NOT called by rdheader. It is only called by the gateway
501+
wrsamp for convenience.
502+
503+
It is also not called by wrhea since it is supposed to be an
504+
explicit function.
505+
506+
Not responsible for initializing the
507+
attributes. That is done by the constructor.
508+
"""
492509
for field in self.get_write_fields():
493510
self.set_default(field)
494511

@@ -630,9 +647,33 @@ def get_sig_name(self):
630647

631648
return sig_name
632649

650+
def wfdb_strptime(time_string):
651+
"""
652+
Given a time string in an acceptable wfdb format, return
653+
a datetime.time object.
654+
655+
Valid formats: SS, MM:SS, HH:MM:SS, all with and without microsec.
656+
"""
657+
n_colons = time_string.count(':')
658+
659+
if n_colons == 0:
660+
time_fmt = '%S'
661+
elif n_colons == 1:
662+
time_fmt = '%M:%S'
663+
elif n_colons == 2:
664+
time_fmt = '%H:%M:%S'
665+
666+
if '.' in time_string:
667+
time_fmt += '.%f'
668+
669+
return datetime.datetime.strptime(time_string, time_fmt).time()
670+
633671

634-
# Read header file to get comment and non-comment lines
635672
def get_header_lines(record_name, pb_dir):
673+
"""
674+
Read a header file to get comment and non-comment lines
675+
676+
"""
636677
# Read local file
637678
if pb_dir is None:
638679
with open(record_name + ".hea", 'r') as fp:
@@ -682,16 +723,24 @@ def _read_record_line(record_line):
682723
# mostly None)
683724
if record_fields[field] == '':
684725
record_fields[field] = RECORD_SPECS.loc[field, 'read_default']
685-
# Typecast non-empty strings for numerical and date/time fields
726+
# Typecast non-empty strings for non-string (numerical/datetime)
727+
# fields
686728
else:
687-
if RECORD_SPECS.loc[field, 'allowed_types'] is int_types:
729+
if RECORD_SPECS.loc[field, 'allowed_types'] == int_types:
688730
record_fields[field] = int(record_fields[field])
689-
# fs may be read as float or int
690-
elif field == 'fs':
691-
fs = float(record_fields['fs'])
692-
if round(fs, 8) == float(int(fs)):
693-
fs = int(fs)
694-
record_fields['fs'] = fs
731+
elif RECORD_SPECS.loc[field, 'allowed_types'] == float_types:
732+
record_fields[field] = float(record_fields[field])
733+
# cast fs to an int if it is close
734+
if field == 'fs':
735+
fs = float(record_fields['fs'])
736+
if round(fs, 8) == float(int(fs)):
737+
fs = int(fs)
738+
record_fields['fs'] = fs
739+
elif field == 'base_time':
740+
record_fields['base_time'] = wfdb_strptime(record_fields['base_time'])
741+
elif field == 'base_date':
742+
record_fields['base_date'] = datetime.datetime.strptime(
743+
record_fields['base_date'], '%d/%m/%Y').date()
695744

696745
return record_fields
697746

wfdb/io/annotation.py

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -204,24 +204,29 @@ def get_label_fields(self):
204204
# Check the set fields of the annotation object
205205
def check_fields(self):
206206
# Check all set fields
207-
for field in ann_field_types:
207+
for field in ALLOWED_TYPES:
208208
if getattr(self, field) is not None:
209209
# Check the type of the field's elements
210210
self.check_field(field)
211211
return
212212

213213

214-
# Check a particular annotation field
214+
215215
def check_field(self, field):
216+
"""
217+
Check a particular annotation field
218+
"""
216219

217220
item = getattr(self, field)
218221

219-
if not isinstance(item, ann_field_types[field]):
220-
raise TypeError('The '+field+' field must be one of the following types:', ann_field_types[field])
222+
if not isinstance(item, ALLOWED_TYPES[field]):
223+
raise TypeError('The '+field+' field must be one of the following types:', ALLOWED_TYPES[field])
221224

222-
if field in int_ann_fields:
223-
if not hasattr(field, '__index__'):
224-
raise TypeError('The '+field+' field must have an integer-based dtype.')
225+
# Numerical integer annotation fields: sample, label_store, sub,
226+
# chan, num
227+
if ALLOWED_TYPES[field] == (np.ndarray):
228+
record.check_np_array(item=item, field_name=field, ndim=1,
229+
parent_class=np.integer, channel_num=None)
225230

226231
# Field specific checks
227232
if field == 'record_name':
@@ -286,13 +291,13 @@ def check_field(self, field):
286291
if not hasattr(label_store[i], '__index__'):
287292
raise TypeError('The label_store values of the '+field+' field must be integer-like')
288293

289-
if not isinstance(symbol[i], strtypes) or len(symbol[i]) not in [1,2,3]:
294+
if not isinstance(symbol[i], str_types) or len(symbol[i]) not in [1,2,3]:
290295
raise ValueError('The symbol values of the '+field+' field must be strings of length 1 to 3')
291296

292297
if bool(re.search('[ \t\n\r\f\v]', symbol[i])):
293298
raise ValueError('The symbol values of the '+field+' field must not contain whitespace characters')
294299

295-
if not isinstance(description[i], strtypes):
300+
if not isinstance(description[i], str_types):
296301
raise TypeError('The description values of the '+field+' field must be strings')
297302

298303
# Would be good to enfore this but existing garbage annotations have tabs and newlines...
@@ -304,7 +309,7 @@ def check_field(self, field):
304309
uniq_elements = set(item)
305310

306311
for e in uniq_elements:
307-
if not isinstance(e, strtypes):
312+
if not isinstance(e, str_types):
308313
raise TypeError('Subelements of the '+field+' field must be strings')
309314

310315
if field == 'symbol':
@@ -1580,22 +1585,69 @@ def rm_last(*args):
15801585
return [a[:-1] for a in args]
15811586
return
15821587

1583-
## ------------- /Reading Annotations ------------- ##
1588+
## ------------- Annotation Field Specifications ------------- ##
1589+
1590+
"""
1591+
WFDB field specifications for each field. The indexes are the field
1592+
names.
1593+
1594+
Parameters
1595+
----------
1596+
allowed_types:
1597+
Data types the field (or its elements) can be.
1598+
delimiter:
1599+
The text delimiter that precedes the field in the header file.
1600+
write_required:
1601+
Whether the field is required for writing a header (more stringent
1602+
than origin WFDB library).
1603+
read_default:
1604+
The default value for the field when read if any. Most fields do not
1605+
have a default. The reason for the variation, is that we do not want
1606+
to imply that some fields are present when they are not, unless the
1607+
field is essential. See the notes.
1608+
write_default:
1609+
The default value for the field to fill in before writing, if any.
1610+
1611+
Notes
1612+
-----
1613+
In the original WFDB package, certain fields have default values, but
1614+
not all of them. Some attributes need to be present for core
1615+
functionality, ie. baseline, whereas others are not essential, yet have
1616+
defaults, ie. base_time.
1617+
1618+
This inconsistency has likely resulted in the generation of incorrect
1619+
files, and general confusion. This library aims to make explicit,
1620+
whether certain fields are present in the file, by setting their values
1621+
to None if they are not written in, unless the fields are essential, in
1622+
which case an actual default value will be set.
1623+
1624+
The read vs write default values are different for 2 reasons:
1625+
1. We want to force the user to be explicit with certain important
1626+
fields when writing WFDB records fields, without affecting
1627+
existing WFDB headers when reading.
1628+
2. Certain unimportant fields may be dependencies of other
1629+
important fields. When writing, we want to fill in defaults
1630+
so that the user doesn't need to. But when reading, it should
1631+
be clear that the fields are missing.
1632+
1633+
"""
1634+
15841635

15851636
# Allowed types of each Annotation object attribute.
1586-
ann_field_types = {'record_name': (str), 'extension': (str), 'sample': (np.ndarray),
1587-
'symbol': (list, np.ndarray), 'subtype': (np.ndarray), 'chan': (np.ndarray),
1588-
'num': (np.ndarray), 'aux_note': (list, np.ndarray), 'fs': _header.float_types,
1589-
'label_store': (np.ndarray), 'description':(list, np.ndarray), 'custom_labels': (pd.DataFrame, list, tuple),
1637+
ALLOWED_TYPES = {'record_name': (str), 'extension': (str),
1638+
'sample': (np.ndarray,), 'symbol': (list, np.ndarray),
1639+
'subtype': (np.ndarray,), 'chan': (np.ndarray,),
1640+
'num': (np.ndarray,), 'aux_note': (list, np.ndarray),
1641+
'fs': _header.float_types, 'label_store': (np.ndarray,),
1642+
'description':(list, np.ndarray),
1643+
'custom_labels': (pd.DataFrame, list, tuple),
15901644
'contained_labels':(pd.DataFrame, list, tuple)}
15911645

1592-
strtypes = (str, np.str_)
1646+
str_types = (str, np.str_)
15931647

15941648
# Elements of the annotation label
15951649
ann_label_fields = ('label_store', 'symbol', 'description')
15961650

1597-
# Numerical integer annotation fields: sample, label_store, sub, chan, num
1598-
int_ann_fields = [field for field in ann_field_types if ann_field_types[field] == (np.ndarray)]
15991651

16001652
class AnnotationClass(object):
16011653
def __init__(self, extension, description, human_reviewed):

wfdb/io/record.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
# For wrsamp(), the field to use will be d_signal (which is allowed to be empty for 0 channel records).
21
# set_p_features and set_d_features use characteristics of the p_signal or d_signal field to fill in other header fields.
32
# These are separate from another method 'set_defaults' which the user may call to set default header fields
4-
# The check_field_cohesion() function will be called in wrheader which checks all the header fields.
5-
# The check_sig_cohesion() function will be called in wrsamp in wrdat to check the d_signal against the header fields.
63

74
import datetime
85
import multiprocessing
@@ -110,13 +107,6 @@ def check_field(self, field, required_channels='all'):
110107
elif field == 'sig_len':
111108
if self.sig_len < 0:
112109
raise ValueError('sig_len must be a non-negative integer')
113-
elif field == 'base_time':
114-
try:
115-
_ = datetime.datetime.strptime(self.base_time, '%H:%M:%S.%f')
116-
except ValueError:
117-
_ = datetime.datetime.strptime(self.base_time, '%H:%M/%S')
118-
elif field == 'base_date':
119-
_ = datetime.datetime.strptime(self.base_date, '%d/%m/%Y')
120110

121111
# Signal specification fields
122112
elif field in _header.SIGNAL_SPECS.index:
@@ -353,7 +343,9 @@ def wrsamp(self, expanded=False, write_dir=''):
353343

354344
def arrange_fields(self, channels, expanded=False):
355345
"""
356-
Arrange/edit object fields to reflect user channel and/or signal range input
346+
Arrange/edit object fields to reflect user channel and/or signal
347+
range input.
348+
357349
Account for case when signals are expanded
358350
"""
359351

@@ -793,9 +785,9 @@ def _check_item_type(item, field_name, allowed_types, expect_list=False,
793785
for ch in range(len(item)):
794786
# Check whether the field may be None
795787
if ch in required_channels:
796-
allowed_types_ch = allowed_types + (type(None),)
797-
else:
798788
allowed_types_ch = allowed_types
789+
else:
790+
allowed_types_ch = allowed_types + (type(None),)
799791

800792
if not isinstance(item[ch], allowed_types_ch):
801793
raise TypeError('Channel %d of field `%s` must be one of the following types:' % (ch, field_name),

wfdb/processing/qrs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -853,7 +853,6 @@ def sm(self, at_t):
853853
# from 1 to dt. 0 is never calculated.
854854
else:
855855
v = int(self.at(smt))
856-
print(smdt)
857856
for j in range(1, smdt):
858857
smtpj = self.at(smt + j)
859858
smtlj = self.at(smt - j)

0 commit comments

Comments
 (0)