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.
- docs/Makefile +20 -0
- docs/api_reference.rst +20 -0
- docs/conf.py +167 -0
- docs/contributing.rst +1 -0
- docs/history.rst +1 -0
- docs/index.rst +4 -0
- docs/make.bat +36 -0
- docs/readme.rst +1 -0
- docs/resources/conventions.py +162 -0
- docs/resources/working_with_sofa_HRIR_lateral.png +0 -0
- docs/resources/working_with_sofa_source_horizontal.png +0 -0
- docs/resources/working_with_sofa_source_lateral.png +0 -0
- docs/sofar.rst +82 -0
- sofar/__init__.py +28 -0
- sofar/io.py +427 -0
- sofar/sofa.py +1835 -0
- sofar/sofa_conventions/VERSION +1 -0
- sofar/sofa_conventions/conventions/AnnotatedEmitterAudio_0.2.csv +46 -0
- sofar/sofa_conventions/conventions/AnnotatedEmitterAudio_0.2.json +353 -0
- sofar/sofa_conventions/conventions/AnnotatedReceiverAudio_0.2.csv +46 -0
- sofar/sofa_conventions/conventions/AnnotatedReceiverAudio_0.2.json +353 -0
- sofar/sofa_conventions/conventions/FreeFieldDirectivityTF_1.1.csv +59 -0
- sofar/sofa_conventions/conventions/FreeFieldDirectivityTF_1.1.json +444 -0
- sofar/sofa_conventions/conventions/FreeFieldHRIR_1.0.csv +43 -0
- sofar/sofa_conventions/conventions/FreeFieldHRIR_1.0.json +333 -0
- sofar/sofa_conventions/conventions/FreeFieldHRTF_1.0.csv +44 -0
- sofar/sofa_conventions/conventions/FreeFieldHRTF_1.0.json +340 -0
- sofar/sofa_conventions/conventions/GeneralFIR-E_2.0.csv +37 -0
- sofar/sofa_conventions/conventions/GeneralFIR-E_2.0.json +270 -0
- sofar/sofa_conventions/conventions/GeneralFIR_1.0.csv +40 -0
- sofar/sofa_conventions/conventions/GeneralFIR_1.0.json +295 -0
- sofar/sofa_conventions/conventions/GeneralSOS_1.0.csv +40 -0
- sofar/sofa_conventions/conventions/GeneralSOS_1.0.json +306 -0
- sofar/sofa_conventions/conventions/GeneralTF-E_1.0.csv +38 -0
- sofar/sofa_conventions/conventions/GeneralTF-E_1.0.json +277 -0
- sofar/sofa_conventions/conventions/GeneralTF_1.0.csv +38 -0
- sofar/sofa_conventions/conventions/GeneralTF_1.0.json +277 -0
- sofar/sofa_conventions/conventions/GeneralTF_2.0.csv +38 -0
- sofar/sofa_conventions/conventions/GeneralTF_2.0.json +277 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldHRIR_1.0.csv +47 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldHRIR_1.0.json +369 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldHRSOS_1.0.csv +43 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldHRSOS_1.0.json +349 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldHRTF_1.0.csv +44 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldHRTF_1.0.json +340 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldSOS_1.0.csv +43 -0
- sofar/sofa_conventions/conventions/SimpleFreeFieldSOS_1.0.json +349 -0
- sofar/sofa_conventions/conventions/SimpleHeadphoneIR_1.0.csv +51 -0
- sofar/sofa_conventions/conventions/SimpleHeadphoneIR_1.0.json +396 -0
- sofar/sofa_conventions/conventions/SingleRoomMIMOSRIR_1.0.csv +78 -0
- sofar/sofa_conventions/conventions/SingleRoomMIMOSRIR_1.0.json +601 -0
- sofar/sofa_conventions/conventions/SingleRoomSRIR_1.0.csv +78 -0
- sofar/sofa_conventions/conventions/SingleRoomSRIR_1.0.json +601 -0
- sofar/sofa_conventions/conventions/deprecated/AnnotatedEmitterAudio_0.1.csv +46 -0
- sofar/sofa_conventions/conventions/deprecated/AnnotatedEmitterAudio_0.1.json +351 -0
- sofar/sofa_conventions/conventions/deprecated/AnnotatedReceiverAudio_0.1.csv +46 -0
- sofar/sofa_conventions/conventions/deprecated/AnnotatedReceiverAudio_0.1.json +351 -0
- sofar/sofa_conventions/conventions/deprecated/FreeFieldDirectivityTF_1.0.csv +58 -0
- sofar/sofa_conventions/conventions/deprecated/FreeFieldDirectivityTF_1.0.json +437 -0
- sofar/sofa_conventions/conventions/deprecated/GeneralFIRE_1.0.csv +37 -0
- sofar/sofa_conventions/conventions/deprecated/GeneralFIRE_1.0.json +270 -0
- sofar/sofa_conventions/conventions/deprecated/MultiSpeakerBRIR_0.3.csv +48 -0
- sofar/sofa_conventions/conventions/deprecated/MultiSpeakerBRIR_0.3.json +376 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldHRIR_0.4.csv +43 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldHRIR_0.4.json +333 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_0.4.csv +44 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_0.4.json +340 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_1.0.csv +44 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleFreeFieldTF_1.0.json +340 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.1.csv +51 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.1.json +396 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.2.csv +51 -0
- sofar/sofa_conventions/conventions/deprecated/SimpleHeadphoneIR_0.2.json +396 -0
- sofar/sofa_conventions/conventions/deprecated/SingleRoomDRIR_0.2.csv +47 -0
- sofar/sofa_conventions/conventions/deprecated/SingleRoomDRIR_0.2.json +360 -0
- sofar/sofa_conventions/conventions/deprecated/SingleRoomDRIR_0.3.csv +47 -0
- sofar/sofa_conventions/conventions/deprecated/SingleRoomDRIR_0.3.json +360 -0
- sofar/sofa_conventions/conventions/deprecated/SingleTrackedAudio_0.1.csv +47 -0
- sofar/sofa_conventions/conventions/deprecated/SingleTrackedAudio_0.1.json +366 -0
- sofar/sofa_conventions/conventions/deprecated/SingleTrackedAudio_0.2.csv +51 -0
- sofar/sofa_conventions/conventions/deprecated/SingleTrackedAudio_0.2.json +397 -0
- sofar/sofa_conventions/rules/deprecations.json +13 -0
- sofar/sofa_conventions/rules/rules.json +819 -0
- sofar/sofa_conventions/rules/unit_aliases.json +11 -0
- sofar/sofa_conventions/rules/upgrade.json +226 -0
- sofar/sofa_conventions/write_upgrade_rules.py +139 -0
- sofar/sofa_conventions/write_verification_data.py +313 -0
- sofar/sofa_conventions/write_verification_rules.py +356 -0
- sofar/sofastream.py +301 -0
- sofar/update_conventions.py +449 -0
- sofar/utils.py +316 -0
- sofar-1.2.1.dist-info/LICENSE +22 -0
- sofar-1.2.1.dist-info/METADATA +136 -0
- sofar-1.2.1.dist-info/RECORD +105 -0
- sofar-1.2.1.dist-info/WHEEL +5 -0
- sofar-1.2.1.dist-info/top_level.txt +3 -0
- tests/__init__.py +0 -0
- tests/conftest.py +27 -0
- tests/test_deprecations.py +19 -0
- tests/test_io.py +349 -0
- tests/test_sofa.py +353 -0
- tests/test_sofa_upgrade_conventions.py +111 -0
- tests/test_sofa_verify.py +480 -0
- tests/test_sofastream.py +127 -0
- 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
|