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
@@ -0,0 +1,449 @@
1
+ """Module for updating the SOFA conventions used in sofar."""
2
+ import contextlib
3
+ import os
4
+ import shutil
5
+ import re
6
+ import glob
7
+ import json
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+ from tempfile import TemporaryDirectory
11
+
12
+
13
+ def update_conventions(conventions_path=None, assume_yes=False):
14
+ """
15
+ Update SOFA conventions.
16
+
17
+ SOFA convention define what data is stored in a SOFA file and how it is
18
+ stored. Updating makes sure that sofar is using the latest conventions.
19
+ This is done in three steps
20
+
21
+ 1.
22
+ Download official SOFA conventions as csv files from
23
+ https://www.sofaconventions.org/conventions/ and
24
+ https://www.sofaconventions.org/conventions/deprecated/.
25
+ 2.
26
+ Notify which conventions would be added or updated.
27
+ 3.
28
+ Convert csv files to json files to be read by sofar.
29
+
30
+ The csv and json files are stored at sofar/conventions. Sofar works only on
31
+ the json files. To get a list of all currently available SOFA conventions
32
+ and their paths see :py:func:`~sofar.list_conventions`.
33
+
34
+ .. note::
35
+ If the official convention contain errors, calling this function might
36
+ break sofar. If this is the case sofar must be re-installed, e.g., by
37
+ running ``pip install --force-reinstall sofar``. Be sure that you want
38
+ to do this.
39
+
40
+ Parameters
41
+ ----------
42
+ conventions_path : str, optional
43
+ Path to the folder where the conventions are saved. The default is
44
+ ``None``, which saves the conventions inside the sofar package.
45
+ Conventions saved under a different path can not be used by sofar. This
46
+ parameter was added mostly for testing and debugging.
47
+ assume_yes : bool, optional
48
+ Updating the conventions must be confirmed by typing "y" if this is
49
+ ``False``. Otherwise, the conventions are updated without confirmation.
50
+ The default is ``False``
51
+ """
52
+
53
+ # url for parsing and downloading the convention files
54
+ urls = ("https://www.sofaconventions.org/conventions/",
55
+ "https://www.sofaconventions.org/conventions/deprecated/")
56
+ ext = 'csv'
57
+
58
+ print(f"Reading SOFA conventions from {urls[0]} ...")
59
+
60
+ # get file names of conventions from sofaconventions.org
61
+ page = requests.get(urls[0]).text
62
+ soup = BeautifulSoup(page, 'html.parser')
63
+ standardized = [os.path.split(node.get('href'))[1]
64
+ for node in soup.find_all('a')
65
+ if node.get('href').endswith(ext)]
66
+ page = requests.get(urls[1]).text
67
+ soup = BeautifulSoup(page, 'html.parser')
68
+ deprecated = [os.path.split(node.get('href'))[1]
69
+ for node in soup.find_all('a')
70
+ if node.get('href').endswith(ext)]
71
+
72
+ conventions = standardized + deprecated
73
+
74
+ # directory handling
75
+ if conventions_path is None:
76
+ conventions_path = os.path.join(
77
+ os.path.dirname(__file__), "sofa_conventions", "conventions")
78
+ if not os.path.isdir(conventions_path):
79
+ os.mkdir(conventions_path)
80
+ if not os.path.isdir(os.path.join(conventions_path, "deprecated")):
81
+ os.mkdir(os.path.join(conventions_path, "deprecated"))
82
+
83
+ # Loop and download conventions to temporary directory if they changed
84
+ updated = False
85
+
86
+ update = []
87
+ deprecate = []
88
+
89
+ with TemporaryDirectory() as temp:
90
+ os.mkdir(os.path.join(temp, 'deprecated'))
91
+ for convention in conventions:
92
+
93
+ # exclude these conventions
94
+ if convention.startswith(("General_", "GeneralString_")):
95
+ continue
96
+
97
+ # get filename and url
98
+ is_standardized = convention in standardized
99
+ standardized_csv = os.path.join(conventions_path, convention)
100
+ deprecated_csv = os.path.join(
101
+ conventions_path, "deprecated", convention)
102
+ url = (
103
+ f"{urls[0]}/{convention}"
104
+ if is_standardized
105
+ else f"{urls[1]}/{convention}"
106
+ )
107
+
108
+ # download SOFA convention definitions to package directory
109
+ data = requests.get(url)
110
+ # remove windows style line breaks and trailing tabs
111
+ data = data.content.replace(b"\r\n", b"\n").replace(b"\t\n", b"\n")
112
+
113
+ # check if convention needs to be added or updated
114
+ if is_standardized and not os.path.isfile(standardized_csv):
115
+ # add new standardized convention
116
+ updated = True
117
+ with open(os.path.join(temp, convention), "wb") as file:
118
+ file.write(data)
119
+ print(f"- add convention: {convention[:-4]}")
120
+ update.append(convention)
121
+ if is_standardized and os.path.isfile(standardized_csv):
122
+ # check for update of a standardized convention
123
+ with open(standardized_csv, "rb") as file:
124
+ data_current = b"".join(file.readlines())
125
+ data_current = data_current.replace(
126
+ b"\r\n", b"\n").replace(b"\t\n", b"\n")
127
+ if data_current != data:
128
+ updated = True
129
+ with open(os.path.join(temp, convention), "wb") as file:
130
+ file.write(data)
131
+ print(f"- update convention: {convention[:-4]}")
132
+ update.append(convention)
133
+ elif not is_standardized and os.path.isfile(standardized_csv):
134
+ # deprecate standardized convention
135
+ updated = True
136
+ with open(os.path.join(temp, 'deprecated', convention), "wb") \
137
+ as file:
138
+ file.write(data)
139
+ print(f"- deprecate convention: {convention[:-4]}")
140
+ deprecate.append(convention)
141
+ elif not is_standardized and os.path.isfile(deprecated_csv):
142
+ # check for update of a deprecated convention
143
+ with open(deprecated_csv, "rb") as file:
144
+ data_current = b"".join(file.readlines())
145
+ data_current = data_current.replace(
146
+ b"\r\n", b"\n").replace(b"\t\n", b"\n")
147
+ if data_current != data:
148
+ updated = True
149
+ with open(os.path.join(temp, 'deprecated', convention),
150
+ "wb") as file:
151
+ file.write(data)
152
+ print(f"- update deprecated convention: {convention[:-4]}")
153
+ update.append(os.path.join('deprecated', convention))
154
+ elif not is_standardized and not os.path.isfile(deprecated_csv):
155
+ # add new deprecation
156
+ updated = True
157
+ with open(os.path.join(temp, 'deprecated', convention), "wb") \
158
+ as file:
159
+ file.write(data)
160
+ print(f"- add deprecated convention: {convention[:-4]}")
161
+ update.append(os.path.join('deprecated', convention))
162
+
163
+ if updated and not assume_yes:
164
+ # these lines were only tested manually. I was too lazy to write a
165
+ # test coping with keyboard input
166
+ print(("\nDo you want to update the conventions above? (y/n)\n"
167
+ "Read the documentation before continuing. "
168
+ "If updating breaks sofar it has to be re-installed"))
169
+ response = input()
170
+ if response != "y":
171
+ print("\nUpdating the conventions was canceled.")
172
+ return
173
+
174
+ if updated:
175
+ for convention in update:
176
+ shutil.copy(os.path.join(temp, convention),
177
+ os.path.join(conventions_path, convention))
178
+ for convention in deprecate:
179
+ os.remove(os.path.join(conventions_path, convention))
180
+ os.remove(
181
+ os.path.join(conventions_path, f"{convention[:-3]}json"))
182
+ shutil.copy(
183
+ os.path.join(temp, 'deprecated', convention),
184
+ os.path.join(conventions_path, 'deprecated', convention))
185
+ # compile json files from csv file
186
+ _compile_conventions(conventions_path)
187
+ print("... done.")
188
+ else:
189
+ print("... conventions already up to date.")
190
+
191
+
192
+ def _compile_conventions(conventions_path=None):
193
+ """
194
+ Compile SOFA conventions (json files) from source conventions (csv files
195
+ from SOFA SOFAtoolbox), i.e., only do step 2 from `update_conventions`.
196
+ This is a helper function for debugging and developing and might break
197
+ sofar.
198
+
199
+ Parameters
200
+ ----------
201
+ conventions_path : str
202
+ Path to the `conventions`folder containing csv and json files. The
203
+ default ``None`` uses the default location inside the sofar package.
204
+ """
205
+ # directory handling
206
+ if conventions_path is None:
207
+ conventions_path = os.path.join(
208
+ os.path.dirname(__file__), "sofa_conventions", "conventions")
209
+ if not os.path.isdir(conventions_path):
210
+ raise ValueError(f"{conventions_path} does not exist")
211
+
212
+ # get list of source conventions
213
+ csv_files = glob.glob(os.path.join(conventions_path, "*.csv")) + \
214
+ glob.glob(os.path.join(conventions_path, "deprecated", "*.csv"))
215
+
216
+ for csv_file in csv_files:
217
+
218
+ # convert SOFA conventions from csv to json
219
+ convention_dict = _convention_csv2dict(csv_file)
220
+ with open(f"{csv_file[:-3]}json", 'w') as file:
221
+ json.dump(convention_dict, file, indent=4)
222
+
223
+
224
+ def _convention_csv2dict(file: str):
225
+ """
226
+ Read a SOFA convention from csv.
227
+
228
+ File are taken from from https://www.sofaconventions.org/conventions/
229
+ and convert them to a Python dictionary. The dictionary can be written for
230
+ example to a json file using
231
+
232
+ import json
233
+
234
+ with open(filename, 'w') as file:
235
+ json.dump(dictionary, file, indent=4)
236
+
237
+ Parameters
238
+ ----------
239
+ file : str
240
+ filename of the SOFA convention
241
+
242
+ Returns
243
+ -------
244
+ convention : dict
245
+ SOFA convention as nested dictionary. Each attribute is a sub
246
+ dictionary with the keys `default`, `flags`, `dimensions`, `type`, and
247
+ `comment`.
248
+ """
249
+
250
+ # read the file
251
+ # (encoding could be changed to utf-8 after the SOFA conventions repo is
252
+ # clean.)
253
+ with open(file, 'r', encoding="windows-1252") as fid:
254
+ lines = fid.readlines()
255
+
256
+ # write into dict
257
+ convention = {}
258
+ for idl, line in enumerate(lines):
259
+
260
+ try:
261
+ # separate by tabs
262
+ line = line.strip().split("\t")
263
+ # parse the line entry by entry
264
+ for idc, cell in enumerate(line):
265
+ # detect empty cells and leading trailing white spaces
266
+ cell = None if cell.replace(' ', '') == '' else cell.strip()
267
+ # nothing to do for empty cells
268
+ if cell is None:
269
+ line[idc] = cell
270
+ continue
271
+ # parse text cells that do not contain arrays
272
+ if cell[0] != '[':
273
+ # check for numbers
274
+ with contextlib.suppress(ValueError):
275
+ cell = float(cell) if '.' in cell else int(cell)
276
+ line[idc] = cell
277
+ continue
278
+
279
+ # parse array cell
280
+ # remove brackets
281
+ cell = cell[1:-1]
282
+
283
+ if ';' not in cell:
284
+ # get rid of white spaces
285
+ cell = cell.strip()
286
+ cell = cell.replace(' ', ',')
287
+ cell = cell.replace(' ', '')
288
+ # create flat list of integers and floats
289
+ numbers = cell.split(',')
290
+ cell = [float(n) if '.' in n else int(n) for n in numbers]
291
+ else:
292
+ # create a nested list of integers and floats
293
+ # separate multidimensional arrays
294
+ cell = cell.split(';')
295
+ cell_nd = [None] * len(cell)
296
+ for idx, cc in enumerate(cell):
297
+ # get rid of white spaces
298
+ cc = cc.strip()
299
+ cc = cc.replace(' ', ',')
300
+ cc = cc.replace(' ', '')
301
+ numbers = cc.split(',')
302
+ cell_nd[idx] = [float(n) if '.' in n else int(n)
303
+ for n in numbers]
304
+
305
+ cell = cell_nd
306
+
307
+ # write parsed cell to line
308
+ line[idc] = cell
309
+
310
+ # first line contains field names
311
+ if idl == 0:
312
+ fields = line[1:]
313
+ continue
314
+
315
+ # add blank comment if it does not exist
316
+ if len(line) == 5:
317
+ line.append("")
318
+ # convert empty defaults from None to ""
319
+ if line[1] is None:
320
+ line[1] = ""
321
+
322
+ # make sure some unusual default values are converted for json
323
+ if line[1] == "permute([0 0 0 1 0 0; 0 0 0 1 0 0], [3 1 2]);":
324
+ # Field Data.SOS in SimpleFreeFieldHRSOS and SimpleFreeFieldSOS
325
+ line[1] = [[[0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 0, 0]]]
326
+ if line[1] == "permute([0 0 0 1 0 0], [3 1 2]);":
327
+ # Field Data.SOS in GeneralSOS
328
+ line[1] = [[[0, 0, 0, 1, 0, 0]]]
329
+ if line[1] == "{''}":
330
+ line[1] = ['']
331
+ # convert versions to strings
332
+ if "Version" in line[0] and not isinstance(line[1], str):
333
+ line[1] = str(float(line[1]))
334
+
335
+ # write second to last line
336
+ convention[line[0]] = {}
337
+ for ff, field in enumerate(fields):
338
+ convention[line[0]][field.lower()] = line[ff + 1]
339
+
340
+ except: # noqa: E722
341
+ raise ValueError((f"Failed to parse line {idl}, entry {idc} in: "
342
+ f"{file}: \n{line}\n")) from None
343
+
344
+ # reorder the fields to be nicer to read and understand
345
+ # 1. Move everything to the end that is not GLOBAL
346
+ keys = list(convention.keys())
347
+ for key in keys:
348
+ if "GLOBAL" not in key:
349
+ convention[key] = convention.pop(key)
350
+ # 1. Move Data entries to the end
351
+ for key in keys:
352
+ if key.startswith("Data"):
353
+ convention[key] = convention.pop(key)
354
+
355
+ return convention
356
+
357
+
358
+ def _check_congruency(save_dir=None, branch="master"):
359
+ """
360
+ SOFA conventions are stored in two different places.
361
+
362
+ They should be identical, but let's find out. Compares conventions on
363
+ sofaconventions.org to github.com/sofacoustics/SOFAtoolbox
364
+
365
+ Prints warnings about incongruent conventions
366
+
367
+ Parameters
368
+ ----------
369
+ save_dir : str
370
+ directory to save diverging conventions for further inspections.
371
+ branch : str
372
+ branch which is used to load conventions from github.
373
+ """
374
+
375
+ # urls for checking which conventions exist
376
+ urls_check = ["https://www.sofaconventions.org/conventions/",
377
+ ("https://github.com/sofacoustics/SOFAtoolbox/tree/"
378
+ f"{branch}/SOFAtoolbox/conventions/")]
379
+ # urls for loading the convention files
380
+ urls_load = ["https://www.sofaconventions.org/conventions/",
381
+ ("https://raw.githubusercontent.com/sofacoustics/SOFAtoolbox/"
382
+ f"{branch}/SOFAtoolbox/conventions/")]
383
+ subdirs = ["sofaconventions", "sofatoolbox"]
384
+
385
+ # check save_dir
386
+ if save_dir is not None:
387
+ if not os.path.isdir(save_dir):
388
+ raise ValueError(f"{save_dir} does not exist")
389
+ for subdir in subdirs:
390
+ if not os.path.isdir(os.path.join(save_dir, subdir)):
391
+ os.makedirs(os.path.join(save_dir, subdir))
392
+
393
+ # get file names of conventions from sofaconventions.org
394
+ url = urls_check[0]
395
+ page = requests.get(url).text
396
+ soup = BeautifulSoup(page, 'html.parser')
397
+ sofaconventions = [os.path.split(node.get('href'))[1]
398
+ for node in soup.find_all('a')
399
+ if node.get('href').endswith(".csv")]
400
+
401
+ if not sofaconventions:
402
+ raise ValueError(f"Did not find any conventions at {url}")
403
+
404
+ # get file names of conventions from github
405
+ url = urls_check[1]
406
+ page = requests.get(url).text
407
+ sofatoolbox = re.findall(
408
+ r'"SOFAtoolbox/conventions/([^"]+\.csv)"', page)
409
+
410
+ if not sofatoolbox:
411
+ raise ValueError(f"Did not find any conventions at {url}")
412
+
413
+ # check if lists are identical. Remove items not contained in both lists
414
+ report = ""
415
+ for convention in sofaconventions:
416
+ if convention.startswith(("General_", "GeneralString_")):
417
+ sofaconventions.remove(convention)
418
+ elif convention not in sofatoolbox:
419
+ sofaconventions.remove(convention)
420
+ report += (f"- {convention} is missing in SOFAtoolbox\n")
421
+ for convention in sofatoolbox:
422
+ if convention.startswith(("General_", "GeneralString_")):
423
+ sofatoolbox.remove(convention)
424
+ elif convention not in sofaconventions:
425
+ sofatoolbox.remove(convention)
426
+ report += (f"- {convention} is missing on sofaconventions.org\n")
427
+
428
+ # Loop and download conventions to check if they are identical
429
+ for convention in sofaconventions:
430
+
431
+ # download SOFA convention definitions to package directory
432
+ data = [requests.get(url + convention) for url in urls_load]
433
+ # remove trailing tabs and windows style line breaks
434
+ data = [d.content.replace(b"\r\n", b"\n").replace(b"\t\n", b"\n")
435
+ for d in data]
436
+
437
+ # check for equality
438
+ if data[0] != data[1]:
439
+ report += f"- {convention} differs across platforms\n"
440
+
441
+ # save diverging files
442
+ if save_dir is not None:
443
+ for subdir, d in zip(subdirs, data):
444
+ filename = os.path.join(save_dir, subdir, convention)
445
+ with open(filename, "wb") as file:
446
+ file.write(d)
447
+
448
+ if report:
449
+ print("Diverging conventions across platforms:\n" + report)