Skip to content

Commit 9b61c1b

Browse files
committed
add write functionality for custom ann types
1 parent b14a0dd commit 9b61c1b

File tree

3 files changed

+118
-48
lines changed

3 files changed

+118
-48
lines changed

README.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,13 @@ by https://www.physionet.org/physiotools/wag/annot-5.htm:
113113
- ``num``: The labelled annotation number.
114114
- ``aux``: The auxiliary information string for the annotation.
115115
- ``fs``: The sampling frequency of the record if contained in the annotation file.
116+
- ``custom_anntypes``: The custom annotation types defined in the annotation file. A dictionary with {key:value} corresponding to {anntype:description}. eg. {'#': 'lost connection', 'C': 'reconnected'}
116117

117118
Constructor function:
118119
::
119120

120121
def __init__(self, recordname, annotator, annsamp, anntype, subtype = None,
121-
chan = None, num = None, aux = None, fs = None)
122+
chan = None, num = None, aux = None, fs = None, custom_anntypes = None)
122123

123124
Call `showanncodes()` to see the list of standard annotation codes. Any text used to label annotations that are not one of these codes should go in the 'aux' field rather than the 'anntype' field.
124125

wfdb/readwrite/annotations.py

Lines changed: 112 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ class Annotation(object):
1919
- anntype: The annotation type according the the standard WFDB codes.
2020
- subtype: The marked class/category of the annotation.
2121
- chan: The signal channel associated with the annotations.
22-
- num: The labelled annotation number.
22+
- num: The labelled annotation number.
2323
- aux: The auxiliary information string for the annotation.
2424
- fs: The sampling frequency of the record if contained in the annotation file.
2525
- custom_anntypes: The custom annotation types defined in the annotation file.
26-
A dictionary with {key:value} corresponding to {anntype:description}.
26+
A dictionary with {key:value} corresponding to {anntype:description}.
2727
eg. {'#': 'lost connection', 'C': 'reconnected'}
2828
2929
Constructor function:
@@ -114,12 +114,13 @@ def checkfields(self):
114114
def checkfield(self, field):
115115

116116
# Non list/array fields
117-
if field in ['recordname', 'annotator', 'fs']:
117+
if field in ['recordname', 'annotator', 'fs', 'custom_anntypes']:
118118
# Check the field type
119119
if type(getattr(self, field)) not in annfieldtypes[field]:
120-
print(annfieldtypes[field])
121-
raise TypeError('The '+field+' field must be one of the above types.')
122-
120+
if len(annfieldtypes[field]>1):
121+
raise TypeError('The '+field+' field must be one of the following types:', annfieldtypes)
122+
else:
123+
raise TypeError('The '+field+' field must be the following type:', annfieldtypes[0])
123124
# Field specific checks
124125
if field == 'recordname':
125126
# Allow letters, digits, hyphens, and underscores.
@@ -134,6 +135,25 @@ def checkfield(self, field):
134135
elif field == 'fs':
135136
if self.fs <=0:
136137
raise ValueError('The fs field must be a non-negative number')
138+
elif field == 'custom_anntypes':
139+
# All key/values must be strings
140+
for key in self.custom_anntypes.keys():
141+
if type(key)!= str:
142+
raise ValueError('All custom_anntypes keys must be strings')
143+
if len(key)>1:
144+
raise ValueError('All custom_anntypes keys must be single characters')
145+
# Discourage (but not prevent) using existing codes
146+
if key in annsyms.values():
147+
print('It is discouraged to define the custom annotation code: '+key+' that already has an entry in the wfdb library.')
148+
print('To see existing annotation codes and their meanings, call: showanncodes(). Continuing...')
149+
150+
for value in self.custom_anntypes.values():
151+
if type(key)!= str:
152+
raise ValueError('All custom_anntypes dictionary values must be strings')
153+
# No pointless characters
154+
acceptedstring = re.match('[\w -]+', value)
155+
if not acceptedstring or acceptedstring.string != value:
156+
raise ValueError('custom_anntypes dictionary values must only contain alphanumerics, spaces, underscores, and dashes')
137157

138158
else:
139159
fielditem = getattr(self, field)
@@ -147,8 +167,7 @@ def checkfield(self, field):
147167
if field in ['annsamp','anntype']:
148168
for item in fielditem:
149169
if type(item) not in annfieldtypes[field]:
150-
print("All elements of the '", field, "' field must be one of the following types:")
151-
print(annfieldtypes[field])
170+
print("All elements of the '"+field+"' field must be one of the following types:", annfieldtypes[field])
152171
print("All elements must be present")
153172
raise Exception()
154173
else:
@@ -170,11 +189,12 @@ def checkfield(self, field):
170189
if max(sampdiffs) > 2147483648:
171190
raise ValueError('WFDB annotation files cannot store sample differences greater than 2**31')
172191
elif field == 'anntype':
173-
# Ensure all fields lie in standard WFDB annotation codes
174-
if set(self.anntype) - set(annsyms.values()) != set():
175-
print("The 'anntype' field contains items not encoded in the WFDB annotation library.")
192+
# Ensure all fields lie in standard WFDB annotation codes or custom codes
193+
if set(self.anntype) - set(annsyms.values()).union() != set():
194+
print("The 'anntype' field contains items not encoded in the WFDB library, or in this object's custom defined anntypes.")
176195
print('To see the valid annotation codes call: showanncodes()')
177196
print('To transfer non-encoded anntype items into the aux field call: self.type2aux()')
197+
print("To define custom codes, set the custom_anntypes field as a dictionary with format: {custom anntype character:description}")
178198
raise Exception()
179199
elif field == 'subtype':
180200
# signed character
@@ -204,20 +224,23 @@ def checkfieldcohesion(self):
204224
# Write an annotation file
205225
def wrannfile(self):
206226

207-
# If there is an fs, write it
227+
# Calculate the fs bytes to write if present
208228
if self.fs is not None:
209229
fsbytes = fs2bytes(self.fs)
210230
else:
211-
fsbytes = None
231+
fsbytes = []
232+
233+
# Calculate the custom_anntypes bytes to write if present
234+
if self.custom_anntypes is not None:
235+
cabytes = ca2bytes(self.custom_anntypes)
236+
else:
237+
cabytes = []
212238

213239
# Calculate the main bytes to write
214240
databytes = self.fieldbytes()
215241

216-
# Combine all bytes to write including file terminator
217-
if fsbytes is not None:
218-
databytes = np.concatenate((fsbytes, databytes, np.array([0,0]).astype('u1')))
219-
else:
220-
databytes = np.concatenate((databytes, np.array([0,0]).astype('u1')))
242+
# Combine all bytes to write: fs (if any), custom annotations(if any), main content, file terminator
243+
databytes = np.concatenate((fsbytes, cabytes, databytes, np.array([0,0]).astype('u1')))
221244

222245
# Write the file
223246
with open(self.recordname+'.'+self.annotator, 'wb') as f:
@@ -308,6 +331,51 @@ def fs2bytes(fs):
308331

309332
return np.array(databytes).astype('u1')
310333

334+
# Calculate the bytes written to the annotation file for the custom_anntypes field
335+
def ca2bytes(custom_anntypes):
336+
337+
# The start wrapper: '0 NOTE length AUX ## annotation type definitions'
338+
headbytes = [0,88,30,252,35,35,32,97,110,110,111,116,97,116,105,111,110,32,116
339+
121,112,101,32,100,101,102,105,110,105,116,105,111,110,115]
340+
341+
# The end wrapper: '0 NOTE length AUX ## end of definitions' followed by SKIP -1, +1
342+
tailbytes = [0,88,21,252,35,35,32,101,110,100,32,111,102,32,100,101,102,105,110,
343+
105,116,105,111,110,115,0,0,236,255,255,255,255,1,0]
344+
345+
# Annotation codes range from 0-49.
346+
freenumbers = list(set(range(50)) - set(annsyms.keys()))
347+
348+
if len(custom_anntypes) > len(freenumbers):
349+
raise Exception('There can only be a maximum of '+len(freenumbers)+' custom annotation codes.')
350+
351+
# Allocate a number to each custom anntype.
352+
# List sublists: [number, code, description]
353+
writecontent = []
354+
for i in range(len(custom_anntypes)):
355+
writecontent.append([i,custom_anntypes.keys()[i],custom_anntypes.values()[i]])
356+
357+
custombytes = [customcode2bytes(triplet) for triplet in writecontent]
358+
custombytes = [item for sublist in custombytes for item in sublist]
359+
360+
return np.array(headbytes+custombytes+tailbytes).astype('u1')
361+
362+
# Convert triplet of [number, codesymbol (character), description] into annotation bytes
363+
# Helper function to ca2bytes
364+
def customcode2bytes(c_triplet):
365+
366+
# Structure: 0, NOTE, len(aux), AUX, codenumber, space, codesymbol, space, description, (0 null if necessary)
367+
# Remember, aux string includes 'number(s)<space><symbol><space><description>''
368+
annbytes = [0, 88, len(c_triplet[2]) + 3 + len(str(c_triplet[0])), 252] + [ord(c) for c in str(c_triplet[0])] \
369+
+ [32] + ord(c_triplet[1]) + [32] + [ord(c) for c in c_triplet[2]]
370+
371+
if len(annbytes) % 2:
372+
annbytes.append(0)
373+
374+
return annbytes
375+
376+
377+
378+
311379
# Convert an annotation field into bytes to write
312380
def field2bytes(field, value):
313381

@@ -791,6 +859,7 @@ def proc_special_types(annsamp,anntype,num,subtype,chan,aux):
791859

792860
# Annotation mnemonic symbols for the 'anntype' field as specified in annot.c
793861
# from wfdb software library 10.5.24. At this point, several values are blank.
862+
# Commented out values are present in original file but have no meaning.
794863
annsyms = {
795864
0: ' ', # not-QRS (not a getann/putann codedict) */
796865
1: 'N', # normal beat */
@@ -807,9 +876,9 @@ def proc_special_types(annsamp,anntype,num,subtype,chan,aux):
807876
12: '/', # paced beat */
808877
13: 'Q', # unclassifiable beat */
809878
14: '~', # signal quality change */
810-
15: '[15]',
879+
# 15: '[15]',
811880
16: '|', # isolated QRS-like artifact */
812-
17: '[17]',
881+
# 17: '[17]',
813882
18: 's', # ST change */
814883
19: 'T', # T-wave change */
815884
20: '*', # systole */
@@ -834,20 +903,21 @@ def proc_special_types(annsamp,anntype,num,subtype,chan,aux):
834903
39: '(', # waveform onset */
835904
40: ')', # waveform end */
836905
41: 'r', # R-on-T premature ventricular contraction */
837-
42: '[42]',
838-
43: '[43]',
839-
44: '[44]',
840-
45: '[45]',
841-
46: '[46]',
842-
47: '[47]',
843-
48: '[48]',
844-
49: '[49]',
906+
# 42: '[42]',
907+
# 43: '[43]',
908+
# 44: '[44]',
909+
# 45: '[45]',
910+
# 46: '[46]',
911+
# 47: '[47]',
912+
# 48: '[48]',
913+
# 49: '[49]',
845914
}
846915
# Reverse ann symbols for mapping symbols back to numbers
847916
revannsyms = {v: k for k, v in annsyms.items()}
848917

849918
# Annotation codes for 'anntype' field as specified in ecgcodes.h from
850-
# wfdb software library 10.5.24
919+
# wfdb software library 10.5.24. Commented out values are present in
920+
# original file but have no meaning.
851921
anncodes = {
852922
0: 'NOTQRS', # not-QRS (not a getann/putann codedict) */
853923
1: 'NORMAL', # normal beat */
@@ -864,9 +934,9 @@ def proc_special_types(annsamp,anntype,num,subtype,chan,aux):
864934
12: 'PACE', # paced beat */
865935
13: 'UNKNOWN', # unclassifiable beat */
866936
14: 'NOISE', # signal quality change */
867-
15: '',
937+
# 15: '',
868938
16: 'ARFCT', # isolated QRS-like artifact */
869-
17: '',
939+
# 17: '',
870940
18: 'STCH', # ST change */
871941
19: 'TCH', # T-wave change */
872942
20: 'SYSTOLE', # systole */
@@ -890,23 +960,24 @@ def proc_special_types(annsamp,anntype,num,subtype,chan,aux):
890960
38: 'PFUS', # fusion of paced and normal beat */
891961
39: 'WFON', # waveform onset */
892962
40: 'WFOFF', # waveform end */
893-
41: 'RONT', # R-on-T premature ventricular contraction */
894-
42: '',
895-
43: '',
896-
44: '',
897-
45: '',
898-
46: '',
899-
47: '',
900-
48: '',
901-
49: ''
963+
41: 'RONT', # R-on-T premature ventricular contraction */
964+
# 42: '',
965+
# 43: '',
966+
# 44: '',
967+
# 45: '',
968+
# 46: '',
969+
# 47: '',
970+
# 48: '',
971+
# 49: ''
902972
}
903973

904974
# Mapping annotation symbols to the annotation codes
905975
# For printing/user guidance
906976
symcodes = pd.DataFrame({'Ann Symbol': list(annsyms.values()), 'Ann Code Meaning': list(anncodes.values())})
907977
symcodes = symcodes.set_index('Ann Symbol', list(annsyms.values()))
908978

909-
annfields = ['recordname', 'annotator', 'annsamp', 'anntype', 'num', 'subtype', 'chan', 'aux', 'fs', 'custom_anntypes']
979+
# All annotation fields. Note: custom_anntypes placed first to check field before anntype
980+
annfields = ['recordname', 'annotator', 'custom_anntypes', 'annsamp', 'anntype', 'num', 'subtype', 'chan', 'aux', 'fs']
910981

911982
annfieldtypes = {'recordname': [str], 'annotator': [str], 'annsamp': _headers.inttypes,
912983
'anntype': [str], 'num':_headers.inttypes, 'subtype': _headers.inttypes,

wfdb/readwrite/records.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -298,19 +298,17 @@ def checkitemtype(item, field, allowedtypes, channels=None):
298298
# The field must exist for the channel
299299
if mustexist:
300300
if type(item[ch]) not in allowedtypes:
301-
print(allowedtypes)
302-
raise TypeError("Channel "+str(ch)+" of field: '"+field+"' must be one of the above types")
301+
raise TypeError("Channel "+str(ch)+" of field: '"+field+"' must be one of the following types:", allowedtypes)
302+
303303
# The field may be None for the channel
304304
else:
305305
if type(item[ch]) not in allowedtypes and item[ch] is not None:
306-
print(allowedtypes)
307-
raise TypeError("Channel "+str(ch)+" of field: '"+field+"' must be a 'None', or one of the above types")
306+
raise TypeError("Channel "+str(ch)+" of field: '"+field+"' must be a 'None', or one of the following types:", allowedtypes)
308307

309308
# Single scalar to check
310309
else:
311310
if type(item) not in allowedtypes:
312-
print(allowedtypes)
313-
raise TypeError("Field: '"+field+"' must be one of the above types")
311+
raise TypeError("Field: '"+field+"' must be one of the following types:", allowedtypes)
314312

315313

316314

0 commit comments

Comments
 (0)