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