sofar 0.3.1__py2.py3-none-any.whl → 1.1.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. sofar/__init__.py +13 -7
  2. sofar/io.py +423 -0
  3. sofar/sofa.py +1795 -0
  4. sofar/sofa_conventions/VERSION +1 -0
  5. sofar/sofa_conventions/conventions/FreeFieldDirectivityTF_1.1.csv +59 -0
  6. sofar/sofa_conventions/conventions/FreeFieldDirectivityTF_1.1.json +444 -0
  7. sofar/{conventions/source → sofa_conventions/conventions}/FreeFieldHRIR_1.0.csv +3 -3
  8. sofar/{conventions → sofa_conventions/conventions}/FreeFieldHRIR_1.0.json +3 -3
  9. sofar/{conventions/source → sofa_conventions/conventions}/FreeFieldHRTF_1.0.csv +2 -2
  10. sofar/{conventions → sofa_conventions/conventions}/FreeFieldHRTF_1.0.json +3 -3
  11. sofar/{conventions/source → sofa_conventions/conventions}/GeneralFIR-E_2.0.csv +2 -2
  12. sofar/{conventions → sofa_conventions/conventions}/GeneralFIR-E_2.0.json +2 -2
  13. sofar/{conventions/source/GeneralFIR_2.0.csv → sofa_conventions/conventions/GeneralFIR_1.0.csv} +2 -2
  14. sofar/{conventions/GeneralFIR_2.0.json → sofa_conventions/conventions/GeneralFIR_1.0.json} +2 -2
  15. sofar/{conventions/source/GeneralFIR_1.0.csv → sofa_conventions/conventions/GeneralSOS_1.0.csv} +11 -11
  16. sofar/{conventions/GeneralFIR_1.0.json → sofa_conventions/conventions/GeneralSOS_1.0.json} +48 -37
  17. sofar/{conventions/source → sofa_conventions/conventions}/GeneralTF-E_1.0.csv +3 -3
  18. sofar/{conventions → sofa_conventions/conventions}/GeneralTF-E_1.0.json +4 -4
  19. sofar/{conventions/source → sofa_conventions/conventions}/GeneralTF_1.0.csv +1 -1
  20. sofar/{conventions → sofa_conventions/conventions}/GeneralTF_1.0.json +1 -1
  21. sofar/{conventions/source → sofa_conventions/conventions}/GeneralTF_2.0.csv +4 -4
  22. sofar/{conventions → sofa_conventions/conventions}/GeneralTF_2.0.json +4 -4
  23. sofar/sofa_conventions/conventions/SimpleFreeFieldHRIR_1.0.csv +47 -0
  24. sofar/{conventions → sofa_conventions/conventions}/SimpleFreeFieldHRIR_1.0.json +1 -1
  25. sofar/{conventions/source → sofa_conventions/conventions}/SimpleFreeFieldHRSOS_1.0.csv +1 -1
  26. sofar/{conventions → sofa_conventions/conventions}/SimpleFreeFieldHRSOS_1.0.json +1 -1
  27. sofar/{conventions/source/SimpleFreeFieldHRTF_2.0.csv → sofa_conventions/conventions/SimpleFreeFieldHRTF_1.0.csv} +3 -3
  28. sofar/{conventions/SimpleFreeFieldHRTF_2.0.json → sofa_conventions/conventions/SimpleFreeFieldHRTF_1.0.json} +4 -4
  29. sofar/{conventions/source → sofa_conventions/conventions}/SimpleHeadphoneIR_1.0.csv +9 -9
  30. sofar/sofa_conventions/conventions/SimpleHeadphoneIR_1.0.json +396 -0
  31. sofar/{conventions/source → sofa_conventions/conventions}/SingleRoomMIMOSRIR_1.0.csv +18 -8
  32. sofar/{conventions → sofa_conventions/conventions}/SingleRoomMIMOSRIR_1.0.json +124 -50
  33. sofar/{conventions/source → sofa_conventions/conventions}/SingleRoomSRIR_1.0.csv +18 -8
  34. sofar/{conventions → sofa_conventions/conventions}/SingleRoomSRIR_1.0.json +124 -50
  35. sofar/{conventions/source → sofa_conventions/conventions/deprecated}/FreeFieldDirectivityTF_1.0.csv +2 -2
  36. sofar/{conventions → sofa_conventions/conventions/deprecated}/FreeFieldDirectivityTF_1.0.json +2 -2
  37. sofar/sofa_conventions/conventions/deprecated/MultiSpeakerBRIR_0.3.csv +48 -0
  38. sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldHRIR_0.4.csv +43 -0
  39. sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldHRIR_0.4.json +333 -0
  40. sofar/{conventions/source/SimpleFreeFieldHRIR_1.0.csv → sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_0.4.csv} +15 -18
  41. sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_0.4.json +340 -0
  42. sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_1.0.csv +44 -0
  43. sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_1.0.json +340 -0
  44. sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.1.csv +51 -0
  45. sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.1.json +396 -0
  46. sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.2.csv +51 -0
  47. sofar/{conventions/SimpleHeadphoneIR_1.0.json → sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.2.json} +3 -3
  48. sofar/sofa_conventions/conventions/deprecated/SingleRoomDRIR_0.2.csv +47 -0
  49. sofar/sofa_conventions/conventions/deprecated/SingleRoomDRIR_0.2.json +360 -0
  50. sofar/sofa_conventions/rules/deprecations.json +12 -0
  51. sofar/sofa_conventions/rules/rules.json +800 -0
  52. sofar/sofa_conventions/rules/unit_aliases.json +11 -0
  53. sofar/sofa_conventions/rules/upgrade.json +190 -0
  54. sofar/update_conventions.py +427 -0
  55. sofar/utils.py +315 -0
  56. {sofar-0.3.1.dist-info → sofar-1.1.0.dist-info}/AUTHORS.rst +1 -0
  57. sofar-1.1.0.dist-info/METADATA +89 -0
  58. sofar-1.1.0.dist-info/RECORD +75 -0
  59. {sofar-0.3.1.dist-info → sofar-1.1.0.dist-info}/WHEEL +1 -1
  60. {sofar-0.3.1.dist-info → sofar-1.1.0.dist-info}/top_level.txt +1 -0
  61. tests/__init__.py +0 -0
  62. tests/test_deprecations.py +19 -0
  63. tests/test_io.py +344 -0
  64. tests/test_sofa.py +354 -0
  65. tests/test_sofa_upgrade_conventions.py +102 -0
  66. tests/test_sofa_verify.py +472 -0
  67. tests/test_utils.py +241 -0
  68. sofar/conventions/source/MultiSpeakerBRIR_0.3.csv +0 -48
  69. sofar/sofar.py +0 -2531
  70. sofar-0.3.1.dist-info/METADATA +0 -69
  71. sofar-0.3.1.dist-info/RECORD +0 -46
  72. /sofar/{conventions/source → sofa_conventions/conventions}/SimpleFreeFieldSOS_1.0.csv +0 -0
  73. /sofar/{conventions → sofa_conventions/conventions}/SimpleFreeFieldSOS_1.0.json +0 -0
  74. /sofar/{conventions/source → sofa_conventions/conventions/deprecated}/GeneralFIRE_1.0.csv +0 -0
  75. /sofar/{conventions → sofa_conventions/conventions/deprecated}/GeneralFIRE_1.0.json +0 -0
  76. /sofar/{conventions → sofa_conventions/conventions/deprecated}/MultiSpeakerBRIR_0.3.json +0 -0
  77. /sofar/{conventions/source → sofa_conventions/conventions/deprecated}/SingleRoomDRIR_0.3.csv +0 -0
  78. /sofar/{conventions → sofa_conventions/conventions/deprecated}/SingleRoomDRIR_0.3.json +0 -0
  79. {sofar-0.3.1.dist-info → sofar-1.1.0.dist-info}/LICENSE +0 -0
sofar/sofa.py ADDED
@@ -0,0 +1,1795 @@
1
+ import os
2
+ import re
3
+ import json
4
+ from datetime import datetime
5
+ import platform
6
+ import numpy as np
7
+ import warnings
8
+ from copy import deepcopy
9
+ import sofar as sf
10
+ from .utils import (_nd_newaxis, _atleast_nd, _get_conventions,
11
+ _verify_convention_and_version)
12
+
13
+
14
+ class Sofa():
15
+ """Create a new SOFA object.
16
+
17
+ Parameters
18
+ ----------
19
+ convention : str
20
+ The name of the convention from which the SOFA file is created. See
21
+ :py:func:`~sofar.list_conventions`.
22
+ mandatory : bool, optional
23
+ If ``True``, only the mandatory data of the convention will be
24
+ returned. The default is ``False``, which returns mandatory and
25
+ optional data.
26
+ version : str, optional
27
+ The version of the convention as a string, e.g., ``' 2.0'``. The
28
+ default is ``'latest'``. Also see :py:func:`~sofar.list_conventions`.
29
+ verify : bool, optional
30
+ Verify the SOFA object by calling :py:func:`~Sofa.verify`. This helps
31
+ to find potential errors in the default values and is thus recommended
32
+ If creating a file does not work, try to call `Sofa` with
33
+ ``verify=False``. The default is ``True``.
34
+
35
+ Returns
36
+ -------
37
+ sofa : Sofa
38
+ A SOFA object filled with the default values of the convention.
39
+
40
+ Examples
41
+ --------
42
+ Create a new SOFA object with default values
43
+
44
+ .. code-block:: python
45
+
46
+ import sofar as sf
47
+
48
+ # create SOFA object
49
+ sofa = sf.Sofa("SimpleFreeFieldHRIR")
50
+
51
+ Add data as a list
52
+
53
+ .. code-block:: python
54
+
55
+ sofa.Data_IR = [1, 1]
56
+
57
+ Data can be entered as numbers, numpy arrays or lists. Note the following
58
+
59
+ 1. Lists are converted to numpy arrays with at least two dimensions, i.e.,
60
+ ``sofa.Data_IR`` is converted to a numpy array of shape (1, 2)
61
+ 2. Missing dimensions are appended when writing the SOFA object to disk,
62
+ i.e., ``sofa.Data_IR`` is written as an array of shape (1, 2, 1) because
63
+ the SOFA standard AES69-2020 defines it as a three dimensional array
64
+ with the dimensions (`M: measurements`, `R: receivers`, `N: samples`)
65
+ 3. When reading data from a SOFA file, array data is always returned as
66
+ numpy arrays and singleton trailing dimensions are discarded (numpy
67
+ default). I.e., ``sofa.Data_IR`` will again be an array of shape (1, 2)
68
+ after writing and reading to and from disk.
69
+ 4. One dimensional arrays with only one element will be converted to scalar
70
+ values. E.g. ``sofa.Data_SamplingRate`` is stored as an array of shape
71
+ (1, ) inside SOFA files (according to the SOFA standard AES69-2020) but
72
+ will be a scalar inside SOFA objects after reading from disk.
73
+
74
+
75
+ For more examples refer to the `Quick tour of SOFA and sofar` at
76
+ https://sofar.readthedocs.io/en/latest/
77
+ """
78
+
79
+ # these have to be set here, because they are used in __setattr__ and
80
+ # Python checks if they exist upon class creation
81
+
82
+ # don't allow adding attributes and deleting/writing read only attributes
83
+ _protected = False
84
+ # list of read only attributes (filled upon init)
85
+ _read_only_attr = []
86
+
87
+ def __init__(self, convention, mandatory=False, version="latest",
88
+ verify=True):
89
+ """See class docstring"""
90
+
91
+ # get convention
92
+ if convention is not None:
93
+ self._convention = self._load_convention(convention, version)
94
+
95
+ # update read only attributes
96
+ self._read_only_attr = [
97
+ key for key in self._convention.keys()
98
+ if self._read_only(self._convention[key]["flags"])]
99
+
100
+ # add attributes with default values
101
+ self._convention_to_sofa(mandatory)
102
+
103
+ # add and update the API
104
+ # (mandatory=False can not be verified because some conventions
105
+ # have default values that have optional variables as dependencies)
106
+ if verify and not mandatory:
107
+ self.verify(mode="read")
108
+
109
+ self.protected = True
110
+ else:
111
+ verify = False
112
+ self._convention = {}
113
+
114
+ def __setattr__(self, name: str, value):
115
+ # don't allow new attributes to be added outside the class
116
+ if self.protected and not hasattr(self, name):
117
+ raise TypeError(f"{name} is an invalid attribute")
118
+
119
+ # don't allow setting read only attributes
120
+ if name in self._read_only_attr and self.protected:
121
+ raise TypeError((
122
+ f"{name} is a read only attribute. Iy you know what you are "
123
+ "doing, you can set Sofa.protected = False to write read "
124
+ "only data (e.g., to repair corrupted SOFA data)."))
125
+
126
+ # convert to numpy array or scalar
127
+ if not isinstance(value, (str, dict, np.ndarray)) \
128
+ and name != "protected":
129
+ value = np.atleast_2d(value)
130
+ if value.size == 1:
131
+ value = value.flatten()[0]
132
+
133
+ super.__setattr__(self, name, value)
134
+
135
+ def __delattr__(self, name: str):
136
+ # can't delete non existing attributes
137
+ if not hasattr(self, name):
138
+ raise TypeError(f"{name} is not an attribute")
139
+ # delete anything if not frozen, delete non mandatory
140
+ if not self.protected or \
141
+ not self._mandatory(self._convention[name]["flags"]):
142
+ super().__delattr__(name)
143
+
144
+ # check if custom field as to be deleted
145
+ if hasattr(self, "_custom"):
146
+ if name in self._custom:
147
+ self._custom.pop(name)
148
+ else:
149
+ raise TypeError(
150
+ f"{name} is a mandatory attribute that can not be deleted")
151
+
152
+ def __repr__(self):
153
+ return (f"sofar.SOFA object: {self.GLOBAL_SOFAConventions} "
154
+ f"{self.GLOBAL_SOFAConventionsVersion}")
155
+
156
+ @property
157
+ def list_dimensions(self):
158
+ """
159
+ Print the dimensions of the SOFA object
160
+
161
+ See :py:func:`~Sofa.inspect` to see the shapes of the data inside the
162
+ SOFA object and :py:func:`~Sofa.get_dimension` to get the size/value
163
+ of a specific dimensions as integer number.
164
+
165
+ The SOFA file standard defines the following dimensions that are used
166
+ to define the shape of the data entries:
167
+
168
+ M
169
+ number of measurements
170
+ N
171
+ number of samles, frequencies, SOS coefficients
172
+ (depending on self.GLOBAL_DataType)
173
+ R
174
+ Number of receivers or SH coefficients
175
+ (depending on ReceiverPosition_Type)
176
+ E
177
+ Number of emitters or SH coefficients
178
+ (depending on EmitterPosition_Type)
179
+ S
180
+ Maximum length of a string in a string array
181
+ C
182
+ Size of the coordinate dimension. This is always three.
183
+ I
184
+ Single dimension. This is always one.
185
+
186
+ """
187
+
188
+ # Check if the dimensions can be updated
189
+ self._update_dimensions()
190
+
191
+ # get verbose description for dimesion N
192
+ if self.GLOBAL_DataType.startswith("FIR"):
193
+ N_verbose = "samples"
194
+ elif self.GLOBAL_DataType.startswith("TF"):
195
+ N_verbose = "frequencies"
196
+ elif self.GLOBAL_DataType.startswith("SOS"):
197
+ N_verbose = "SOS coefficients"
198
+
199
+ # get verbose description for dimensions R and E
200
+ R_verbose = "receiver spherical harmonics coefficients" if \
201
+ 'harmonic' in self.ReceiverPosition_Type else "receiver"
202
+ E_verbose = "emitter spherical harmonics coefficients" if \
203
+ 'harmonic' in self.EmitterPosition_Type else "emitter"
204
+
205
+ dimensions = {
206
+ "M": "measurements",
207
+ "N": N_verbose,
208
+ "R": R_verbose,
209
+ "E": E_verbose,
210
+ "S": "maximum string length",
211
+ "C": "coordinate dimensions, fixed",
212
+ "I": "single dimension, fixed"}
213
+
214
+ info_str = ""
215
+ for key, value in self._api.items():
216
+ dim_info = dimensions[key] if key in dimensions \
217
+ else "custom dimension"
218
+
219
+ info_str += f"{key} = {value} {dim_info}"
220
+
221
+ if dim_info != "custom ":
222
+ for key2, value2 in self._convention.items():
223
+ dim = value2["dimensions"]
224
+ if dim is not None and key.lower() in dim:
225
+ info_str += \
226
+ f" (set by {key2} of dimension {dim.upper()})"
227
+ break
228
+
229
+ info_str += "\n"
230
+
231
+ print(info_str)
232
+
233
+ def get_dimension(self, dimension):
234
+ """
235
+ Get size of a SOFA dimension
236
+
237
+ SOFA dimensions specify the shape of the data contained in a SOFA
238
+ object. For a list of all dimensions see :py:func:`~list_dimensions`.
239
+
240
+ Parameters
241
+ ----------
242
+ dimension : str
243
+ The dimension as a string, e.g., ``'N'``.
244
+
245
+ Returns
246
+ -------
247
+ size : int
248
+ the size of the queried dimension.
249
+ """
250
+
251
+ # Check if the dimensions can be updated
252
+ self._update_dimensions()
253
+
254
+ if dimension not in self._api:
255
+ raise ValueError((
256
+ f"{dimension} is not a valid dimension. "
257
+ "See Sofa.list_dimensions for a list of valid dimensions."))
258
+
259
+ return self._api[dimension]
260
+
261
+ def _update_dimensions(self):
262
+ """
263
+ Call verify and raise an error if the dimensions could not be updated.
264
+
265
+ used in Sofa.list_dimensions and Sofa.get_dimension
266
+ """
267
+
268
+ issues = self.verify(issue_handling="return")
269
+ if issues is not None and ("data of wrong type" in issues or
270
+ "variables of wrong shape" in issues or
271
+ not hasattr(self, "_api")):
272
+ raise ValueError(("Dimensions can not be shown because variables "
273
+ "of wrong type or shape were detected. "
274
+ "Call Sofa.verify() for more information."))
275
+
276
+ def info(self, info="all"):
277
+ """
278
+ Print information about the convention of a SOFA object.
279
+
280
+ Prints the variable type (attribute, double, string), shape, flags
281
+ (mandatory, read only) and comment (if any) for each or selected
282
+ entries.
283
+
284
+ Parameters
285
+ ----------
286
+ info : str
287
+ Specifies the kind of information that is printed:
288
+
289
+ ``'all'`` ``'mandatory'`` ``'optional'`` ``'read only'`` ``'data'``
290
+ Print the name, type, shape, and flags and comment for all or
291
+ selected entries of the SOFA object. ``'data'`` does not show
292
+ entries of type attribute.
293
+ key
294
+ If key is the name of an object attribute, all information for
295
+ attribute will be printed.
296
+ """
297
+
298
+ # warn for upcoming deprecation
299
+ warnings.warn((
300
+ 'Sofa.info() will be deprecated in sofar 1.3.0 The conventions are'
301
+ ' now documented at '
302
+ 'https://sofar.readthedocs.io/en/stable/resources/conventions.html'), # noqa
303
+ UserWarning)
304
+
305
+ # update the private attribute `_convention` to make sure the required
306
+ # meta data is in place
307
+ if not hasattr(self, "_convention"):
308
+ self._reset_convention()
309
+
310
+ # list of all attributes
311
+ keys = [k for k in self.__dict__.keys() if not k.startswith("_")]
312
+
313
+ # start printing the information
314
+ info_str = (
315
+ f"{self.GLOBAL_SOFAConventions} "
316
+ F"{self.GLOBAL_SOFAConventionsVersion} "
317
+ f"(SOFA version {self.GLOBAL_Version})\n")
318
+ info_str += "-" * len(info_str) + "\n"
319
+
320
+ if info in ["all", "mandatory", "optional", "read only", "data"]:
321
+
322
+ info_str += f"showing {info} entries : type (shape), flags\n\n"
323
+
324
+ for key in keys:
325
+
326
+ # check if field should be skipped
327
+ flags = self._convention[key]["flags"]
328
+ if (not self._mandatory(flags) and info == "mandatory") \
329
+ or \
330
+ (self._mandatory(flags) and info == "optional") \
331
+ or \
332
+ (not self._read_only(flags) and info == "read only") \
333
+ or \
334
+ (self._convention[key]['type'] == "attribute" and
335
+ info == "data"):
336
+ continue
337
+
338
+ info_str += f"{key} : {self._convention[key]['type']}"
339
+
340
+ if self._convention[key]['dimensions']:
341
+ info_str += \
342
+ f" ({self._convention[key]['dimensions'].upper()})"
343
+
344
+ if self._mandatory(flags):
345
+ info_str += ", mandatory"
346
+ else:
347
+ info_str += ", optional"
348
+ if self._read_only(flags):
349
+ info_str += ", read only"
350
+
351
+ if self._convention[key]['comment']:
352
+ info_str += f"\n {self._convention[key]['comment']}\n"
353
+ else:
354
+ info_str += "\n"
355
+
356
+ elif info in keys:
357
+
358
+ for key in [k for k in keys if info in k]:
359
+ comment = str(self._convention[key]['comment'])
360
+
361
+ info_str += (
362
+ f"{key}\n"
363
+ f" type: {self._convention[key]['type']}\n"
364
+ f" mandatory: "
365
+ f"{self._mandatory(self._convention[key]['flags'])}\n"
366
+ f" read only: "
367
+ f"{self._read_only(self._convention[key]['flags'])}\n"
368
+ f" default: {self._convention[key]['default']}\n"
369
+ f" shape: "
370
+ f"{str(self._convention[key]['dimensions']).upper()}\n"
371
+ f" comment: {comment}\n")
372
+ else:
373
+ raise ValueError(f"info='{info}' is invalid")
374
+
375
+ print(info_str)
376
+
377
+ def inspect(self, file=None, issue_handling="print"):
378
+ """
379
+ Get information about data inside a SOFA object.
380
+
381
+ Prints the values of all attributes and variables with six or less
382
+ entries and the shapes and type of all numeric and string variables.
383
+ When printing the values of arrays, single dimensions are discarded for
384
+ easy of display, i.e., an array of shape (1, 3, 2) will be displayed as
385
+ an array of shape (3, 2).
386
+
387
+ Parameters
388
+ ----------
389
+ file : str
390
+ Full path of a file under which the information is to be stored in
391
+ plain text. The default ``None`` only print the information to the
392
+ console.
393
+ issue_handling : str, optional
394
+ Defines how issues detected during verification of the SOFA object
395
+ are handeled (see :py:func:`~sofar.sofar.Sofa.verify`)
396
+
397
+ ``'raise'``
398
+ Warnings and errors are raised if issues are detected
399
+ ``'print'``
400
+ Issues are printed without raising warnings and errors
401
+ ``'return'``
402
+ Issues are returned as string but neither raised nor printed
403
+ ``'ignore'``
404
+ Issues are ignored, i.e., not raised, printed, or returned.
405
+
406
+ The default is ``print'``.
407
+ """
408
+
409
+ # update the private attribute `_convention` to make sure the required
410
+ # meta data is in place
411
+ self.verify(issue_handling=issue_handling)
412
+
413
+ # list of all attributes
414
+ keys = [k for k in self.__dict__.keys() if not k.startswith("_")]
415
+
416
+ # start printing the information
417
+ info_str = (
418
+ f"{self.GLOBAL_SOFAConventions} "
419
+ F"{self.GLOBAL_SOFAConventionsVersion} "
420
+ f"(SOFA version {self.GLOBAL_Version})\n")
421
+ info_str += "-" * len(info_str) + "\n"
422
+
423
+ for key in keys:
424
+
425
+ info_str += key + " : "
426
+ value = getattr(self, key)
427
+
428
+ # information for attributes and scalars
429
+ if self._convention[key]["type"] == "attribute" or value.size == 1:
430
+ info_str += str(value) + "\n"
431
+ # information for variables
432
+ else:
433
+ # get shape and dimension
434
+ shape = value.shape
435
+ dimension = self._dimensions[key]
436
+
437
+ # pad shape if required (trailing single dimensions are
438
+ # discarded following the numpy default)
439
+ while len(shape) < len(dimension):
440
+ shape += (1, )
441
+
442
+ # make verbose shape, e.g., '(M=100, R=2, N=128, '
443
+ shape_verbose = "("
444
+ for s, d in zip(shape, dimension):
445
+ shape_verbose += f"{d}={s}, "
446
+
447
+ # add shape information
448
+ info_str += shape_verbose[:-2] + ")\n"
449
+ # add value information if not too much
450
+ if value.size < 7:
451
+ info_str += " " + \
452
+ str(np.squeeze(value)).replace("\n", "\n ") + "\n"
453
+
454
+ # write to text file
455
+ if file is not None:
456
+ with open(file, 'w') as f_id:
457
+ f_id.write(info_str + "\n")
458
+
459
+ # output to console
460
+ print(info_str)
461
+
462
+ def add_missing(self, mandatory=True, optional=True, verbose=True):
463
+ """
464
+ Add missing data with default values.
465
+
466
+ Data might be missing in SOFA objects if the creator did not include it
467
+ or if a new data was suggested for a newer version of a SOFA
468
+ convention. Use this function to add the data with its default values.
469
+
470
+ mandatory : Bool
471
+ Add missing mandatory data. The default is ``True``.
472
+ optional : Bool
473
+ Add missing optional data. The default is ``True``.
474
+ verbose : Bool
475
+ Print the information about added data to the console. The default
476
+ is ``True``.
477
+ """
478
+
479
+ # initialize
480
+ self._reset_convention()
481
+ added = "Added the following missing data with their default values:\n"
482
+
483
+ # current data
484
+ keys = [key for key in self.__dict__.keys() if not key.startswith("_")]
485
+
486
+ self.protected = False
487
+
488
+ # loop data in convention
489
+ for key in self._convention.keys():
490
+ is_mandatory = self._mandatory(self._convention[key]["flags"])
491
+ add_data = (is_mandatory and mandatory) or \
492
+ (not is_mandatory and optional)
493
+ # add with default
494
+ if key not in keys and add_data:
495
+ setattr(self, key, self._convention[key]["default"])
496
+ added += f"- {key} "
497
+ added += f"({'mandatory' if is_mandatory else 'optional'})\n"
498
+
499
+ self.protected = True
500
+
501
+ if verbose:
502
+ if "-" in added:
503
+ print(added)
504
+ else:
505
+ print("All mandatory data contained.")
506
+
507
+ def add_variable(self, name, value, dtype, dimensions):
508
+ """
509
+ Add custom variable to the SOFA object, i.e., numeric or string arrays.
510
+
511
+ Parameters
512
+ ----------
513
+ name : str
514
+ Name of the new variable.
515
+ value : any
516
+ value to be added (see `dtype` for restrictions).
517
+ dtype : str
518
+ Type of the entry to be added in netCDF style:
519
+
520
+ ``'double'``
521
+ Use this to store numeric data that can be provided as number
522
+ list or numpy array.
523
+ ``'string'``
524
+ Use this to store string variables as numpy string arrays of
525
+ type ``'U'`` or ``'S'``.
526
+
527
+ dimensions : str
528
+ The shape of the new entry as a string. See
529
+ :py:func:`~Sofa.list_dimensions`.
530
+
531
+ Examples
532
+ --------
533
+ .. code-block:: python
534
+
535
+ import sofar as sf
536
+ sofa = sf.Sofa("GeneralTF")
537
+
538
+ # add numeric data
539
+ sofa.add_variable("Temperature", 25.1, "double", "MI")
540
+
541
+ # add GLOBAL and Variable attribtue
542
+ sofa.add_entry(
543
+ "GLOBAL_DateMeasured", "8.08.2021", "attribute", None)
544
+ sofa.add_entry(
545
+ "Temperature_Units", "degree Celsius", "attribute", None)
546
+
547
+ # add a string data
548
+ sofa.add_variable(
549
+ "Comment", "Measured with wind screen", "string", "MS")
550
+ """
551
+
552
+ self._add_entry(name, value, dtype, dimensions)
553
+
554
+ def add_attribute(self, name, value):
555
+ """
556
+ Add custom attribute to the SOFA object.
557
+
558
+ Parameters
559
+ ----------
560
+ name : str
561
+ Name of the new attribute.
562
+ value : str
563
+ value to be added.
564
+
565
+ Examples
566
+ --------
567
+ .. code-block:: python
568
+
569
+ import sofar as sf
570
+ sofa = sf.Sofa("GeneralTF")
571
+
572
+ # add GLOBAL and Variable attribtue
573
+ sofa.add_attribute("GLOBAL_DateMeasured", "8.08.2021")
574
+ sofa.add_attribute("Data_Real_Units", "Pascal")
575
+
576
+ """
577
+
578
+ self._add_entry(name, value, 'attribute', None)
579
+
580
+ def delete(self, name):
581
+ """
582
+ Delete variable or attribute from SOFA object.
583
+
584
+ Note that mandatory data can not be deleted. Check the
585
+ `sofar documentation
586
+ <https://sofar.readthedocs.io/en/stable/resources/conventions.html>`_
587
+ for a complete list of optional variables and attributes.
588
+
589
+ Parameters
590
+ ----------
591
+ name : str
592
+ Name of the variable or attribute to be deleted
593
+ """
594
+ delattr(self, name)
595
+
596
+ @property
597
+ def protected(self):
598
+ """
599
+ If Sofa.protected is ``True``, read only data can not be changed. Only
600
+ change this to ``False`` if you know what you are doing, e.g., if you
601
+ need to repair corrupted SOFA data.
602
+ """
603
+ return self._protected
604
+
605
+ @protected.setter
606
+ def protected(self, value: bool):
607
+ """
608
+ If Sofa.protected is ``True``, read only data can not be changed. Only
609
+ change this to ``False`` if you know what you are doing, e.g., if you
610
+ need to repair corrupted SOFA data.
611
+ """
612
+ if not isinstance(value, bool):
613
+ raise ValueError("Sofa.protected can only be True or False")
614
+ self._protected = value
615
+
616
+ def _add_entry(self, name, value, dtype, dimensions):
617
+ """
618
+ Add custom data to a SOFA object. See add_variable and add_attribute
619
+ for more information.
620
+ """
621
+
622
+ # check input
623
+ if hasattr(self, name):
624
+ raise ValueError(f"Entry {name} already exists")
625
+ if dtype not in ["attribute", "double", "string"]:
626
+ raise ValueError(
627
+ f"dtype is {dtype} but must be attribute, double, or string")
628
+ if "_" in name and dtype != "attribute":
629
+ raise ValueError(("underscores '_' in the name are only "
630
+ "allowed for attributes"))
631
+ if dtype == "attribute":
632
+ if name.count("_") != 1 or \
633
+ (name.startswith("Data_") and (name.count("_") == 0 or
634
+ name.count("_") > 2)):
635
+ raise ValueError((f"The name of {name} must have the "
636
+ "form VariableName_AttributeName"))
637
+ if not name.startswith("GLOBAL_") and \
638
+ name[:name.rindex("_")] not in self._convention:
639
+ raise ValueError((f"Adding Attribute {name} requires "
640
+ f"variable {name[:name.rindex('_')]}"))
641
+ if dimensions is None and dtype != "attribute":
642
+ raise ValueError(("dimensions must be provided for entries of "
643
+ "dtype double and string"))
644
+ if dimensions is not None:
645
+ dimensions = dimensions.upper()
646
+ for dimension in dimensions:
647
+ if dimension not in "ERMNCIS":
648
+ warnings.warn(
649
+ f"Added custom dimension {dimensions} to SOFA object")
650
+
651
+ # add attribute to class
652
+ self._add_custom_api_entry(name, value, None, dimensions, dtype)
653
+
654
+ def _add_custom_api_entry(self, key, value, flags, dimensions, dtype):
655
+ """
656
+ Add custom entry to the sofa._convention and permanently save it in
657
+ sofa._custom
658
+
659
+ Parameters
660
+ ----------
661
+ key : str
662
+ name of the entry
663
+ value: any
664
+ Value of the entry
665
+ flags, dimensions, dtype : any
666
+ as in sofa._convention
667
+ dimensions : string
668
+ Dimensions in case of numeric or string array
669
+ dtype : string
670
+ double, string, or attribute
671
+ """
672
+ # create custom API if it not exists
673
+ self.protected = False
674
+
675
+ # lower case letters to indicate custom dimensions
676
+ if dimensions is not None:
677
+ dimensions = [d.upper() if d.upper() in "ERMNCIS" else d.lower()
678
+ for d in dimensions]
679
+ dimensions = "".join(dimensions)
680
+
681
+ # add user entry to custom API, if not contained in the convention
682
+ if not hasattr(self, "_convention") or key not in self._convention:
683
+ if not hasattr(self, "_custom"):
684
+ self._custom = {}
685
+
686
+ self._custom[key] = {
687
+ "flags": flags,
688
+ "dimensions": dimensions,
689
+ "type": dtype,
690
+ "default": None,
691
+ "comment": ""}
692
+ self._convention[key] = self._custom[key]
693
+
694
+ # add attribute to object
695
+ setattr(self, key, value)
696
+ self.protected = True
697
+
698
+ def upgrade_convention(self, target=None, verify=True):
699
+ """
700
+ Upgrade Sofa data to newer conventions.
701
+
702
+ Calling this with the default arguments returns a list of possible
703
+ conventions to which the data will be upgraded. If the data is up to
704
+ date the list will be empty.
705
+
706
+ Parameters
707
+ ----------
708
+ target : str, optional
709
+ The convention and version to which the data should be upgraded as
710
+ a string. For example ``'SimpleFreeFieldHRIR_1.0'`` would upgrade
711
+ the data to the SOFA-Convention `SimpleFreeFieldHRIR` version 1.0.
712
+ The default is ``None`` which returns a list of possible
713
+ conventions to which the data can be updated.
714
+ verify : bool, optional
715
+ Flag to specify if the data should be verified after the upgrade
716
+ using :py:func:`~Sofa.verify`. The default is ``True``.
717
+
718
+ Returns
719
+ -------
720
+ target : list of strings
721
+ List with available conventions to which the data can be updated.
722
+ If the data is up to data, the list will be empty. `target` is only
723
+ returned if `target` is ``None``.
724
+ """
725
+
726
+ # check input ---------------------------------------------------------
727
+ self._reset_convention()
728
+
729
+ # get deprecations and information about Sofa object
730
+ _, _, deprecations, upgrade = self._verification_rules()
731
+ convention_current = self.GLOBAL_SOFAConventions
732
+ version_current = self.GLOBAL_SOFAConventionsVersion
733
+ sofa_version_current = self.GLOBAL_Version
734
+
735
+ # check if convention is deprecated -----------------------------------
736
+ is_deprecated = False
737
+
738
+ if convention_current in deprecations["GLOBAL:SOFAConventions"]:
739
+ is_deprecated = True
740
+ elif convention_current in upgrade:
741
+ for from_to in upgrade[convention_current]["from_to"]:
742
+ if version_current in from_to[0]:
743
+ is_deprecated = True
744
+ break
745
+
746
+ # check for upgrades --------------------------------------------------
747
+ if is_deprecated:
748
+ version_matched = False
749
+ # check if upgrade is available for this convention
750
+ if convention_current not in upgrade:
751
+ print((f"Convention {convention_current} v{version_current} is"
752
+ " outdated but is missing upgrade rules"))
753
+ return
754
+
755
+ # check if upgrade is available for this version
756
+ for from_to in upgrade[convention_current]["from_to"]:
757
+ if version_current in from_to[0]:
758
+ version_matched = True
759
+ targets = from_to[1]
760
+
761
+ if target in targets:
762
+ target_id = from_to[2]
763
+ else:
764
+ if target is not None:
765
+ print(f"{target} is invalid.")
766
+
767
+ upgrades = (
768
+ f"{convention_current} v{version_current} "
769
+ "can be upgraded to:\n")
770
+ for t in targets:
771
+ t = t.split("_")
772
+ upgrades += f"- {t[0]} v{t[1]}\n"
773
+ print(upgrades)
774
+ return targets
775
+ break
776
+ if not version_matched:
777
+ print((f"Convention {convention_current} v{version_current} is"
778
+ " outdated but is missing upgrade rules"))
779
+ else:
780
+ print((f"Convention {convention_current} v{version_current} "
781
+ "is up to date"))
782
+ return
783
+
784
+ # get information to upgrade ------------------------------------------
785
+ upgrade = upgrade[self.GLOBAL_SOFAConventions][target_id]
786
+ convention_target, version_target = target.split("_")
787
+
788
+ # upgrade -------------------------------------------------------------
789
+ self._convention = self._load_convention(
790
+ convention_target, version_target)
791
+ sofa_version_target = self._convention["GLOBAL_Version"]["default"]
792
+
793
+ print((f"Upgrading {convention_current} v{version_current} to "
794
+ f"{convention_target} v{version_target} (SOFA version "
795
+ f"{sofa_version_current} to {sofa_version_target})"))
796
+
797
+ # upgrade convention
798
+ keys = ["GLOBAL_SOFAConventions",
799
+ "GLOBAL_SOFAConventionsVersion",
800
+ "GLOBAL_Version",
801
+ "GLOBAL_DataType"]
802
+
803
+ self.protected = False
804
+ for key in keys:
805
+ setattr(self, key, self._convention[key]["default"])
806
+
807
+ # move data
808
+ for source, move in upgrade["move"].items():
809
+ # get info
810
+ source_sofar = source.replace(".", '_').replace(":", "_")
811
+ target_sofar = move["target"].replace(".", '_').replace(":", "_")
812
+ move_info = f"- Moving {source_sofar} to {target_sofar}."
813
+ # get data
814
+ data = getattr(self, source_sofar)
815
+ # delete from Sofa object
816
+ delattr(self, source_sofar)
817
+ # Check move axis
818
+ moveaxis = upgrade["move"][source]["moveaxis"]
819
+ if moveaxis is not None:
820
+ # add dimensions if required
821
+ # (sofar discards trailing singular dimensions)
822
+ while data.ndim < np.max(moveaxis) + 1:
823
+ data = data[..., None]
824
+ # move the axis
825
+ data = np.moveaxis(data, moveaxis[0], moveaxis[1])
826
+ move_info += f" Moving axis {moveaxis[0]} to {moveaxis[1]}."
827
+ # Check deprecated dimensions
828
+ deprecated_dimensions = \
829
+ upgrade["move"][source]["deprecated_dimensions"]
830
+ if deprecated_dimensions is not None:
831
+ move_info += (
832
+ f" WARNING: Dimensions {', '.join(deprecated_dimensions)} "
833
+ "are now deprecated.")
834
+ # add data
835
+ setattr(self, target_sofar, data)
836
+ # print info
837
+ print(move_info)
838
+
839
+ if not upgrade["move"]:
840
+ print("- No data to move")
841
+
842
+ # remove data
843
+ for target in upgrade["remove"]:
844
+ target_sofar = target.replace(".", '_').replace(":", "_")
845
+ delattr(self, target_sofar)
846
+ print(f"- Deleting {target_sofar}.")
847
+
848
+ if not upgrade["remove"]:
849
+ print("- No data to remove")
850
+
851
+ # check for missing mandatory data
852
+ self.add_missing(True, False)
853
+ self.protected = True
854
+
855
+ # display general message
856
+ if upgrade["message"] is not None:
857
+ print(upgrade["message"])
858
+
859
+ if verify:
860
+ self.verify()
861
+
862
+ def verify(self, issue_handling="raise", mode="write"):
863
+ """
864
+ Verify a SOFA object against the SOFA standard.
865
+
866
+ This function updates the API, and checks the following
867
+
868
+ - Are all mandatory data contained? If `issue_handling` is ``"raise"``
869
+ missing mandatory data raises an error. Otherwise mandatory data are
870
+ added with their default value and a warning is given.
871
+ - Are the names of variables and attributes in accordance to the SOFA
872
+ standard?
873
+ - Are the data types in accordance with the SOFA standard?
874
+ - Are the dimensions of the variables consistent and in accordance
875
+ to the SOFA standard?
876
+ - Are the values of attributes consistent and in accordance to the
877
+ SOFA standard?
878
+
879
+ A detailed set of validation rules can be found at
880
+ https://github.com/pyfar/sofar/tree/main/sofar/verification_rules
881
+
882
+ .. note::
883
+ :py:func:`~verify` is automatically called when you create a new
884
+ SOFA object, read a SOFA file from disk, and write a SOFA file to
885
+ disk (using the default parameters).
886
+
887
+ The API of a SOFA object consists of four parts, that are stored
888
+ dictionaries in private attributes. This is required for writing data
889
+ with :py:func:`~sofa.write_sofa` and should usually not be manipulated
890
+ outside of :py:func:`~verify`
891
+
892
+ self._convention
893
+ The SOFA convention with default values, variable dimensions, flags
894
+ and comments. These data are read from the official SOFA
895
+ conventions contained in the SOFA Matlab/Octave API.
896
+ self._dimensions
897
+ The detected dimensions of the data inside the SOFA object.
898
+ self._api
899
+ The size of the dimensions (see py:func:`~list_dimensions`). This
900
+ specifies the dimensions of the data inside the SOFA object.
901
+ self._custom
902
+ Stores information of custom variables that are not defined by the
903
+ convention. The format is the same as in `self._convention`.
904
+
905
+ Parameters
906
+ ----------
907
+ issue_handling : str, optional
908
+ Defines how detected issues are handeled
909
+
910
+ ``'raise'``
911
+ Warnings and errors are raised if issues are detected
912
+ ``'print'``
913
+ Issues are printed without raising warnings and errors
914
+ ``'return'``
915
+ Issues are returned as string but neither raised nor printed
916
+
917
+ The default is ``'raise'``.
918
+ mode : str, optional
919
+ The SOFA standard is more strict for writing data than for reading
920
+ data.
921
+
922
+ ``'write'``
923
+ All units (e.g. 'meter') must be written be lower case.
924
+ ``'read'``
925
+ Units can contain upper case letters (e.g. 'Meter')
926
+
927
+ The default is ``'write'``
928
+
929
+ Returns
930
+ -------
931
+ issues : str, None
932
+ Detected issues as a string. None if no issues were detected. Note
933
+ that this is only returned if ``issue_handling='return'`` (see
934
+ above)
935
+
936
+ """
937
+ # NOTE: This function collects warnings and errors and tries to output
938
+ # them in a block. This makes the code slightly more complicated but
939
+ # is more convenient for the user and with respect to a potential
940
+ # future web based tool for verifying SOFA files.
941
+
942
+ # initialize warning and error messages
943
+ error_msg = "\nERRORS\n------\n"
944
+ warning_msg = "\nWARNINGS\n--------\n"
945
+
946
+ # ---------------------------------------------------------------------
947
+ # 0. update the convention
948
+ self._reset_convention()
949
+
950
+ # ---------------------------------------------------------------------
951
+ # 1. check if the mandatory attributes are contained
952
+ missing = ""
953
+ keys = [key for key in self.__dict__.keys() if not key.startswith("_")]
954
+
955
+ for key in self._convention.keys():
956
+ if self._mandatory(self._convention[key]["flags"]) \
957
+ and key not in keys:
958
+
959
+ if issue_handling != "raise":
960
+ # add missing data with default value
961
+ self.protected = False
962
+ setattr(self, key, self._convention[key]["default"])
963
+ self.protected = True
964
+
965
+ # prepare to raise warning
966
+ missing += "- " + key + "\n"
967
+
968
+ if missing:
969
+ if issue_handling == "raise":
970
+ error_msg += ("Detected missing mandatory data "
971
+ "call sofa.add_missing() to fix this):\n")
972
+ error_msg += missing
973
+ else:
974
+ warning_msg += "Added mandatory data with default values:\n"
975
+ warning_msg += missing
976
+
977
+ # ---------------------------------------------------------------------
978
+ # 2. verify data type
979
+ current_error = ""
980
+ for key in keys:
981
+
982
+ # handle dimensions
983
+ dimensions = self._convention[key]["dimensions"]
984
+ dtype = self._convention[key]["type"]
985
+
986
+ # check data type
987
+ value = getattr(self, key)
988
+
989
+ if dtype == "attribute":
990
+ if not isinstance(value, str):
991
+ current_error += \
992
+ f"- {key} must be string but is {type(value)}\n"
993
+
994
+ elif dtype == "double":
995
+ # multiple checks needed because sofar does not force the user
996
+ # to initally pass data as numpy arrays
997
+ if not isinstance(value,
998
+ (np.int_, np.float_, np.double, np.ndarray)):
999
+ current_error += (f"- {key} must be int, float or numpy "
1000
+ f"array but is {type(value)}\n")
1001
+
1002
+ if isinstance(value, np.ndarray) and not (
1003
+ str(value.dtype).startswith('int') or
1004
+ str(value.dtype).startswith('float')):
1005
+ current_error += (f"- {key} must be int or float "
1006
+ f"but is {type(value.dtype)}\n")
1007
+
1008
+ elif dtype == "string":
1009
+ # multiple checks needed because sofar does not force the user
1010
+ # to initally pass data as numpy arrays
1011
+ if not isinstance(value, (str, np.ndarray)):
1012
+ current_error += (f"- {key} must be string or numpy array "
1013
+ f"but is {type(value)}\n")
1014
+
1015
+ if isinstance(value, np.ndarray) and not (
1016
+ str(value.dtype).startswith('<U') or
1017
+ str(value.dtype).startswith('<S')):
1018
+ current_error += (f"- {key} must be U or S "
1019
+ f"but is {type(value.dtype)}\n")
1020
+
1021
+ else:
1022
+ # Could only be tested by manipulating JSON convention files
1023
+ # (Could take different data types in the future and convert to
1024
+ # numpy double arrays.)
1025
+ current_error += (
1026
+ f"- {key}: Error in convention. Type must be "
1027
+ f"double, string, or attribute but is {dtype}\n")
1028
+
1029
+ if current_error:
1030
+ error_msg += "Detected data of wrong type:\n"
1031
+ error_msg += current_error
1032
+
1033
+ # if an error ocurred up to here, it has to be handled. Otherwise
1034
+ # detecting the dimensions might fail. Warnings are not reported until
1035
+ # the end
1036
+ if error_msg != "\nERRORS\n------\n" and issue_handling != "ignore":
1037
+ _, issues = self._verify_handle_issues(
1038
+ "\nWARNINGS\n--------\n", error_msg, issue_handling)
1039
+
1040
+ if issue_handling == "print":
1041
+ return
1042
+ else: # (issue_handling == "return"):
1043
+ return issues
1044
+
1045
+ # ---------------------------------------------------------------------
1046
+ # 3. Verify names of entries
1047
+
1048
+ # check attributes without variables
1049
+ current_error = ""
1050
+ for key in keys:
1051
+
1052
+ if self._convention[key]["type"] != "attribute" or \
1053
+ key.count("_") == 0:
1054
+ continue
1055
+
1056
+ if (key[:key.rindex("_")] not in self._convention and
1057
+ not key.startswith("GLOBAL_")):
1058
+ current_error += "- " + key + "\n"
1059
+
1060
+ if current_error:
1061
+ error_msg += "Detected attributes with missing variables:\n"
1062
+ error_msg += current_error
1063
+
1064
+ # check number of underscores
1065
+ current_error = ""
1066
+ for key in keys:
1067
+
1068
+ if self._convention[key]["type"] != "attribute":
1069
+ continue
1070
+
1071
+ # the case above caught attributes with too many underscores
1072
+ if key.count("_") == 0:
1073
+ current_error += "- " + key + "\n"
1074
+
1075
+ if current_error:
1076
+ error_msg += (
1077
+ "Detected attribute names with too many or little underscores."
1078
+ " Names must have the form Variable_Attribute, Data_Attribute "
1079
+ "(one underscore), or Data_Variable_Attribute (two "
1080
+ "underscores):\n")
1081
+ error_msg += current_error
1082
+
1083
+ # check numeric variables
1084
+ current_error = ""
1085
+ for key in keys:
1086
+
1087
+ if self._convention[key]["type"] == "attribute":
1088
+ continue
1089
+
1090
+ if "_" in key.replace("Data_", ""):
1091
+ current_error += "- " + key + "\n"
1092
+
1093
+ if current_error:
1094
+ error_msg += (
1095
+ "Detected variable names with too many underscores."
1096
+ "Underscores are only allowed for the variable Data:\n")
1097
+ error_msg += current_error
1098
+
1099
+ # check reserved names
1100
+ current_error = ""
1101
+ for key in keys:
1102
+
1103
+ # AES69-2020 Sec. 4.7.1
1104
+ if key.startswith("PRIVATE") or key.startswith("API"):
1105
+ current_error += "- " + key + "\n"
1106
+ if (key.startswith("GLOBAL") and not key.startswith("GLOBAL_")) or\
1107
+ (key.startswith("GLOBAL") and
1108
+ self._convention[key]["type"] != "attribute"):
1109
+ current_error += "- " + key + "\n"
1110
+
1111
+ if current_error:
1112
+ error_msg += (
1113
+ "Detected variable or attribute with reserved key words "
1114
+ "PRIVATE, API, or GLOBAL:\n")
1115
+ error_msg += current_error
1116
+
1117
+ # check names of custom data (shall not have the same name as
1118
+ # normative data contained in the convention AES69-2022 Sec. 5.3)
1119
+ current_error = ""
1120
+ if hasattr(self, "_custom"):
1121
+
1122
+ # get normative variables and attributes
1123
+ normative = sf.Sofa(
1124
+ self.GLOBAL_SOFAConventions,
1125
+ version=self.GLOBAL_SOFAConventionsVersion)._convention.keys()
1126
+ normative = [n.replace("GLOBAL_", "") for n in normative]
1127
+
1128
+ # compare against custom
1129
+ for key in self._custom.keys():
1130
+ if key.replace("GLOBAL_", "") in normative:
1131
+ current_error += "- " + key + "\n"
1132
+
1133
+ if current_error:
1134
+ error_msg += (
1135
+ "Detected custom variable or attribute with reserved names. "
1136
+ "Custom data shall not have the same name as data contained in"
1137
+ " the convention itself:\n")
1138
+ error_msg += current_error
1139
+
1140
+ # ---------------------------------------------------------------------
1141
+ # 4. Get dimensions (E, R, M, N, S, c, I, and custom)
1142
+
1143
+ # initialize required API fields
1144
+ self.protected = False
1145
+ self._dimensions = {}
1146
+ self._api = {}
1147
+ self.protected = True
1148
+
1149
+ # get keys for checking the dimensions (all SOFA variables)
1150
+ keys = [key for key in self.__dict__.keys()
1151
+ if key in self._convention
1152
+ and self._convention[key]["dimensions"] is not None]
1153
+ if hasattr(self, "_custom"):
1154
+ keys_custom = [key for key in self._custom.keys()
1155
+ if not key.startswith("_")
1156
+ and self._custom[key]["dimensions"] is not None]
1157
+ keys += keys_custom
1158
+
1159
+ S = 0
1160
+ for key in keys:
1161
+
1162
+ value = getattr(self, key)
1163
+ dimensions = self._convention[key]["dimensions"]
1164
+
1165
+ # - dimensions are given as string, e.g., 'mRN', or 'IC, MC'
1166
+ # - defined by lower case letters in `dimensions`
1167
+ for id, dim in enumerate(dimensions.split(", ")[0]):
1168
+ if dim not in "ICS" and dim.islower():
1169
+ # numeric data
1170
+ self._api[dim.upper()] = \
1171
+ _nd_newaxis(value, 4).shape[id]
1172
+ if dim == "S":
1173
+ # string data
1174
+ S = max(S, np.max(self._get_size_and_shape_of_string_var(
1175
+ value, key)[0]))
1176
+
1177
+ # add fixed sizes
1178
+ self._api["C"] = 3
1179
+ self._api["I"] = 1
1180
+ self._api["S"] = S
1181
+
1182
+ # ---------------------------------------------------------------------
1183
+ # 5. verify dimensions of data
1184
+ current_error = ""
1185
+ for key in keys:
1186
+
1187
+ # handle dimensions
1188
+ dimensions = self._convention[key]["dimensions"]
1189
+ dtype = self._convention[key]["type"]
1190
+
1191
+ # get value and actual shape
1192
+ try:
1193
+ value = getattr(self, key).copy()
1194
+ except AttributeError:
1195
+ value = getattr(self, key)
1196
+
1197
+ if dtype in ["attribute", "string"]:
1198
+ # string or string array like data
1199
+ shape_act = self._get_size_and_shape_of_string_var(
1200
+ value, key)[1]
1201
+ elif len(dimensions.split(",")[0]) > 1:
1202
+ # multidimensional array like data
1203
+ shape_act = _atleast_nd(value, 4).shape
1204
+ else:
1205
+ # scalar of single dimensional array like data
1206
+ shape_act = (np.array(value).size, )
1207
+
1208
+ shape_matched = False
1209
+ for dim in dimensions.split(", "):
1210
+
1211
+ # get the reference shape ('S' translates to a shape of 1,
1212
+ # because the strings are stored in an array whose shape does
1213
+ # not reflect the max. lengths of the actual strings inside it)
1214
+ shape_ref = tuple(
1215
+ [self._api[d.upper()] if d != "S" else 1 for d in dim])
1216
+
1217
+ # get shape for comparison to correct length by cropping and
1218
+ # appending singelton dimensions if required
1219
+ shape_compare = shape_act[:len(shape_ref)]
1220
+ for _ in range(len(shape_ref) - len(shape_compare)):
1221
+ shape_compare += (1, )
1222
+
1223
+ # check if the shapes match and write to API
1224
+ if shape_compare == shape_ref:
1225
+ shape_matched = True
1226
+ self._dimensions[key] = dim.upper()
1227
+ break
1228
+
1229
+ if not shape_matched:
1230
+ # get possible dimensions in verbose form, i.e., "(M=2, C=3)""
1231
+ dimensions_verbose = []
1232
+ for dim in dimensions.upper().replace(" ", "").split(","):
1233
+ dimensions_verbose.append(
1234
+ f"({', '.join([f'{d}={self._api[d]}' for d in dim])})")
1235
+
1236
+ current_error += (
1237
+ f"- {key} has shape {shape_compare} but must "
1238
+ f"have {', '.join(dimensions_verbose)}\n")
1239
+
1240
+ if current_error:
1241
+ error_msg += "Detected variables of wrong shape:\n"
1242
+ error_msg += current_error
1243
+
1244
+ # ---------------------------------------------------------------------
1245
+ # 6. check restrictions on the content of SOFA files
1246
+ rules, unit_aliases, deprecations, _ = self._verification_rules()
1247
+
1248
+ current_error = ""
1249
+ for key in rules.keys():
1250
+
1251
+ # convert to sofar format
1252
+ key_sofar = key.replace(".", "_").replace(":", "_")
1253
+
1254
+ if not hasattr(self, key_sofar):
1255
+ continue
1256
+
1257
+ # actual and possible values for the current key
1258
+ test = getattr(self, key_sofar)
1259
+ ref = rules[key]["value"]
1260
+
1261
+ # test if the value is valid
1262
+ if not self._verify_value(test, ref, unit_aliases, key_sofar):
1263
+ current_error += (f"- {key_sofar} is {test} "
1264
+ f"but must be {', '.join(ref)}\n")
1265
+
1266
+ # get lower case value for of test for verifying specific
1267
+ # dependencies
1268
+ if isinstance(test, str) and test.lower() in \
1269
+ ["cartesian", "spherical", "spherical harmonics"]:
1270
+ test = test.lower()
1271
+
1272
+ # check general dependencies
1273
+ items = rules[key]["general"] if "general" in rules[key] else []
1274
+
1275
+ for key_dep in items:
1276
+
1277
+ # convert key to sofar format
1278
+ key_dep = key_dep.replace(".", "_").replace(":", "_")
1279
+
1280
+ # check if dependency is contained in SOFA object hard to test,
1281
+ # for mandatory fields (added by sofar by default).
1282
+ if not hasattr(self, key_dep):
1283
+ current_error += (f"- {key_dep} must be given if "
1284
+ f"{key_sofar} is in SOFA object\n")
1285
+ continue
1286
+
1287
+ # check specific dependencies
1288
+ if "specific" in rules[key] \
1289
+ and test in rules[key]["specific"]:
1290
+ items = rules[key]["specific"][test].items()
1291
+ else:
1292
+ items = {}.items()
1293
+
1294
+ for key_dep, ref_dep in items:
1295
+
1296
+ if key_dep == "_dimensions":
1297
+ # requires specific dimension(s) to have a vertain size
1298
+ for dim in rules[key]["specific"][test]["_dimensions"]:
1299
+ # possible sizes
1300
+ dim_ref = \
1301
+ rules[key]["specific"][test]["_dimensions"][dim]["value"] # noqa
1302
+ # current size
1303
+ dim_act = self._api[dim]
1304
+ # verbose error string for possible sizes
1305
+ dim_str = \
1306
+ rules[key]["specific"][test]["_dimensions"][dim]["value_str"] # noqa
1307
+ # perform the check
1308
+ if dim_act not in dim_ref:
1309
+ current_error += \
1310
+ (f"- Dimension {dim} is of size {dim_act} "
1311
+ f"but must be {dim_str} if {key_sofar} "
1312
+ f"is {test}\n")
1313
+ else:
1314
+
1315
+ # convert name from NetCDF format to sofar format
1316
+ key_dep_sofar = key_dep.replace(".", "_").replace(":", "_")
1317
+
1318
+ # check if dependency is contained in SOFA object
1319
+ if not hasattr(self, key_dep_sofar):
1320
+ current_error += (f"- {key_dep_sofar} must be given if"
1321
+ f" {key_sofar} is {test}\n")
1322
+ continue
1323
+
1324
+ # check if dependency has the correct value
1325
+ if ref_dep is None:
1326
+ continue
1327
+
1328
+ # convert name from NetCDF format to sofar format
1329
+ test_dep = getattr(self, key_dep_sofar)
1330
+
1331
+ if not self._verify_value(
1332
+ test_dep, ref_dep, unit_aliases, key_dep_sofar):
1333
+ current_error += (
1334
+ f"- {key_dep_sofar} is {test_dep} but must be "
1335
+ f"{', '.join(ref_dep)} if {key_sofar} is {test}\n")
1336
+
1337
+ # ---------------------------------------------------------------------
1338
+ # 7. check write only restrictions: units shall be in lower-case
1339
+ # (could be tested in _verify_unit but this way a more verbose error
1340
+ # message can be generated)
1341
+
1342
+ if mode == "write":
1343
+ keys = [k for k in self.__dict__.keys() if k.endswith("Units")]
1344
+ for key in keys:
1345
+ unit = getattr(self, key)
1346
+ if unit.lower() != unit:
1347
+ current_error += (f"- {key} is {unit} but must contain "
1348
+ "only lower case letters when writing "
1349
+ "SOFA files to disk.\n")
1350
+
1351
+ if current_error:
1352
+ error_msg += "Detected violations of the SOFA convention:\n"
1353
+ error_msg += current_error
1354
+
1355
+ # ---------------------------------------------------------------------
1356
+ # 8. check deprecations
1357
+ # (so far there are only deprecations for the convention)
1358
+ if self.GLOBAL_SOFAConventions in \
1359
+ deprecations["GLOBAL:SOFAConventions"]:
1360
+ msg = ("Detected deprecations:\n"
1361
+ f"- GLOBAL_SOFAConventions is "
1362
+ f"{self.GLOBAL_SOFAConventions}, which is deprecated. Use "
1363
+ "Sofa.upgrade_convention() to upgrade to "
1364
+ f"{deprecations['GLOBAL:SOFAConventions'][self.GLOBAL_SOFAConventions]}") # noqa
1365
+ if mode == "write":
1366
+ error_msg += msg
1367
+ else:
1368
+ warning_msg += msg
1369
+
1370
+ # warn if preliminary conventions versions are used
1371
+ if float(self.GLOBAL_SOFAConventionsVersion) < 1.0:
1372
+ warning_msg += (
1373
+ "\n\nDetected preliminary conventions version "
1374
+ f"{self.GLOBAL_SOFAConventionsVersion}:\n - Upgrade data to "
1375
+ "version >= 1.0 if possible. Preliminary conventions might "
1376
+ "change in the future, which could invalidate data that was "
1377
+ "written before the changes.")
1378
+
1379
+ # ---------------------------------------------------------------------
1380
+ # 9. handle warnings and errors
1381
+ if issue_handling != "ignore":
1382
+ error_occurred, issues = self._verify_handle_issues(
1383
+ warning_msg, error_msg, issue_handling)
1384
+
1385
+ if error_occurred:
1386
+ if issue_handling == "print":
1387
+ return
1388
+ elif issue_handling == "return":
1389
+ return issues
1390
+
1391
+ @staticmethod
1392
+ def _verify_value(test, ref, unit_aliases, key):
1393
+ """
1394
+ Check a value agains the SOFA standard for Sofa.verify()
1395
+
1396
+ Parameters
1397
+ ----------
1398
+ test :
1399
+ the value under test
1400
+ ref :
1401
+ the value enforced by the SOFA standard
1402
+ unit_aliases :
1403
+ dict of aliases for units from _verification_rules()
1404
+ key :
1405
+ The name of the current attribute, e.g., "GLOBAL_DataType"
1406
+
1407
+ Returns
1408
+ -------
1409
+ ``True`` if `test` and `ref` agree, ``False`` otherwise
1410
+ """
1411
+
1412
+ # General case of valid value
1413
+ if ref is None or test in ref:
1414
+ return True
1415
+
1416
+ # only string data should remain for verification
1417
+ if not isinstance(test, str):
1418
+ raise TypeError((
1419
+ "At this point, only string data should remain. Please report "
1420
+ "the issue: github.com/pyfar/sofar/issues"))
1421
+
1422
+ # case sensitive check for DataType and SOFAConventions
1423
+ if key in ["GLOBAL_DataType", "GLOBAL_SOFAConventions"]:
1424
+ if test in ref:
1425
+ return True
1426
+ else:
1427
+ return False
1428
+
1429
+ # general case insensitive test
1430
+ test = test.lower()
1431
+ if test in ref:
1432
+ return True
1433
+
1434
+ # if we are not checking a unit, the test value is invalid
1435
+ if key.endswith("Units"):
1436
+ return sf.Sofa._verify_unit(test, ref, unit_aliases)
1437
+ else:
1438
+ return False
1439
+
1440
+ @staticmethod
1441
+ def _verify_unit(test, ref, unit_aliases):
1442
+ """
1443
+ Verify if a unit string agrees with AES69
1444
+
1445
+ Parameters:
1446
+ -----------
1447
+ test : string
1448
+ Current unit string (single units or multiple units separated by
1449
+ commas, commas plus spaces, or spaces).
1450
+ ref : list
1451
+ List of length one containing the LOWER reference case unit string,
1452
+ i.e., only the keys from unit_aliases are allowed words.
1453
+ unit_aliases : dict
1454
+ dict of aliases for units from _verification_rules()
1455
+
1456
+ Returns
1457
+ -------
1458
+ verified : bool
1459
+ """
1460
+ # check format of reference units
1461
+ if not isinstance(ref, list) \
1462
+ or len(ref) != 1 \
1463
+ or not isinstance(ref[0], str):
1464
+ raise TypeError("ref must be a list of length 1 containing a str")
1465
+
1466
+ # Following the SOFA standard AES69-2020, units may be separated by
1467
+ # `, ` (comma and space), `,` (comma only), and ` ` (space only).
1468
+ # (regexp ', ?' matches ', ' and ',')
1469
+ units_ref = re.split(', ?', ref[0])
1470
+ units_test = re.split(', ?', test)
1471
+
1472
+ # check if number of units agree
1473
+ if len(units_ref) != len(units_test):
1474
+ return False
1475
+
1476
+ # check if units are valid
1477
+ for unit_test, unit_ref in zip(units_test, units_ref):
1478
+ if unit_test not in unit_aliases \
1479
+ or unit_aliases[unit_test] != unit_ref:
1480
+ return False
1481
+
1482
+ return True
1483
+
1484
+ @staticmethod
1485
+ def _get_reference_unit(test, unit_aliases):
1486
+ """
1487
+ Return units in reference for, .e.g.,
1488
+ "meter" is converted to "metre" and
1489
+ "degrees degrees,meter" is converted to "degree, degree, metre"
1490
+
1491
+ Parameters:
1492
+ -----------
1493
+ test : string
1494
+ Current unit string MUST be valid, i.e., tested with
1495
+ Sofa._verify_unit (single units or multiple units separated by
1496
+ commas, commas plus spaces, or spaces).
1497
+ unit_aliases : dict
1498
+ dict of aliases for units from _verification_rules()
1499
+
1500
+ Returns
1501
+ -------
1502
+ reference_units : str
1503
+ """
1504
+ # Following the SOFA standard AES69-2020, units may be separated by
1505
+ # `, ` (comma and space), `,` (comma only), and ` ` (space only).
1506
+ # (regexp ', ?' matches ', ' and ',')
1507
+ units_test = re.split(', ?| ', test.lower())
1508
+
1509
+ # get list of reference units
1510
+ units = [unit_aliases[u] for u in units_test]
1511
+ # get reference unit string
1512
+ units = ", ".join(units) if units[0] != "cubic" else " ".join(units)
1513
+
1514
+ return units
1515
+
1516
+ @staticmethod
1517
+ def _verify_handle_issues(warning_msg, error_msg, issue_handling):
1518
+ """Handle warnings and errors from Sofa.verify"""
1519
+
1520
+ # handle warnings
1521
+ if warning_msg != "\nWARNINGS\n--------\n":
1522
+ if issue_handling == "raise":
1523
+ warnings.warn(UserWarning(warning_msg))
1524
+ elif issue_handling == "print":
1525
+ print(warning_msg)
1526
+ else:
1527
+ warning_msg = None
1528
+
1529
+ # handle errors
1530
+ if error_msg != "\nERRORS\n------\n":
1531
+ if issue_handling == "raise":
1532
+ raise ValueError(error_msg)
1533
+ elif issue_handling == "print":
1534
+ print(error_msg)
1535
+ else:
1536
+ error_msg = None
1537
+
1538
+ # flag indicating if an error occurred
1539
+ error_occurred = error_msg is not None
1540
+
1541
+ # verbose issue message
1542
+ if warning_msg and error_msg:
1543
+ issues = error_msg + "\n" + warning_msg
1544
+ elif warning_msg:
1545
+ issues = warning_msg
1546
+ elif error_msg:
1547
+ issues = error_msg
1548
+ else:
1549
+ issues = None
1550
+
1551
+ return error_occurred, issues
1552
+
1553
+ @staticmethod
1554
+ def _verification_rules():
1555
+ """
1556
+ Return dictionaries to verify SOFA objects in Sofa.verify(). For
1557
+ detailed information see folder 'sofa_conventions'.
1558
+
1559
+ Returns:
1560
+ rules : dict
1561
+ All general and specific verification rules
1562
+ unit_aliases : dict
1563
+ Aliases for specific units allowed in SOFA
1564
+ deprecations : dict
1565
+ Deprecated conventions and their substitute
1566
+ upgrade : dict
1567
+ Rules for upgrading deprecated conventions
1568
+ """
1569
+
1570
+ base = os.path.join(
1571
+ os.path.dirname(__file__), "sofa_conventions", "rules")
1572
+
1573
+ with open(os.path.join(base, "rules.json"), "r") as file:
1574
+ rules = json.load(file)
1575
+ with open(os.path.join(base, "unit_aliases.json"), "r") as file:
1576
+ unit_aliases = json.load(file)
1577
+ with open(os.path.join(base, "deprecations.json"), "r") as file:
1578
+ deprecations = json.load(file)
1579
+ with open(os.path.join(base, "upgrade.json"), "r") as file:
1580
+ upgrade = json.load(file)
1581
+
1582
+ return rules, unit_aliases, deprecations, upgrade
1583
+
1584
+ def copy(self):
1585
+ """Return a copy of the SOFA object."""
1586
+ return deepcopy(self)
1587
+
1588
+ def _reset_convention(self):
1589
+ """
1590
+ - Add SOFA convention to SOFA object in private attribute
1591
+ `_convention`. If The object already contains a convention, it will
1592
+ be overwritten.
1593
+ - If the SOFA object contains custom entries, check if any of the
1594
+ custom entries part of the convention. If yes, delete the entry from
1595
+ self._custom
1596
+ - If the SOFA objects contains custom entries, add entries from
1597
+ self._custom to self._convention
1598
+ """
1599
+
1600
+ # verify convention and version
1601
+ c_current = self.GLOBAL_SOFAConventions
1602
+ v_current = str(self.GLOBAL_SOFAConventionsVersion)
1603
+
1604
+ _verify_convention_and_version(v_current, c_current)
1605
+
1606
+ # load and add convention and version
1607
+ convention = self._load_convention(
1608
+ c_current, v_current)
1609
+ self._convention = convention
1610
+
1611
+ if hasattr(self, "_custom"):
1612
+ # check of custom fields can be removed
1613
+ for key in self._convention:
1614
+ if key in self._custom:
1615
+ del self._custom[key]
1616
+
1617
+ # check if custom fields can be added
1618
+ for key in self._custom:
1619
+ self._convention[key] = self._custom[key]
1620
+
1621
+ def _load_convention(self, convention, version):
1622
+ """
1623
+ Load SOFA convention from json file.
1624
+
1625
+ Parameters
1626
+ ----------
1627
+ convention : str
1628
+ The name of the convention from which the SOFA file is created. See
1629
+ :py:func:`~sofar.list_conventions`.
1630
+ version : str
1631
+ ``'latest'``
1632
+ Use the latest API and upgrade the SOFA file if required.
1633
+ str
1634
+ Version string, e.g., ``'1.0'``.
1635
+
1636
+ Returns
1637
+ -------
1638
+ convention : dict
1639
+ The SOFA convention as a dictionary
1640
+ """
1641
+ # check input
1642
+ if not isinstance(convention, str):
1643
+ raise TypeError(("Convention must be a string "
1644
+ f"but is of type {type(convention)}"))
1645
+
1646
+ # get and check path to json file
1647
+ paths = _get_conventions("path")
1648
+ path = [path for path in paths
1649
+ if os.path.basename(path).startswith(convention + "_")]
1650
+
1651
+ if not len(path):
1652
+ raise ValueError(
1653
+ (f"Convention '{convention}' not found. See "
1654
+ "sofar.list_conventions() for available conventions."))
1655
+
1656
+ # get available versions as strings
1657
+ versions = [p.split('_')[-1][:-5] for p in path]
1658
+
1659
+ # select the correct version
1660
+ if version == "latest":
1661
+ versions = np.array([float(v) for v in versions])
1662
+ path = path[np.argmax(versions)]
1663
+ else:
1664
+ if version not in versions:
1665
+ raise ValueError((
1666
+ f"Version {version} not found. "
1667
+ f"Available versions are {versions}"))
1668
+ path = path[versions.index(version)]
1669
+
1670
+ # read convention from json file
1671
+ with open(path, "r") as file:
1672
+ convention = json.load(file)
1673
+
1674
+ # replace ':' and '.' in key names by '_'
1675
+ convention = {
1676
+ key.replace(':', '_'): value for key, value in convention.items()}
1677
+ convention = {
1678
+ key.replace('.', '_'): value for key, value in convention.items()}
1679
+
1680
+ return convention
1681
+
1682
+ def _convention_to_sofa(self, mandatory):
1683
+ """
1684
+ Use SOFA convention to create attributes with default values.
1685
+
1686
+ Parameters
1687
+ ----------
1688
+ mandatory : bool
1689
+ Flag to indicate if only mandatory fields are to be included.
1690
+ """
1691
+
1692
+ # populate the SOFA file
1693
+ for key in self._convention.keys():
1694
+
1695
+ # skip optional fields if requested
1696
+ if not self._mandatory(self._convention[key]["flags"]) \
1697
+ and mandatory:
1698
+ continue
1699
+
1700
+ # get the default value
1701
+ default = self._convention[key]["default"]
1702
+ if isinstance(default, list):
1703
+ ndim = len(self._convention[key]["dimensions"].split(", ")[0])
1704
+ default = _atleast_nd(default, ndim)
1705
+
1706
+ # create attribute with default value
1707
+ setattr(self, key, default)
1708
+
1709
+ # write API and date specific fields (some read only)
1710
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1711
+ self.protected = False
1712
+ self.GLOBAL_DateCreated = now
1713
+ self.GLOBAL_DateModified = now
1714
+ self.GLOBAL_APIName = "sofar SOFA API for Python (pyfar.org)"
1715
+ self.GLOBAL_APIVersion = sf.version()
1716
+ self.GLOBAL_ApplicationName = "Python"
1717
+ self.GLOBAL_ApplicationVersion = (
1718
+ f"{platform.python_version()} "
1719
+ f"[{platform.python_implementation()} - "
1720
+ f"{platform.python_compiler()}]")
1721
+ self.protected = True
1722
+
1723
+ @staticmethod
1724
+ def _get_size_and_shape_of_string_var(value, key):
1725
+ """
1726
+ String variables can be strings, list of strings, or numpy arrays of
1727
+ strings. This functions returns the length of the longest string S
1728
+ inside the string variable and the shape of the string variable as
1729
+ required by the SOFA definition. Note that the shape is the shape of
1730
+ the array that holds the strings. NETCDF stores all string variables in
1731
+ arrays.
1732
+ """
1733
+
1734
+ if isinstance(value, str):
1735
+ S = len(value)
1736
+ shape = (1, 1)
1737
+ elif isinstance(value, list):
1738
+ S = len(max(value, key=len))
1739
+ shape = np.array(value).shape
1740
+ elif isinstance(value, np.ndarray):
1741
+ S = max(np.vectorize(len)(value))
1742
+ shape = value.shape
1743
+ else:
1744
+ raise TypeError((f"{key} must be a string, numpy string array, "
1745
+ "or list of strings"))
1746
+
1747
+ return S, shape
1748
+
1749
+ @staticmethod
1750
+ def _mandatory(flags):
1751
+ """
1752
+ Check if a field is mandatory
1753
+
1754
+ Parameters
1755
+ ----------
1756
+ flags : None, str
1757
+ The flags from convention[key]["flags"]
1758
+
1759
+ Returns
1760
+ -------
1761
+ is_mandatory : bool
1762
+ """
1763
+ # skip optional fields if requested
1764
+ if flags is None:
1765
+ is_mandatory = False
1766
+ elif "m" not in flags:
1767
+ is_mandatory = False
1768
+ else:
1769
+ is_mandatory = True
1770
+
1771
+ return is_mandatory
1772
+
1773
+ @staticmethod
1774
+ def _read_only(flags):
1775
+ """
1776
+ Check if a field is read only
1777
+
1778
+ Parameters
1779
+ ----------
1780
+ flags : None, str
1781
+ The flags from convention[key]["flags"]
1782
+
1783
+ Returns
1784
+ -------
1785
+ is_read_only : bool
1786
+ """
1787
+ # skip optional fields if requested
1788
+ if flags is None:
1789
+ is_read_only = False
1790
+ elif "r" not in flags:
1791
+ is_read_only = False
1792
+ else:
1793
+ is_read_only = True
1794
+
1795
+ return is_read_only