PyOPIA 2.9.2__tar.gz → 2.9.3__tar.gz

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 (29) hide show
  1. {pyopia-2.9.2 → pyopia-2.9.3}/PKG-INFO +1 -1
  2. pyopia-2.9.3/pyopia/__init__.py +1 -0
  3. pyopia-2.9.3/pyopia/cf_metadata.json +70 -0
  4. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/io.py +268 -139
  5. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/process.py +1 -1
  6. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/statistics.py +4 -4
  7. pyopia-2.9.3/pyopia/tests/test_io.py +64 -0
  8. pyopia-2.9.2/pyopia/__init__.py +0 -1
  9. {pyopia-2.9.2 → pyopia-2.9.3}/.gitignore +0 -0
  10. {pyopia-2.9.2 → pyopia-2.9.3}/LICENSE +0 -0
  11. {pyopia-2.9.2 → pyopia-2.9.3}/README.md +0 -0
  12. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/background.py +0 -0
  13. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/classify.py +0 -0
  14. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/cli.py +0 -0
  15. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/exampledata.py +0 -0
  16. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/__init__.py +0 -0
  17. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/common.py +0 -0
  18. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/holo.py +0 -0
  19. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/silcam.py +0 -0
  20. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/uvp.py +0 -0
  21. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/pipeline.py +0 -0
  22. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/plotting.py +0 -0
  23. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/simulator/__init__.py +0 -0
  24. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/simulator/silcam.py +0 -0
  25. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/__init__.py +0 -0
  26. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/test_classify.py +0 -0
  27. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/test_notebooks.py +0 -0
  28. {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/test_pipeline.py +0 -0
  29. {pyopia-2.9.2 → pyopia-2.9.3}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOPIA
3
- Version: 2.9.2
3
+ Version: 2.9.3
4
4
  Summary: A Python Ocean Particle Image Analysis toolbox.
5
5
  Project-URL: Repository, https://github.com/sintef/pyopia
6
6
  Project-URL: Documentation, https://pyopia.readthedocs.io
@@ -0,0 +1 @@
1
+ __version__ = "2.9.3"
@@ -0,0 +1,70 @@
1
+ {"major_axis_length": {
2
+ "standard_name": "major_axis_length",
3
+ "long_name": "The length of the major axis of the ellipse that has the same normalized second central moments as the region",
4
+ "units": "micrometer",
5
+ "calculation_method": "Computed using skimage.measure.regionprops (axis_major_length)",
6
+ "pyopia_process_level": 1},
7
+ "minor_axis_length": {
8
+ "standard_name": "minor_axis_length",
9
+ "long_name": "The length of the minor axis of the ellipse that has the same normalized second central moments as the region",
10
+ "units": "micrometer",
11
+ "calculation_method": "Computed using skimage.measure.regionprops (axis_minor_length)",
12
+ "pyopia_process_level": 1},
13
+ "equivalent_diameter": {
14
+ "standard_name": "equivalent_circular_diameter",
15
+ "long_name": "Diameter of a circle with the same area as the particle",
16
+ "units": "micrometer",
17
+ "calculation_method": "Computed using skimage.measure.regionprops (equivalent_diameter)",
18
+ "pyopia_process_level": 1},
19
+ "minr": {
20
+ "standard_name": "minimum_row_index",
21
+ "long_name": "Minimum row index of the particle bounding box",
22
+ "units": "pixels",
23
+ "calculation_method": "Extracted from skimage.measure.regionprops (bbox[0])",
24
+ "pyopia_process_level": 1},
25
+ "maxr": {
26
+ "standard_name": "maximum_row_index",
27
+ "long_name": "Maximum row index of the particle bounding box",
28
+ "units": "pixels",
29
+ "calculation_method": "Extracted from skimage.measure.regionprops (bbox[2])",
30
+ "pyopia_process_level": 1},
31
+ "minc": {
32
+ "standard_name": "minimum_column_index",
33
+ "long_name": "Minimum column index of the particle bounding box",
34
+ "units": "pixels",
35
+ "calculation_method": "Extracted from skimage.measure.regionprops (bbox[1])",
36
+ "pyopia_process_level": 1},
37
+ "maxc": {
38
+ "standard_name": "maximum_column_index",
39
+ "long_name": "Maximum column index of the particle bounding box",
40
+ "units": "pixels",
41
+ "calculation_method": "Extracted from skimage.measure.regionprops (bbox[3])",
42
+ "pyopia_process_level": 1},
43
+ "saturation": {
44
+ "standard_name": "image_saturation",
45
+ "long_name": "Percentage saturation of the image",
46
+ "units": "percent",
47
+ "calculation_method": "Computed as the percentage of the image covered by particles relative to the maximum acceptable coverage",
48
+ "pyopia_process_level": 1},
49
+ "index": {
50
+ "standard_name": "index",
51
+ "long_name": "Index of the particle in the dataset",
52
+ "units": "",
53
+ "calculation_method": "Sequential numbering of particles in the dataset",
54
+ "pyopia_process_level": 1},
55
+ "export_name": {
56
+ "standard_name": "export_name",
57
+ "long_name": "Name of the exported particle ROI file",
58
+ "units": "",
59
+ "calculation_method": "Generated during particle export",
60
+ "pyopia_process_level": 1},
61
+ "time": {
62
+ "standard_name": "time",
63
+ "long_name": "Time of particle observation",
64
+ "calculation_method": "Extracted from the timestamp of the observation",
65
+ "pyopia_process_level": 0},
66
+ "timestamp": {
67
+ "standard_name": "timestamp",
68
+ "long_name": "Timestamp of particle observation",
69
+ "calculation_method": "Recorded during particle observation",
70
+ "pyopia_process_level": 0}}
@@ -1,35 +1,41 @@
1
- '''
1
+ """
2
2
  Module containing tools for datafile and metadata handling
3
- '''
3
+ """
4
4
 
5
+ import logging
6
+ import os
5
7
  from datetime import datetime
6
- import numpy as np
8
+ from glob import glob
9
+
7
10
  import h5py
11
+ import numpy as np
8
12
  import pandas as pd
9
13
  import toml
10
14
  import xarray
11
- import os
12
- from glob import glob
13
15
  import xarray as xr
14
16
  from tqdm.auto import tqdm
15
17
 
18
+ import json
19
+ from pathlib import Path
20
+
16
21
  from pyopia import __version__ as pyopia_version
17
22
 
18
- import logging
19
23
  logger = logging.getLogger()
20
24
 
21
25
  # The netcdf4 engine seems to produce errors with the stats dataset, so we use h5netcdf instead
22
- NETCDF_ENGINE = 'h5netcdf'
23
-
24
-
25
- def write_stats(stats,
26
- datafilename,
27
- settings=None,
28
- export_name_len=40,
29
- dataformat='nc',
30
- append=True,
31
- image_stats=None):
32
- '''
26
+ NETCDF_ENGINE = "h5netcdf"
27
+
28
+
29
+ def write_stats(
30
+ stats,
31
+ datafilename,
32
+ settings=None,
33
+ export_name_len=40,
34
+ dataformat="nc",
35
+ append=True,
36
+ image_stats=None,
37
+ ):
38
+ """
33
39
  Writes particle stats into the ouput file.
34
40
  Appends if file already exists.
35
41
 
@@ -40,7 +46,7 @@ def write_stats(stats,
40
46
  stats : DataFrame or xr.Dataset
41
47
  particle statistics
42
48
  export_name_len : int
43
- Max number of chars allowed for col 'export name'
49
+ Max number of chars allowed for col 'export_name'
44
50
  append : bool
45
51
  Append all processed data into one nc file.
46
52
  Defaults to True.
@@ -51,45 +57,60 @@ def write_stats(stats,
51
57
  as the dataset gets larger.
52
58
  image_stats : xr.Dataset
53
59
  summary statistics of each raw image (including those with no particles)
54
- '''
60
+ """
55
61
 
56
62
  if len(stats) == 0: # to avoid issue with wrong time datatypes in xarray
57
63
  return
58
64
 
59
- if 'export name' in stats.columns:
60
- min_itemsize = {'export name': export_name_len}
65
+ if "export_name" in stats.columns:
66
+ min_itemsize = {"export_name": export_name_len}
61
67
  else:
62
68
  min_itemsize = None
63
69
 
64
- if dataformat == 'h5':
65
- with pd.HDFStore(datafilename + '-STATS.h5', 'a') as fh:
70
+ if dataformat == "h5":
71
+ with pd.HDFStore(datafilename + "-STATS.h5", "a") as fh:
66
72
  stats.to_hdf(
67
- fh, 'ParticleStats/stats', append=True, format='t',
68
- data_columns=True, min_itemsize=min_itemsize)
73
+ fh,
74
+ "ParticleStats/stats",
75
+ append=True,
76
+ format="t",
77
+ data_columns=True,
78
+ min_itemsize=min_itemsize,
79
+ )
69
80
 
70
81
  # metadata
71
- with h5py.File(datafilename + '-STATS.h5', "a") as fh:
72
- meta = fh.require_group('Meta')
73
- meta.attrs['Modified'] = str(datetime.now())
74
- meta.attrs['PyOpia version'] = pyopia_version
75
- meta.attrs['Pipeline steps'] = settings
76
- elif dataformat == 'nc':
77
-
82
+ with h5py.File(datafilename + "-STATS.h5", "a") as fh:
83
+ meta = fh.require_group("Meta")
84
+ meta.attrs["Modified"] = str(datetime.now())
85
+ meta.attrs["PyOPIA_version"] = pyopia_version
86
+ meta.attrs["steps"] = settings
87
+ elif dataformat == "nc":
78
88
  if isinstance(stats, xr.Dataset):
79
89
  xstats = stats
80
90
  else:
81
91
  xstats = make_xstats(stats, settings)
82
92
 
83
- if append and os.path.isfile(datafilename + '-STATS.nc'):
84
- existing_stats = load_stats(datafilename + '-STATS.nc')
85
- xstats = xarray.concat([existing_stats, xstats], 'index')
93
+ xstats = add_cf_attributes(xstats)
94
+
95
+ if append and os.path.isfile(datafilename + "-STATS.nc"):
96
+ existing_stats = load_stats(datafilename + "-STATS.nc")
97
+ xstats = xarray.concat([existing_stats, xstats], "index")
86
98
  elif not append:
87
99
  # When appending, only store the last row in the image_stats DataFrame
88
- datafilename += ('-Image-D' +
89
- str(xstats['timestamp'][0].values).replace('-', '').replace(':', '').replace('.', '-'))
100
+ datafilename += "-Image-D" + str(xstats["timestamp"][0].values).replace(
101
+ "-", ""
102
+ ).replace(":", "").replace(".", "-")
103
+
104
+ logger.info(f"Writing stats to file: {datafilename + '-STATS.nc'}")
105
+ logger.debug(f"Settings provided: {settings}")
90
106
 
91
107
  encoding = setup_xstats_encoding(xstats)
92
- xstats.to_netcdf(datafilename + '-STATS.nc', encoding=encoding, engine=NETCDF_ENGINE, format='NETCDF4')
108
+ xstats.to_netcdf(
109
+ datafilename + "-STATS.nc",
110
+ encoding=encoding,
111
+ engine=NETCDF_ENGINE,
112
+ format="NETCDF4",
113
+ )
93
114
 
94
115
  # If we have image statistics (summary data for each raw image), add the image_stats a group
95
116
  if image_stats is not None:
@@ -98,11 +119,16 @@ def write_stats(stats,
98
119
  else:
99
120
  ximage_stats = image_stats.loc[[image_stats.index[-1]], :].to_xarray()
100
121
 
101
- ximage_stats.to_netcdf(datafilename + '-STATS.nc', group='image_stats', mode='a', engine=NETCDF_ENGINE)
122
+ ximage_stats.to_netcdf(
123
+ datafilename + "-STATS.nc",
124
+ group="image_stats",
125
+ mode="a",
126
+ engine=NETCDF_ENGINE,
127
+ )
102
128
 
103
129
 
104
- def setup_xstats_encoding(xstats, string_vars=['export name', 'holo_filename']):
105
- '''Setup encoding for writing to NetCDF, where string variables are explicitly defined as string types
130
+ def setup_xstats_encoding(xstats, string_vars=["export_name", "holo_filename"]):
131
+ """Setup encoding for writing to NetCDF, where string variables are explicitly defined as string types
106
132
 
107
133
  Notes
108
134
  -----
@@ -115,19 +141,19 @@ def setup_xstats_encoding(xstats, string_vars=['export name', 'holo_filename']):
115
141
  xstats : xarray.Dataset
116
142
  Xarray version of stats dataframe, including metadata
117
143
  string_vars : list, optional
118
- list of string columns in xstats, by default ['export name', 'holo_filename']
144
+ list of string columns in xstats, by default ['export_name', 'holo_filename']
119
145
 
120
146
  Returns
121
147
  -------
122
148
  encoding : dict
123
149
  'encoding' input argument to be given to xstats.to_netcdf()
124
- '''
125
- encoding = {k: {'dtype': 'str'} for k in string_vars if k in xstats.data_vars}
150
+ """
151
+ encoding = {k: {"dtype": "str"} for k in string_vars if k in xstats.data_vars}
126
152
  return encoding
127
153
 
128
154
 
129
155
  def make_xstats(stats, toml_steps):
130
- '''Converts a stats dataframe into xarray DataSet, with metadata
156
+ """Converts a stats dataframe into xarray DataSet, with metadata
131
157
 
132
158
  Parameters
133
159
  ----------
@@ -140,17 +166,17 @@ def make_xstats(stats, toml_steps):
140
166
  -------
141
167
  xstats : xarray.Dataset
142
168
  Xarray version of stats dataframe, including metadata
143
- '''
169
+ """
144
170
  xstats = stats.to_xarray()
145
171
  xstats.attrs["steps"] = toml.dumps(toml_steps)
146
- xstats.attrs['Modified'] = str(datetime.now())
147
- xstats.attrs['PyOpia version'] = pyopia_version
172
+ xstats.attrs["Modified"] = str(datetime.now())
173
+ xstats.attrs["PyOPIA_version"] = pyopia_version
148
174
  xstats = xstats.assign_coords(time=xstats.timestamp)
149
175
  return xstats
150
176
 
151
177
 
152
178
  def load_image_stats(datafilename):
153
- '''Load the summary stats and time information for each image
179
+ """Load the summary stats and time information for each image
154
180
 
155
181
  Parameters
156
182
  ----------
@@ -161,14 +187,16 @@ def load_image_stats(datafilename):
161
187
  -------
162
188
  image_stats : xarray.Dataset
163
189
  summary statistics of each raw image (including those with no particles)
164
- '''
165
- with xarray.open_dataset(datafilename, engine=NETCDF_ENGINE, group='image_stats') as image_stats:
190
+ """
191
+ with xarray.open_dataset(
192
+ datafilename, engine=NETCDF_ENGINE, group="image_stats"
193
+ ) as image_stats:
166
194
  image_stats.load()
167
195
  return image_stats
168
196
 
169
197
 
170
198
  def load_stats(datafilename):
171
- '''Load -STATS.nc file as xarray Dataset
199
+ """Load -STATS.nc file as xarray Dataset
172
200
 
173
201
  .. warning:: Support for loading of old -STATS.h5 formats will be removed in version 3.0.0.
174
202
  They will need to be converted to .nc prior to loading.
@@ -183,26 +211,28 @@ def load_stats(datafilename):
183
211
  -------
184
212
  xstats : xarray.Dataset
185
213
  Particle statistics
186
- '''
214
+ """
187
215
 
188
- if datafilename.endswith('.nc'):
216
+ if datafilename.endswith(".nc"):
189
217
  with xarray.open_dataset(datafilename, engine=NETCDF_ENGINE) as xstats:
190
218
  xstats.load()
191
- elif datafilename.endswith('.h5'):
192
- logger.warning('In future, load_stats will only take .nc files')
193
- stats = pd.read_hdf(datafilename, 'ParticleStats/stats')
219
+ elif datafilename.endswith(".h5"):
220
+ logger.warning("In future, load_stats will only take .nc files")
221
+ stats = pd.read_hdf(datafilename, "ParticleStats/stats")
194
222
  xstats = stats.to_xarray()
195
223
  else:
196
- logger.warning('WARNING. File extension not specified.' +
197
- 'Assuming prefix of -STATS.h5 for backwards compatability.' +
198
- 'In future, load_stats will only take .nc files')
199
- stats = pd.read_hdf(datafilename + '-STATS.h5', 'ParticleStats/stats')
224
+ logger.warning(
225
+ "WARNING. File extension not specified."
226
+ + "Assuming prefix of -STATS.h5 for backwards compatability."
227
+ + "In future, load_stats will only take .nc files"
228
+ )
229
+ stats = pd.read_hdf(datafilename + "-STATS.h5", "ParticleStats/stats")
200
230
  xstats = stats.to_xarray()
201
231
  return xstats
202
232
 
203
233
 
204
- def combine_stats_netcdf_files(path_to_data, prefix='*'):
205
- '''.. deprecated:: 2.4.11
234
+ def combine_stats_netcdf_files(path_to_data, prefix="*"):
235
+ """.. deprecated:: 2.4.11
206
236
  :class:`pyopia.io.combine_stats_netcdf_files` will be removed in version 3.0.0, it is replaced by
207
237
  :class:`pyopia.io.concat_stats_netcdf_files`.
208
238
 
@@ -224,27 +254,37 @@ def combine_stats_netcdf_files(path_to_data, prefix='*'):
224
254
  Particle statistics and metatdata from processing steps
225
255
  image_stats : xarray.Dataset
226
256
  summary statistics of each raw image (including those with no particles)
227
- '''
228
-
229
- sorted_filelist = sorted(glob(os.path.join(path_to_data, prefix + 'Image-D*-STATS.nc')))
230
- with xarray.open_mfdataset(sorted_filelist, combine='nested', concat_dim='index',
231
- decode_cf=True, parallel=False,
232
- coords='minimal', compat='override') as ds:
257
+ """
258
+
259
+ sorted_filelist = sorted(
260
+ glob(os.path.join(path_to_data, prefix + "Image-D*-STATS.nc"))
261
+ )
262
+ with xarray.open_mfdataset(
263
+ sorted_filelist,
264
+ combine="nested",
265
+ concat_dim="index",
266
+ decode_cf=True,
267
+ parallel=False,
268
+ coords="minimal",
269
+ compat="override",
270
+ ) as ds:
233
271
  xstats = ds.load()
234
272
 
235
273
  # Check if we have image statistics, if so, load it.
236
274
  try:
237
- with xarray.open_mfdataset(sorted_filelist, group='image_stats') as ds:
275
+ with xarray.open_mfdataset(sorted_filelist, group="image_stats") as ds:
238
276
  image_stats = ds.load()
239
277
  except OSError:
240
- logger.info('Could get image_stats from netcdf files for merging, returning None for this.')
278
+ logger.info(
279
+ "Could get image_stats from netcdf files for merging, returning None for this."
280
+ )
241
281
  image_stats = None
242
282
 
243
283
  return xstats, image_stats
244
284
 
245
285
 
246
286
  def concat_stats_netcdf_files(sorted_filelist):
247
- '''Concatenate specified list of STATS.nc files into one 'xstats' xarray dataset
287
+ """Concatenate specified list of STATS.nc files into one 'xstats' xarray dataset
248
288
  created by :func:`pyopia.io.write_stats when using 'append = false'.
249
289
 
250
290
  Existing files are first loaded and then combined, so memory usage will go up with longer file lists.
@@ -260,9 +300,9 @@ def concat_stats_netcdf_files(sorted_filelist):
260
300
  Particle statistics and metatdata from processing steps
261
301
  image_stats : xarray.Dataset or None
262
302
  Summary statistics of each raw image (including those with no particles)
263
- '''
303
+ """
264
304
  if len(sorted_filelist) < 1:
265
- logger.error('No files found to concatenate, doing nothing.')
305
+ logger.error("No files found to concatenate, doing nothing.")
266
306
  return None, None
267
307
 
268
308
  # We load one dataset at the time into a list for later merge
@@ -272,35 +312,41 @@ def concat_stats_netcdf_files(sorted_filelist):
272
312
  # Check if we have image statistics in first file, if not, we skip checking the rest
273
313
  skip_image_stats = False
274
314
  try:
275
- with xr.open_dataset(sorted_filelist[0], group='image_stats', engine=NETCDF_ENGINE) as ds:
315
+ with xr.open_dataset(
316
+ sorted_filelist[0], group="image_stats", engine=NETCDF_ENGINE
317
+ ) as ds:
276
318
  ds.load()
277
319
  except OSError:
278
- logger.info('Could get image_stats from netcdf files for merging, returning None for this.')
320
+ logger.info(
321
+ "Could get image_stats from netcdf files for merging, returning None for this."
322
+ )
279
323
  skip_image_stats = True
280
324
 
281
325
  # Load datasets from each file into the lists
282
- for f in tqdm(sorted_filelist, desc='Loading datasets'):
326
+ for f in tqdm(sorted_filelist, desc="Loading datasets"):
283
327
  with xr.open_dataset(f, engine=NETCDF_ENGINE) as ds:
284
328
  ds.load()
285
329
  datasets.append(ds)
286
330
 
287
331
  if not skip_image_stats:
288
- with xr.open_dataset(f, group='image_stats', engine=NETCDF_ENGINE) as ds:
332
+ with xr.open_dataset(f, group="image_stats", engine=NETCDF_ENGINE) as ds:
289
333
  ds.load()
290
334
  datasets_image_stats.append(ds)
291
335
 
292
336
  # Combine the individual datasets loaded above
293
- logging.info('Combining datasets')
294
- xstats = xr.concat(datasets, dim='index')
337
+ logging.info("Combining datasets")
338
+ xstats = xr.concat(datasets, dim="index")
295
339
  image_stats = None
296
340
  if not skip_image_stats:
297
- image_stats = xr.concat(datasets_image_stats, dim='timestamp')
341
+ image_stats = xr.concat(datasets_image_stats, dim="timestamp")
298
342
 
299
343
  return xstats, image_stats
300
344
 
301
345
 
302
- def merge_and_save_mfdataset(path_to_data, prefix='*', overwrite_existing_partials=False, chunk_size=None):
303
- '''Combine a multi-file directory of STATS.nc files into a single '-STATS.nc' file
346
+ def merge_and_save_mfdataset(
347
+ path_to_data, prefix="*", overwrite_existing_partials=False, chunk_size=None
348
+ ):
349
+ """Combine a multi-file directory of STATS.nc files into a single '-STATS.nc' file
304
350
  that can then be loaded with :func:`pyopia.io.load_stats`
305
351
 
306
352
  Parameters
@@ -323,49 +369,68 @@ def merge_and_save_mfdataset(path_to_data, prefix='*', overwrite_existing_partia
323
369
  of input files divided by chunk_size. The last chunk may contain less
324
370
  files than specified, depending on the total number of files.
325
371
  Default: None, which processes all files together.
326
- '''
327
- logging.info(f'Combine stats netcdf files from {path_to_data}')
372
+ """
373
+ logging.info(f"Combine stats netcdf files from {path_to_data}")
328
374
  if (chunk_size is not None) and (chunk_size < 1):
329
- raise ValueError(f'Invalid chunk size, must be greater than 0, was {chunk_size}')
375
+ raise ValueError(
376
+ f"Invalid chunk size, must be greater than 0, was {chunk_size}"
377
+ )
330
378
 
331
379
  # Get sorted list of per-image stats netcdf files
332
- sorted_filelist = sorted(glob(os.path.join(path_to_data, prefix + 'Image-D*-STATS.nc')))
380
+ sorted_filelist = sorted(
381
+ glob(os.path.join(path_to_data, prefix + "Image-D*-STATS.nc"))
382
+ )
333
383
 
384
+ # fmt: off
334
385
  # Chunk the file list into smaller parts if specified
335
386
  num_files = len(sorted_filelist)
336
387
  chunk_size_used = num_files if chunk_size is None else min(chunk_size, num_files)
337
388
  num_chunks = int(np.ceil(num_files / chunk_size_used))
338
- filelist_chunks = [sorted_filelist[i*chunk_size_used:min(num_files, (i+1)*chunk_size_used)] for i in range(num_chunks)]
339
- infostr = f'Processing {num_chunks} partial file lists of {chunk_size_used} files each'
340
- infostr += f', based on a total of {num_files} files.'
389
+ filelist_chunks = [
390
+ sorted_filelist[i * chunk_size_used:min(num_files, (i + 1) * chunk_size_used)]
391
+ for i in range(num_chunks)
392
+ ]
393
+ infostr = (
394
+ f"Processing {num_chunks} partial file lists of {chunk_size_used} files each"
395
+ )
396
+ infostr += f", based on a total of {num_files} files."
341
397
  logging.info(infostr)
398
+ # fmt: on
342
399
 
343
400
  # Get config from first file in list
344
401
  xstats = load_stats(sorted_filelist[0])
345
402
  settings = steps_from_xstats(xstats)
346
- prefix_out = os.path.basename(settings['steps']['output']['output_datafile'])
403
+ prefix_out = os.path.basename(settings["steps"]["output"]["output_datafile"])
347
404
  encoding = setup_xstats_encoding(xstats)
348
405
 
349
406
  def process_store(i, filelist_):
350
- output_name = os.path.join(path_to_data, f'part-{i:04d}-{prefix_out}-STATS.nc')
407
+ output_name = os.path.join(path_to_data, f"part-{i:04d}-{prefix_out}-STATS.nc")
351
408
 
352
409
  # Skip this chunk if the merged output file exists and overwrite is set to False
353
410
  if os.path.exists(output_name) and not overwrite_existing_partials:
354
- logging.info(f'File exists ({output_name}), skipping')
411
+ logging.info(f"File exists ({output_name}), skipping")
355
412
  return output_name
356
413
 
357
414
  # Load the individual datasets
358
415
  xstats, image_stats = concat_stats_netcdf_files(filelist_)
359
416
 
360
417
  # Save the particle statistics (xstats) to NetCDF
361
- logging.info(f'Writing {output_name}')
418
+ logging.info(f"Writing {output_name}")
362
419
  if xstats is not None:
363
- xstats.to_netcdf(output_name, mode='w', encoding=encoding, engine=NETCDF_ENGINE, format='NETCDF4')
420
+ xstats.to_netcdf(
421
+ output_name,
422
+ mode="w",
423
+ encoding=encoding,
424
+ engine=NETCDF_ENGINE,
425
+ format="NETCDF4",
426
+ )
364
427
 
365
428
  # If summary data for each raw image are available (image_stats), save this into the image_stats group
366
429
  if image_stats is not None:
367
- image_stats.to_netcdf(output_name, group='image_stats', mode='a', engine=NETCDF_ENGINE)
368
- logging.info(f'Writing {output_name} done.')
430
+ image_stats.to_netcdf(
431
+ output_name, group="image_stats", mode="a", engine=NETCDF_ENGINE
432
+ )
433
+ logging.info(f"Writing {output_name} done.")
369
434
 
370
435
  return output_name
371
436
 
@@ -376,19 +441,27 @@ def merge_and_save_mfdataset(path_to_data, prefix='*', overwrite_existing_partia
376
441
  merged_files.append(output_name)
377
442
 
378
443
  # Finally, merge the partially merged files
379
- logging.info('Doing final merge of partially merged files')
380
- output_name = os.path.join(path_to_data, prefix_out + '-STATS.nc')
381
- with xr.open_mfdataset(merged_files, concat_dim='index', combine='nested') as ds:
382
- ds.to_netcdf(output_name, mode='w', encoding=encoding, engine=NETCDF_ENGINE, format='NETCDF4')
444
+ logging.info("Doing final merge of partially merged files")
445
+ output_name = os.path.join(path_to_data, prefix_out + "-STATS.nc")
446
+ with xr.open_mfdataset(merged_files, concat_dim="index", combine="nested") as ds:
447
+ ds.to_netcdf(
448
+ output_name,
449
+ mode="w",
450
+ encoding=encoding,
451
+ engine=NETCDF_ENGINE,
452
+ format="NETCDF4",
453
+ )
383
454
 
384
- with xr.open_mfdataset(merged_files, group='image_stats', concat_dim='timestamp', combine='nested') as ds:
385
- ds.to_netcdf(output_name, mode='a', group='image_stats', engine=NETCDF_ENGINE)
455
+ with xr.open_mfdataset(
456
+ merged_files, group="image_stats", concat_dim="timestamp", combine="nested"
457
+ ) as ds:
458
+ ds.to_netcdf(output_name, mode="a", group="image_stats", engine=NETCDF_ENGINE)
386
459
 
387
- logging.info(f'Writing {output_name} done.')
460
+ logging.info(f"Writing {output_name} done.")
388
461
 
389
462
 
390
463
  def steps_from_xstats(xstats):
391
- '''Get the steps attribute from xarray version of the particle stats into a dictionary
464
+ """Get the steps attribute from xarray version of the particle stats into a dictionary
392
465
 
393
466
  Parameters
394
467
  ----------
@@ -399,13 +472,13 @@ def steps_from_xstats(xstats):
399
472
  -------
400
473
  steps : dict
401
474
  TOML-formatted dictionary of pipeline steps
402
- '''
403
- steps = toml.loads(xstats.__getattr__('steps'))
475
+ """
476
+ steps = toml.loads(xstats.__getattr__("steps"))
404
477
  return steps
405
478
 
406
479
 
407
480
  def load_stats_as_dataframe(stats_file):
408
- '''A loading function for stats files that forces stats into a pandas DataFrame
481
+ """A loading function for stats files that forces stats into a pandas DataFrame
409
482
 
410
483
  Parameters
411
484
  ----------
@@ -416,38 +489,40 @@ def load_stats_as_dataframe(stats_file):
416
489
  -------
417
490
  stats : DataFrame
418
491
  stats pandas dataframe
419
- '''
492
+ """
420
493
  # obtain particle statistics from the stats file
421
494
  stats = load_stats(stats_file)
422
495
  try:
423
496
  stats = stats.to_dataframe()
424
497
  except AttributeError:
425
- logger.info('STATS was likely loaded from an old h5 format, \
426
- which will be deprecated in future. Please use NetCDF in future.')
498
+ logger.info(
499
+ "STATS was likely loaded from an old h5 format, \
500
+ which will be deprecated in future. Please use NetCDF in future."
501
+ )
427
502
  pass
428
503
  return stats
429
504
 
430
505
 
431
506
  def show_h5_meta(h5file):
432
- '''
507
+ """
433
508
  prints metadata from an exported hdf5 file created from pyopia.process
434
509
 
435
510
  Parameters
436
511
  ----------
437
512
  h5file : str
438
513
  h5 filename from exported data from pyopia.process
439
- '''
514
+ """
440
515
 
441
- with h5py.File(h5file, 'r') as f:
442
- keys = list(f['Meta'].attrs.keys())
516
+ with h5py.File(h5file, "r") as f:
517
+ keys = list(f["Meta"].attrs.keys())
443
518
 
444
519
  for k in keys:
445
- logger.info(k + ':')
446
- logger.info(' ' + f['Meta'].attrs[k])
520
+ logger.info(k + ":")
521
+ logger.info(" " + f["Meta"].attrs[k])
447
522
 
448
523
 
449
- class StatsToDisc():
450
- '''PyOpia pipline-compatible class for calling write_stats() that created NetCDF files.
524
+ class StatsToDisc:
525
+ """PyOpia pipline-compatible class for calling write_stats() that created NetCDF files.
451
526
 
452
527
  Parameters
453
528
  ----------
@@ -456,7 +531,7 @@ class StatsToDisc():
456
531
  dataformat : str
457
532
  either 'nc' or 'h5
458
533
  export_name_len : int
459
- max number of chars allowed for col 'export name'. Defaults to 40
534
+ max number of chars allowed for col 'export_name'. Defaults to 40
460
535
  append : bool
461
536
  Append all processed data into one nc file.
462
537
  Defaults to True.
@@ -480,31 +555,32 @@ class StatsToDisc():
480
555
  pipeline_class = 'pyopia.io.StatsToDisc'
481
556
  output_datafile = './test' # prefix path for output nc file
482
557
  append = true
483
- '''
484
- def __init__(self,
485
- output_datafile='data',
486
- dataformat='nc',
487
- export_name_len=40,
488
- append=True):
558
+ """
489
559
 
560
+ def __init__(
561
+ self, output_datafile="data", dataformat="nc", export_name_len=40, append=True
562
+ ):
490
563
  self.output_datafile = output_datafile
491
564
  self.dataformat = dataformat
492
565
  self.export_name_len = export_name_len
493
566
  self.append = append
494
567
 
495
568
  def __call__(self, data):
496
- write_stats(data['stats'], self.output_datafile,
497
- settings=data['settings'],
498
- dataformat=self.dataformat,
499
- export_name_len=self.export_name_len,
500
- append=self.append,
501
- image_stats=data['image_stats'])
569
+ write_stats(
570
+ data["stats"],
571
+ self.output_datafile,
572
+ settings=data["settings"],
573
+ dataformat=self.dataformat,
574
+ export_name_len=self.export_name_len,
575
+ append=self.append,
576
+ image_stats=data["image_stats"],
577
+ )
502
578
 
503
579
  return data
504
580
 
505
581
 
506
582
  def load_toml(toml_file):
507
- '''Load a TOML settings file from file
583
+ """Load a TOML settings file from file
508
584
 
509
585
  Parameters
510
586
  ----------
@@ -515,14 +591,14 @@ def load_toml(toml_file):
515
591
  -------
516
592
  settings : dict
517
593
  TOML settings
518
- '''
519
- with open(toml_file, 'r') as f:
594
+ """
595
+ with open(toml_file, "r") as f:
520
596
  settings = toml.load(f)
521
597
  return settings
522
598
 
523
599
 
524
600
  def StatsH5(**kwargs):
525
- '''.. deprecated:: 2.4.8
601
+ """.. deprecated:: 2.4.8
526
602
  :class:`pyopia.io.StatsH5` will be removed in version 3.0.0, it is replaced by
527
603
  :class:`pyopia.io.StatsToDisc`.
528
604
 
@@ -535,7 +611,7 @@ def StatsH5(**kwargs):
535
611
  dataformat : str
536
612
  either 'nc' or 'h5
537
613
  export_name_len : int
538
- max number of chars allowed for col 'export name'. Defaults to 40
614
+ max number of chars allowed for col 'export_name'. Defaults to 40
539
615
  append : bool
540
616
  Append all processed data into one nc file.
541
617
  Defaults to True.
@@ -559,6 +635,59 @@ def StatsH5(**kwargs):
559
635
  pipeline_class = 'pyopia.io.StatsH5'
560
636
  output_datafile = './test' # prefix path for output nc file
561
637
  append = true
562
- '''
563
- logger.warning('StatsH5 will be removed in version 3.0.0, it is replaced by pyopia.io.StatsToDisc')
638
+ """
639
+ logger.warning(
640
+ "StatsH5 will be removed in version 3.0.0, it is replaced by pyopia.io.StatsToDisc"
641
+ )
564
642
  return StatsToDisc(**kwargs)
643
+
644
+
645
+ def get_cf_metadata_spec():
646
+ path_to_cf_json = Path(__file__).parent / Path("cf_metadata.json")
647
+ with open(path_to_cf_json, "r") as file:
648
+ return json.load(file)
649
+
650
+
651
+ def add_cf_attributes(xstats):
652
+ """
653
+ Adds CF-compliant global attributes and units to the xarray Dataset.
654
+
655
+ Parameters
656
+ ----------
657
+ xstats : xarray.Dataset
658
+ The dataset to which CF-compliant attributes will be added.
659
+ """
660
+
661
+ # Read in CF Metadata from .json specification file
662
+ cf_metadata = get_cf_metadata_spec()
663
+
664
+ # Add CF-compliant global attributes
665
+ xstats.attrs["Conventions"] = "CF-1.8"
666
+ xstats.attrs["license"] = "CC-BY-4.0"
667
+ xstats.attrs["title"] = "PyOPIA Particle Statistics Dataset"
668
+ xstats.attrs["summary"] = (
669
+ "This dataset contains particle statistics generated by the PyOPIA pipeline."
670
+ )
671
+ xstats.attrs["institution"] = ""
672
+ xstats.attrs["source"] = "PyOPIA pipeline"
673
+
674
+ # Apply metadata from CF_METADATA if available
675
+ for var in xstats.data_vars:
676
+ if var in cf_metadata:
677
+ metadata = cf_metadata[var]
678
+ xstats[var].attrs.update(metadata)
679
+
680
+ # Variables starting with probability_ are handled separately
681
+ if var.startswith("probability_"):
682
+ class_name = "_".join(var.split("_")[1:])
683
+ xstats[var].attrs["long_name"] = (
684
+ f"Probability of particle belonging to class {class_name}"
685
+ )
686
+ xstats[var].attrs["units"] = "dimensionless fraction"
687
+ xstats[var].attrs["calculation_method"] = (
688
+ "Determined by neural network classifier"
689
+ )
690
+ xstats[var].attrs["standard_name"] = f"probability_of_{class_name}"
691
+ xstats[var].attrs["pyopia_process_level"] = 1
692
+
693
+ return xstats
@@ -337,7 +337,7 @@ def extract_particles(imc, timestamp, Classification, region_properties,
337
337
 
338
338
  # add the filenames of the HDF5 file and particle number tag to the
339
339
  # particle statistics data
340
- stats['export name'] = pd.Series(index=stats.index, data=filenames, dtype=str)
340
+ stats['export_name'] = pd.Series(index=stats.index, data=filenames, dtype=str)
341
341
 
342
342
  return stats
343
343
 
@@ -476,7 +476,7 @@ def gen_roifiles(stats, auto_scaler=500):
476
476
  a list of string of filenames that can be passed to montage_maker() for making nice montages
477
477
  '''
478
478
 
479
- roifiles = stats['export name'][stats['export name'] != 'not_exported'].values
479
+ roifiles = stats['export_name'][stats['export_name'] != 'not_exported'].values
480
480
 
481
481
  # subsample the particles if necessary
482
482
  logger.info('rofiles: {0}'.format(len(roifiles)))
@@ -699,17 +699,17 @@ def add_depth_to_stats(stats, time, depth):
699
699
 
700
700
 
701
701
  def roi_from_export_name(exportname, path):
702
- '''Returns an image from the export name string in the -STATS.h5 file
702
+ '''Returns an image from the export_name string in the -STATS.h5 file
703
703
 
704
704
  Get the exportname like this:
705
705
  ```python
706
- exportname = stats['export name'].values[0]
706
+ exportname = stats['export_name'].values[0]
707
707
  ```
708
708
 
709
709
  Parameters
710
710
  ----------
711
711
  exportname : str
712
- string containing the name of the exported particle e.g. stats['export name'].values[0]
712
+ string containing the name of the exported particle e.g. stats['export_name'].values[0]
713
713
  path : str
714
714
  path to exported h5 files
715
715
 
@@ -0,0 +1,64 @@
1
+ import os
2
+ from pathlib import Path
3
+ import pytest
4
+ import pandas as pd
5
+ from pyopia.io import write_stats, load_stats, get_cf_metadata_spec
6
+ from pyopia.instrument.silcam import generate_config
7
+
8
+
9
+ pipeline_config = generate_config(
10
+ raw_files="images/*.silc",
11
+ model_path="None",
12
+ outfolder="processed",
13
+ output_prefix="test",
14
+ )
15
+
16
+
17
+ def test_write_and_load_stats(tmp_path: Path):
18
+ # Create a temporary directory for testing
19
+ temp_dir = tmp_path / "test_data"
20
+ temp_dir.mkdir()
21
+
22
+ # Create a sample DataFrame to write
23
+ data = {
24
+ "major_axis_length": [10.5, 20.3],
25
+ "minor_axis_length": [5.2, 10.1],
26
+ "equivalent_diameter": [7.8, 15.2],
27
+ "saturation": [50.0, 75.0],
28
+ }
29
+ # Convert timestamp to datetime format
30
+ data["timestamp"] = pd.to_datetime(["2025-04-25T10:00:00", "2025-04-25T10:05:00"])
31
+ stats_df = pd.DataFrame(data)
32
+
33
+ # Define the output file path
34
+ output_file = os.path.join(temp_dir, "test")
35
+
36
+ # Provide a minimal valid settings object
37
+ config = pipeline_config
38
+
39
+ # Write the stats to a NetCDF file
40
+ write_stats(stats_df, output_file, settings=config, dataformat="nc", append=True)
41
+
42
+ # Check if the file was created
43
+ assert os.path.exists(output_file + "-STATS.nc")
44
+
45
+ # Load the stats back
46
+ loaded_stats = load_stats(output_file + "-STATS.nc")
47
+
48
+ # Verify the data matches
49
+ for var in data.keys():
50
+ assert var in loaded_stats.data_vars
51
+ assert all(loaded_stats[var].values == stats_df[var].values)
52
+
53
+ # Verify CF_METADATA attributes
54
+ for var, metadata in get_cf_metadata_spec().items():
55
+ if var in loaded_stats.data_vars:
56
+ for attr, value in metadata.items():
57
+ assert loaded_stats[var].attrs.get(attr) == value
58
+
59
+ # Check that version tag is in the attributes
60
+ assert "PyOPIA_version" in loaded_stats.attrs
61
+
62
+
63
+ if __name__ == "__main__":
64
+ pytest.main()
@@ -1 +0,0 @@
1
- __version__ = "2.9.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes