PyOPIA 2.12.0__tar.gz → 2.13.0__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 (34) hide show
  1. {pyopia-2.12.0 → pyopia-2.13.0}/PKG-INFO +20 -2
  2. {pyopia-2.12.0 → pyopia-2.13.0}/README.md +19 -1
  3. pyopia-2.13.0/pyopia/__init__.py +1 -0
  4. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/cli.py +44 -0
  5. pyopia-2.13.0/pyopia/dataexport/__init__.py +0 -0
  6. pyopia-2.13.0/pyopia/dataexport/ecotaxa.py +239 -0
  7. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/statistics.py +238 -127
  8. pyopia-2.12.0/pyopia/__init__.py +0 -1
  9. {pyopia-2.12.0 → pyopia-2.13.0}/.gitignore +0 -0
  10. {pyopia-2.12.0 → pyopia-2.13.0}/LICENSE +0 -0
  11. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/auxillarydata.py +0 -0
  12. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/background.py +0 -0
  13. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/cf_metadata.json +0 -0
  14. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/classify.py +0 -0
  15. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/exampledata.py +0 -0
  16. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/__init__.py +0 -0
  17. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/common.py +0 -0
  18. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/holo.py +0 -0
  19. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/silcam.py +0 -0
  20. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/uvp.py +0 -0
  21. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/io.py +0 -0
  22. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/metadata.py +0 -0
  23. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/pipeline.py +0 -0
  24. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/plotting.py +0 -0
  25. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/process.py +0 -0
  26. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/simulator/__init__.py +0 -0
  27. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/simulator/silcam.py +0 -0
  28. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/__init__.py +0 -0
  29. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_auxillarydata.py +0 -0
  30. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_classify.py +0 -0
  31. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_io.py +0 -0
  32. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_notebooks.py +0 -0
  33. {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_pipeline.py +0 -0
  34. {pyopia-2.12.0 → pyopia-2.13.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOPIA
3
- Version: 2.12.0
3
+ Version: 2.13.0
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
@@ -57,11 +57,29 @@ A Python Ocean Particle Image Analysis toolbox
57
57
  1) Install [uv](https://docs.astral.sh/uv/getting-started/installation)
58
58
  2) Initialize PyOPIA project with a small example image dataset and run processing
59
59
  ```bash
60
- uvx --python 3.12 --from pyopia[classification] pyopia --init-project pyopiatest --example-data
60
+ uvx --python 3.12 --from pyopia[classification] pyopia init-project pyopiatest --example-data
61
+ ```
62
+ ```bash
61
63
  cd pyopiatest
64
+ ```
65
+ ```bash
62
66
  uvx --python 3.12 --from pyopia[classification] pyopia process config.toml
63
67
  ```
64
68
  3) Inspect the processed particle statistics in the processed/ folder
69
+ 4) Merge the individual processed image STATS files into a single STATS.nc file, then create a montage of the processed STATS.nc
70
+
71
+ ```bash
72
+ uvx --python 3.12 --from pyopia[classification] pyopia merge-mfdata processed
73
+ ```
74
+
75
+ ```bash
76
+ uvx --python 3.12 --from pyopia[classification] pyopia make-montage processed\pyopiatest-STATS.nc
77
+ ```
78
+ 5) Visualise the monatge of all processed singular particle images in one
79
+ ```bash
80
+ montage.png
81
+ ```
82
+ Will show you a montage of all the processed particle images in one.
65
83
 
66
84
  See the documentation for more information on how to install and use PyOPIA.
67
85
 
@@ -8,11 +8,29 @@ A Python Ocean Particle Image Analysis toolbox
8
8
  1) Install [uv](https://docs.astral.sh/uv/getting-started/installation)
9
9
  2) Initialize PyOPIA project with a small example image dataset and run processing
10
10
  ```bash
11
- uvx --python 3.12 --from pyopia[classification] pyopia --init-project pyopiatest --example-data
11
+ uvx --python 3.12 --from pyopia[classification] pyopia init-project pyopiatest --example-data
12
+ ```
13
+ ```bash
12
14
  cd pyopiatest
15
+ ```
16
+ ```bash
13
17
  uvx --python 3.12 --from pyopia[classification] pyopia process config.toml
14
18
  ```
15
19
  3) Inspect the processed particle statistics in the processed/ folder
20
+ 4) Merge the individual processed image STATS files into a single STATS.nc file, then create a montage of the processed STATS.nc
21
+
22
+ ```bash
23
+ uvx --python 3.12 --from pyopia[classification] pyopia merge-mfdata processed
24
+ ```
25
+
26
+ ```bash
27
+ uvx --python 3.12 --from pyopia[classification] pyopia make-montage processed\pyopiatest-STATS.nc
28
+ ```
29
+ 5) Visualise the monatge of all processed singular particle images in one
30
+ ```bash
31
+ montage.png
32
+ ```
33
+ Will show you a montage of all the processed particle images in one.
16
34
 
17
35
  See the documentation for more information on how to install and use PyOPIA.
18
36
 
@@ -0,0 +1 @@
1
+ __version__ = "2.13.0"
@@ -41,6 +41,7 @@ import pyopia.statistics
41
41
  import pyopia.auxillarydata
42
42
  import pyopia.exampledata
43
43
  import pyopia.metadata
44
+ import pyopia.dataexport.ecotaxa
44
45
 
45
46
  app = typer.Typer()
46
47
 
@@ -473,6 +474,49 @@ def make_montage(
473
474
  plt.savefig(output_filename, dpi=300, bbox_inches="tight")
474
475
 
475
476
 
477
+ @app.command()
478
+ def export_to_ecotaxa(
479
+ stats_filename: pathlib.Path,
480
+ export_filename: pathlib.Path,
481
+ make_label_folders: bool = False,
482
+ filter_variable: Annotated[Tuple[str, float, float], typer.Option()] = [
483
+ None,
484
+ None,
485
+ None,
486
+ ],
487
+ ):
488
+ """EcoTaxa export: Create a zip file with particle images and statistics csv
489
+
490
+ Parameters
491
+ ----------
492
+ config_filename : pathlib.Path
493
+ Config filename
494
+ output_filename: pathlib.Path
495
+ Name of zip file to contain exported particle images and statistics
496
+ make_label_folders : bool, optional, default False
497
+ If True, store particle images in sub-folders with label names.
498
+ NB: This must be False to create an EcoTaxa compatible zip file.
499
+ filter_variable: list
500
+ Variables to filter on (name, min, max), e.g. ['depth', 5, None]
501
+ """
502
+ print("[blue]LOAD STATS")
503
+ xstats = pyopia.io.load_stats(str(stats_filename))
504
+
505
+ # Filter the stats
506
+ if filter_variable[0] is not None:
507
+ print(filter_variable)
508
+ xstats = xstats.where(
509
+ (xstats[filter_variable[0]] <= filter_variable[2])
510
+ & (xstats[filter_variable[0]] >= filter_variable[1])
511
+ ).dropna(dim="index")
512
+
513
+ print("[blue]CREATING ECOTAXA BUNDLE")
514
+ ecotaxa_export = pyopia.dataexport.ecotaxa.EcotaxaExporter()
515
+ ecotaxa_export.create_bundle(
516
+ xstats, export_filename, make_label_folders=make_label_folders
517
+ )
518
+
519
+
476
520
  def process_file_list(file_list, pipeline_config, c):
477
521
  """Run a PyOPIA processing pipeline for a chuncked list of files based on a given config.toml
478
522
 
File without changes
@@ -0,0 +1,239 @@
1
+ """
2
+ Create data bundle from PyOPIA stats that can be imported into EcoTaxa.
3
+
4
+ EcoTaxa: https://ecotaxa.obs-vlfr.fr/
5
+ """
6
+
7
+ import numpy as np
8
+ import pyopia
9
+ import pyopia.io
10
+ import pyopia.statistics
11
+ import zipfile
12
+ import io
13
+ import xarray as xr
14
+ import pandas as pd
15
+ import matplotlib.pyplot as plt
16
+ from typing import Optional
17
+ from pathlib import Path
18
+ from tqdm.rich import tqdm
19
+
20
+ # Define the mapping from EcoTaxa variable names to PyOPIA stats column names.
21
+ # The type expected by EcoTaxa is also defined.
22
+ # Direct mapping from PyOPIA stats row values (statsrows) is possible in many cases.
23
+ # Where no direct mapping from a single PyOPIA stats variable exists, a combination
24
+ # or retrieval of other relevant metadata is used to construct the EcoTaxa value.
25
+ _ecotaxa_dict = {
26
+ "img_file_name": ("export_name", "str"), # source: statsrow
27
+ "img_rank": ("img_rank", "float"), # source: we will make it exist
28
+ "object_id": ("export_name", "str"), # source: statsrow
29
+ "object_lat": (
30
+ "latitude",
31
+ "float",
32
+ ), # needs to be in decimal degrees, source: auxillary data
33
+ "object_lon": (
34
+ "longitude",
35
+ "float",
36
+ ), # needs to be in decimal degrees, source: auxillary data
37
+ "object_date": (
38
+ "timestamp",
39
+ "str",
40
+ ), # UTC, format: YYYYMMDD, source: statsrow timestamp.dt.strftime(%Y%m%d)
41
+ "object_time": (
42
+ "timestamp",
43
+ "str",
44
+ ), # UTC, format: HHMMSS, source: statsrow timestamp.dt.strftime(%H%M%S)
45
+ "object_depth_min": ("depth", "float"), # source: statsrow
46
+ "object_depth_max": ("depth", "float"), # source: statsrow
47
+ "object_major": ("major_axis_length", "float"), # source: statsrow
48
+ "object_minor": ("minor_axis_length", "float"), # source: statsrow
49
+ "object_circ.": ("equivalent_diameter", "float"), # source: statsrow
50
+ "process_id": (
51
+ "process_id",
52
+ "float",
53
+ ), # If missing will be added by EcoTaxa
54
+ "process_img_software_version": ("pyopia.__version__", "str"),
55
+ "process_img_resolution": ("process_img_resolution", "float"),
56
+ "process_particle_pixel_size_?m": (
57
+ "pixel_size",
58
+ "float",
59
+ ), # Needs to be converted from pixels to micrometers, the ? is a choice of EcoTaxa
60
+ "process_date": (
61
+ "process_date",
62
+ "str",
63
+ ), # Added by custom step in create_bundle, from Modified time in PyOPIA netcdf (UTC)
64
+ "process_time": (
65
+ "process_time",
66
+ "str",
67
+ ), # Same as process_date. datetime.now(timezone.utc).strftime("%H%M%S").
68
+ "acq_id": ("acq_id", "float"), # If missing will be added by EcoTaxa
69
+ "sample_id": ("sample_id", "float"), # If missing will be added by EcoTaxa
70
+ "sample_stationid": (
71
+ "sample_stationid",
72
+ "str",
73
+ ), # Will be constructed from project name and station (PyOPIA netcdf)
74
+ }
75
+
76
+ # Some columns requires formatting or transformations
77
+ _ecotaxa_formatters = {
78
+ "object_date": lambda x: x.strftime("%Y%m%d"),
79
+ "object_time": lambda x: x.strftime("%H%M%S"),
80
+ "img_file_name": lambda x: x + ".png",
81
+ }
82
+
83
+ _ecotaxa_types = {
84
+ "float": "[f]",
85
+ "str": "[t]",
86
+ }
87
+
88
+
89
+ class EcotaxaExporter:
90
+ """Export particle statistics (xstats) and images (ROIs) to a zip file for EcoTaxa import"""
91
+
92
+ def statsrow_to_ecotaxarow(self, statsrow, xstats_attrs):
93
+ """Create a row in the EcoTaxa import file from PyOPIA stats row"""
94
+ ecotaxarow = dict()
95
+ for k, (statsname, statstype) in _ecotaxa_dict.items():
96
+ formatter = _ecotaxa_formatters.get(k, lambda x: x)
97
+ if statsname in statsrow.index:
98
+ ecotaxarow[k] = formatter(statsrow.get(statsname))
99
+ elif statsname in xstats_attrs:
100
+ ecotaxarow[k] = formatter(xstats_attrs.get(statsname))
101
+ else:
102
+ ecotaxarow[k] = np.nan
103
+ # type casting
104
+ return ecotaxarow
105
+
106
+ def create_bundle(
107
+ self,
108
+ xstats: xr.Dataset,
109
+ export_filename: Path,
110
+ roi_dir: Optional[Path] = None,
111
+ make_label_folders=False,
112
+ ):
113
+ """
114
+ Create an EcoTaxa import bundle file containing particle images and stats csv.
115
+
116
+ Parameters
117
+ ----------
118
+ xstats : xr.Dataset
119
+ A dataset containing particle statistics and metadata.
120
+ export_filename : pathlib.Path
121
+ Path to the output `.zip` file that will contain the bundle.
122
+ roi_dir : pathlib.Path, optional
123
+ Directory containing particle image files to include in the bundle.
124
+ If None, the roi_dir in the xstats steps metadata will be used.
125
+ make_label_folders : bool, optional, default False
126
+ If True, store particle images in sub-folders with label names.
127
+ NB: This must be False to create an EcoTaxa compatible zip file.
128
+ Returns
129
+ -------
130
+ None
131
+ This method does not return a value. It writes a zip file.
132
+ """
133
+
134
+ # Reconstruct PyOPIA config from xstats
135
+ self.config = pyopia.io.steps_from_xstats(xstats)
136
+
137
+ self.export_filename = export_filename
138
+
139
+ # Name of particle roi directory from config, unless overridden by input argument
140
+ self.roi_dir = (
141
+ Path(self.config["steps"]["statextract"]["export_outputpath"])
142
+ if roi_dir is None
143
+ else roi_dir
144
+ )
145
+
146
+ # Create statistics DataFrame, add classification best guess info for all particles
147
+ self.df_stats_export = pyopia.statistics.add_best_guesses_to_stats(
148
+ xstats.to_pandas()
149
+ )
150
+
151
+ buffer = io.BytesIO()
152
+ with zipfile.ZipFile(self.export_filename, mode="w") as zip:
153
+ # Export particle statistics dataframe to buffer as csv
154
+ buffer.seek(0)
155
+ buffer.truncate(0)
156
+ self.df_stats_export.to_csv(buffer)
157
+
158
+ ecotaxarows = []
159
+
160
+ # Write particle statistics csv to zip file, only if labelled folders are enabled
161
+ # This file is not used by EcoTaxa import
162
+ if make_label_folders:
163
+ buffer.seek(0)
164
+ zip.writestr("particle_statistics.csv", buffer.read())
165
+
166
+ # create it outside here
167
+ for idx, (_, row) in enumerate(
168
+ tqdm(
169
+ self.df_stats_export.iterrows(), total=self.df_stats_export.shape[0]
170
+ )
171
+ ):
172
+ # Get particle image
173
+ export_name = row["export_name"]
174
+ if export_name == "not_exported":
175
+ continue
176
+ particle_image = pyopia.statistics.roi_from_export_name(
177
+ export_name, self.roi_dir
178
+ )
179
+
180
+ # Convert PyOPIA stats row to EcoTaxa import row
181
+ ecotaxarows.append(self.statsrow_to_ecotaxarow(row, xstats.attrs))
182
+
183
+ # Save the particle image to the buffer
184
+ buffer.seek(0)
185
+ buffer.truncate(0)
186
+ plt.imsave(buffer, particle_image, format="png")
187
+
188
+ # Write the buffer to the zip file with a filename
189
+ label_folder = ""
190
+ if make_label_folders:
191
+ label_folder = row["best guess"].replace("probability_", "") + "/"
192
+ buffer.seek(0)
193
+ zip.writestr(f"{label_folder}{export_name}.png", buffer.read())
194
+
195
+ # Convert export table to Pandas DataFrame
196
+ df_ecotaxa_export_table = pd.DataFrame(ecotaxarows)
197
+ df_ecotaxa_types = pd.DataFrame(
198
+ [
199
+ {
200
+ k: _ecotaxa_types[ettype]
201
+ for k, (_, ettype) in _ecotaxa_dict.items()
202
+ }
203
+ ]
204
+ )
205
+
206
+ # Add some EcoTaxa required information manually here.
207
+ # Construct from multiple PyOPIA data values or metadata as needed.
208
+ pixel_size = self.config["general"]["pixel_size"]
209
+ df_ecotaxa_export_table["process_img_software_version"] = (
210
+ f"PyOPIA {xstats.attrs['PyOPIA_version']}"
211
+ )
212
+ df_ecotaxa_export_table["img_rank"] = 0
213
+ df_ecotaxa_export_table["process_particle_pixel_size_?m"] = pixel_size
214
+ df_ecotaxa_export_table["sample_stationid"] = "-".join(
215
+ [xstats.attrs.get(el, "") for el in ["project_name", "station"]]
216
+ )
217
+ processing_datetime = pd.to_datetime(xstats.attrs["Modified"])
218
+ df_ecotaxa_export_table["process_time"] = processing_datetime.strftime(
219
+ "%H%M%S"
220
+ )
221
+ df_ecotaxa_export_table["process_date"] = processing_datetime.strftime(
222
+ "%Y%m%d"
223
+ )
224
+
225
+ for col in ["object_major", "object_minor", "object_circ."]:
226
+ df_ecotaxa_export_table[col] *= pixel_size
227
+
228
+ # Insert type defintion as first row in exported table
229
+ df_ecotaxa_export_table = pd.concat(
230
+ [df_ecotaxa_types, df_ecotaxa_export_table],
231
+ axis=0,
232
+ )
233
+
234
+ # Write to zip archive
235
+ buffer.seek(0)
236
+ buffer.truncate(0)
237
+ df_ecotaxa_export_table.to_csv(buffer, index=False, sep="\t")
238
+ buffer.seek(0)
239
+ zip.writestr("ecotaxa_particle_statistics.tsv", buffer.read())
@@ -1,6 +1,6 @@
1
- '''
1
+ """
2
2
  Module containing tools for handling particle image statistics after processing
3
- '''
3
+ """
4
4
 
5
5
  import os
6
6
  import pandas as pd
@@ -11,11 +11,12 @@ from tqdm import tqdm
11
11
  from pyopia.io import write_stats, load_stats_as_dataframe
12
12
 
13
13
  import logging
14
+
14
15
  logger = logging.getLogger()
15
16
 
16
17
 
17
18
  def d50_from_stats(stats, pixel_size):
18
- '''Calculate the d50 from the stats and settings
19
+ """Calculate the d50 from the stats and settings
19
20
 
20
21
  Parameters
21
22
  ----------
@@ -28,7 +29,7 @@ def d50_from_stats(stats, pixel_size):
28
29
  -------
29
30
  d50 : float
30
31
  the 50th percentile of the cumulative sum of the volume distributon, in microns
31
- '''
32
+ """
32
33
  # the volume distribution needs calculating first
33
34
  dias, vd = vd_from_stats(stats, pixel_size)
34
35
 
@@ -38,7 +39,7 @@ def d50_from_stats(stats, pixel_size):
38
39
 
39
40
 
40
41
  def d50_from_vd(volume_distribution, dias):
41
- '''
42
+ """
42
43
  Calculate d50 from a volume distribution
43
44
 
44
45
  Parameters
@@ -53,7 +54,7 @@ def d50_from_vd(volume_distribution, dias):
53
54
  -------
54
55
  d50 : float
55
56
  The 50th percentile of the cumulative sum of the volume distributon, in microns
56
- '''
57
+ """
57
58
  # calculate cumulative sum of the volume distribution
58
59
  csvd = np.cumsum(volume_distribution / np.sum(volume_distribution))
59
60
 
@@ -63,7 +64,7 @@ def d50_from_vd(volume_distribution, dias):
63
64
 
64
65
 
65
66
  def get_size_bins():
66
- '''
67
+ """
67
68
  Retrieve log-spaced size bins for PSD analysis by doing the same binning as LISST-100x, but with 53 size bins
68
69
 
69
70
  Returns
@@ -72,7 +73,7 @@ def get_size_bins():
72
73
  Mid-points of size bins in microns
73
74
  bin_limits : array
74
75
  Limits of size bins in microns
75
- '''
76
+ """
76
77
  # pre-allocate
77
78
  bin_limits = np.zeros((53), dtype=np.float64)
78
79
 
@@ -99,7 +100,7 @@ def get_size_bins():
99
100
 
100
101
 
101
102
  def crop_stats(stats, crop_stats):
102
- '''Filters stats file based on whether the particles are within a rectangle specified by crop_stats.
103
+ """Filters stats file based on whether the particles are within a rectangle specified by crop_stats.
103
104
 
104
105
  Parameters
105
106
  ----------
@@ -112,9 +113,13 @@ def crop_stats(stats, crop_stats):
112
113
  -------
113
114
  cropped_stats : DataFrame
114
115
  cropped silcam stats file
115
- '''
116
- r = np.array(((stats['maxr'] - stats['minr']) / 2) + stats['minr']) # pixel row of middle of bounding box
117
- c = np.array(((stats['maxc'] - stats['minc']) / 2) + stats['minc']) # pixel column of middle of bounding box
116
+ """
117
+ r = np.array(
118
+ ((stats["maxr"] - stats["minr"]) / 2) + stats["minr"]
119
+ ) # pixel row of middle of bounding box
120
+ c = np.array(
121
+ ((stats["maxc"] - stats["minc"]) / 2) + stats["minc"]
122
+ ) # pixel column of middle of bounding box
118
123
 
119
124
  pts = np.array([[(r_, c_)] for r_, c_ in zip(r, c)])
120
125
  pts = pts.squeeze()
@@ -127,8 +132,8 @@ def crop_stats(stats, crop_stats):
127
132
  return cropped_stats
128
133
 
129
134
 
130
- def vd_from_nd(number_distribution, dias, sample_volume=1.):
131
- '''Calculate volume concentration from particle count
135
+ def vd_from_nd(number_distribution, dias, sample_volume=1.0):
136
+ """Calculate volume concentration from particle count
132
137
 
133
138
  Parameters
134
139
  ----------
@@ -143,17 +148,19 @@ def vd_from_nd(number_distribution, dias, sample_volume=1.):
143
148
  -------
144
149
  volume_distribution : array
145
150
  Particle volume distribution
146
- '''
151
+ """
147
152
  dias = dias * 1e-6 # convert to m
148
- particle_volume = 4 / 3 * np.pi * (dias / 2)**3 # volume in m^3
149
- total_particle_volume = particle_volume * number_distribution * 1e9 # volume in micro-litres
153
+ particle_volume = 4 / 3 * np.pi * (dias / 2) ** 3 # volume in m^3
154
+ total_particle_volume = (
155
+ particle_volume * number_distribution * 1e9
156
+ ) # volume in micro-litres
150
157
  volume_distribution = total_particle_volume / sample_volume # micro-litres / litre
151
158
 
152
159
  return volume_distribution
153
160
 
154
161
 
155
162
  def nc_from_nd(number_distribution, sample_volume):
156
- '''
163
+ """
157
164
  Calculate the number concentration from the count and sample volume
158
165
 
159
166
  Parameters
@@ -167,13 +174,13 @@ def nc_from_nd(number_distribution, sample_volume):
167
174
  -------
168
175
  number_concentration : float
169
176
  Particle number concentration in #/L
170
- '''
177
+ """
171
178
  number_concentration = np.sum(number_distribution) / sample_volume
172
179
  return number_concentration
173
180
 
174
181
 
175
182
  def nc_vc_from_stats(stats, pix_size, path_length, imx=2048, imy=2448):
176
- '''Calculates important summary statistics from a stats DataFrame
183
+ """Calculates important summary statistics from a stats DataFrame
177
184
 
178
185
  Parameters
179
186
  ----------
@@ -198,7 +205,7 @@ def nc_vc_from_stats(stats, pix_size, path_length, imx=2048, imy=2448):
198
205
  Total volume of water sampled in L
199
206
  junge_slope : float
200
207
  Slope of a fitted juge distribution between 150-300um
201
- '''
208
+ """
202
209
  # calculate the sample volume per image
203
210
  sample_volume = get_sample_volume(pix_size, path_length, imx=imx, imy=imy)
204
211
 
@@ -234,7 +241,7 @@ def nc_vc_from_stats(stats, pix_size, path_length, imx=2048, imy=2448):
234
241
 
235
242
 
236
243
  def nd_from_stats_scaled(stats, pix_size, path_length):
237
- '''Calcualte a scaled number distribution from stats.
244
+ """Calcualte a scaled number distribution from stats.
238
245
  units of nd are in number per micron per litre
239
246
 
240
247
  Parameters
@@ -252,7 +259,7 @@ def nd_from_stats_scaled(stats, pix_size, path_length):
252
259
  mid-points of size bins
253
260
  number_distribution : array
254
261
  number distribution in number/micron/litre
255
- '''
262
+ """
256
263
  # calculate the number distirbution (number per bin per sample volume)
257
264
  dias, necd = nd_from_stats(stats, pix_size)
258
265
 
@@ -277,7 +284,7 @@ def nd_from_stats_scaled(stats, pix_size, path_length):
277
284
 
278
285
 
279
286
  def nd_from_stats(stats, pix_size):
280
- '''Calculate number distirbution from stats
287
+ """Calculate number distirbution from stats
281
288
  units are number per bin per sample volume
282
289
 
283
290
  Parameters
@@ -293,10 +300,10 @@ def nd_from_stats(stats, pix_size):
293
300
  mid-points of size bins
294
301
  number_distribution : array
295
302
  number distribution in number/size-bin/sample-volume
296
- '''
303
+ """
297
304
 
298
305
  # convert the equiv diameter from pixels into microns
299
- ecd = stats['equivalent_diameter'] * pix_size
306
+ ecd = stats["equivalent_diameter"] * pix_size
300
307
 
301
308
  # ignore nans
302
309
  ecd = ecd[~np.isnan(ecd.values)]
@@ -314,7 +321,7 @@ def nd_from_stats(stats, pix_size):
314
321
 
315
322
 
316
323
  def vd_from_stats(stats, pix_size):
317
- '''Calculate volume distribution from stats
324
+ """Calculate volume distribution from stats
318
325
  units of miro-litres per sample volume
319
326
 
320
327
  Parameters
@@ -330,7 +337,7 @@ def vd_from_stats(stats, pix_size):
330
337
  mid-points of size bins
331
338
  volume_distribution : array
332
339
  volume distribution in micro-litres/sample-volume
333
- '''
340
+ """
334
341
 
335
342
  # obtain the number distribution
336
343
  dias, necd = nd_from_stats(stats, pix_size)
@@ -342,9 +349,18 @@ def vd_from_stats(stats, pix_size):
342
349
  return dias, volume_distribution
343
350
 
344
351
 
345
- def make_montage(stats_file_or_df, pixel_size, roidir,
346
- auto_scaler=500, msize=1024, maxlength=100000, crop_stats=None, brightness=1, eyecandy=True):
347
- '''Makes nice looking montage from a directory of extracted particle images
352
+ def make_montage(
353
+ stats_file_or_df,
354
+ pixel_size,
355
+ roidir,
356
+ auto_scaler=500,
357
+ msize=1024,
358
+ maxlength=100000,
359
+ crop_stats=None,
360
+ brightness=1,
361
+ eyecandy=True,
362
+ ):
363
+ """Makes nice looking montage from a directory of extracted particle images
348
364
 
349
365
  Parameters
350
366
  ----------
@@ -372,7 +388,7 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
372
388
  -------
373
389
  montage_image : array
374
390
  montage image that can be plotted with :func:`pyopia.plotting.montage_plot`
375
- '''
391
+ """
376
392
  if isinstance(stats_file_or_df, str):
377
393
  stats = load_stats_as_dataframe(stats_file_or_df)
378
394
  else:
@@ -382,11 +398,11 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
382
398
  stats = crop_stats(stats, crop_stats)
383
399
 
384
400
  # remove nans because concentrations are not important here
385
- stats = stats[~np.isnan(stats['major_axis_length'])]
386
- stats = stats[(stats['major_axis_length'] * pixel_size) < maxlength]
401
+ stats = stats[~np.isnan(stats["major_axis_length"])]
402
+ stats = stats[(stats["major_axis_length"] * pixel_size) < maxlength]
387
403
 
388
404
  # sort the particles based on their length
389
- stats.sort_values(by=['major_axis_length'], ascending=False, inplace=True)
405
+ stats.sort_values(by=["major_axis_length"], ascending=False, inplace=True)
390
406
 
391
407
  roifiles = gen_roifiles(stats, auto_scaler=auto_scaler)
392
408
 
@@ -394,7 +410,7 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
394
410
  montage = np.zeros((msize, msize, 3), dtype=np.float64())
395
411
  # pre-allocate an empty test canvas
396
412
  immap_test = np.zeros_like(montage[:, :, 0])
397
- logger.info('making a montage - this might take some time....')
413
+ logger.info("making a montage - this might take some time....")
398
414
 
399
415
  # loop through each extracted particle and attempt to add it to the canvas
400
416
  for files in tqdm(roifiles):
@@ -427,41 +443,43 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
427
443
 
428
444
  # try five times to fit the particle to the canvas by randomly moving
429
445
  # it around
430
- while (counter < 5):
446
+ while counter < 5:
431
447
  r = np.random.randint(1, msize - height)
432
448
  c = np.random.randint(1, msize - width)
433
- test = np.max(immap_test[r:r + height, c:c + width, None] + 1)
449
+ test = np.max(immap_test[r: r + height, c: c + width, None] + 1)
434
450
 
435
451
  # if the new particle is overlapping an existing object in the
436
452
  # canvas, then try again and increment the counter
437
- if (test > 1):
453
+ if test > 1:
438
454
  counter += 1
439
455
  else:
440
456
  break
441
457
 
442
458
  # if we reach this point and there is still an overlap, then forget
443
459
  # this particle, and move on
444
- if (test > 1):
460
+ if test > 1:
445
461
  continue
446
462
 
447
463
  # if we reach here, then the particle has found a position in the
448
464
  # canvas with no overlap, and can then be inserted into the canvas
449
- montage[r:r + height, c:c + width, :] = particle_image
465
+ montage[r: r + height, c: c + width, :] = particle_image
450
466
 
451
- immap_test[r:r + height, c:c + width, None] = immap_test[r:r + height, c:c + width, None] + 1
467
+ immap_test[r: r + height, c: c + width, None] = (
468
+ immap_test[r: r + height, c: c + width, None] + 1
469
+ )
452
470
 
453
471
  # now the montage is finished
454
472
  # here are some small eye-candy scaling things to tidy up
455
473
  montage_image = np.copy(montage)
456
474
  montage_image[montage > 1] = 1
457
475
  montage_image[montage == 0] = 1
458
- logger.info('montage complete')
476
+ logger.info("montage complete")
459
477
 
460
478
  return montage_image
461
479
 
462
480
 
463
481
  def gen_roifiles(stats, auto_scaler=500):
464
- '''Generates a list of filenames suitable for making montages with
482
+ """Generates a list of filenames suitable for making montages with
465
483
 
466
484
  Parameters
467
485
  ----------
@@ -474,22 +492,22 @@ def gen_roifiles(stats, auto_scaler=500):
474
492
  ----------
475
493
  roifiles : list
476
494
  a list of string of filenames that can be passed to montage_maker() for making nice montages
477
- '''
495
+ """
478
496
 
479
- roifiles = stats['export_name'][stats['export_name'] != 'not_exported'].values
497
+ roifiles = stats["export_name"][stats["export_name"] != "not_exported"].values
480
498
 
481
499
  # subsample the particles if necessary
482
- logger.info('rofiles: {0}'.format(len(roifiles)))
500
+ logger.info("rofiles: {0}".format(len(roifiles)))
483
501
  IMSTEP = np.max([int(np.round(len(roifiles) / auto_scaler)), 1])
484
- logger.info('reducing particles by factor of {0}'.format(IMSTEP))
502
+ logger.info("reducing particles by factor of {0}".format(IMSTEP))
485
503
  roifiles = roifiles[np.arange(0, len(roifiles), IMSTEP)]
486
- logger.info('rofiles: {0}'.format(len(roifiles)))
504
+ logger.info("rofiles: {0}".format(len(roifiles)))
487
505
 
488
506
  return roifiles
489
507
 
490
508
 
491
509
  def get_sample_volume(pix_size, path_length, imx=2048, imy=2448):
492
- ''' calculate the sample volume of one image
510
+ """calculate the sample volume of one image
493
511
 
494
512
  Parameters
495
513
  ----------
@@ -506,14 +524,16 @@ def get_sample_volume(pix_size, path_length, imx=2048, imy=2448):
506
524
  -------
507
525
  sample_volume_litres : float
508
526
  Volume of the sample volume in litres
509
- '''
510
- sample_volume_litres = imx * pix_size / 1000 * imy * pix_size / 1000 * path_length * 1e-6
527
+ """
528
+ sample_volume_litres = (
529
+ imx * pix_size / 1000 * imy * pix_size / 1000 * path_length * 1e-6
530
+ )
511
531
 
512
532
  return sample_volume_litres
513
533
 
514
534
 
515
535
  def get_j(dias, number_distribution):
516
- '''Calculates the junge slope from a correctly-scale number distribution
536
+ """Calculates the junge slope from a correctly-scale number distribution
517
537
  (number per micron per litre must be the units of nd)
518
538
 
519
539
  Parameters
@@ -527,20 +547,29 @@ def get_j(dias, number_distribution):
527
547
  -------
528
548
  junge_slope : float
529
549
  Junge slope from fitting of psd between 150 and 300um
530
- '''
550
+ """
531
551
  # conduct this calculation only on the part of the size distribution where
532
552
  # LISST-100 and SilCam data overlap
533
- ind = np.isfinite(dias) & np.isfinite(number_distribution) & (dias < 300) & (dias > 150)
553
+ ind = (
554
+ np.isfinite(dias)
555
+ & np.isfinite(number_distribution)
556
+ & (dias < 300)
557
+ & (dias > 150)
558
+ )
534
559
 
535
560
  # use polyfit to obtain the slope of the ditriubtion in log-space (which is
536
561
  # assumed near-linear in most parts of the ocean)
537
- p = np.polyfit(np.log(dias[ind]), np.log(number_distribution[ind], where=number_distribution[ind] > 0), 1)
562
+ p = np.polyfit(
563
+ np.log(dias[ind]),
564
+ np.log(number_distribution[ind], where=number_distribution[ind] > 0),
565
+ 1,
566
+ )
538
567
  junge_slope = p[0]
539
568
  return junge_slope
540
569
 
541
570
 
542
571
  def count_images_in_stats(stats):
543
- '''count the number of raw images used to generate stats
572
+ """count the number of raw images used to generate stats
544
573
 
545
574
  Parameters
546
575
  ----------
@@ -551,15 +580,15 @@ def count_images_in_stats(stats):
551
580
  -------
552
581
  n_images : int
553
582
  number of images in the stats data
554
- '''
555
- u = pd.to_datetime(stats['timestamp']).unique()
583
+ """
584
+ u = pd.to_datetime(stats["timestamp"]).unique()
556
585
  n_images = len(u)
557
586
 
558
587
  return n_images
559
588
 
560
589
 
561
590
  def extract_nth_largest(stats, n=0):
562
- '''Return statistics of the nth largest particle
591
+ """Return statistics of the nth largest particle
563
592
 
564
593
  Parameters
565
594
  ----------
@@ -572,14 +601,72 @@ def extract_nth_largest(stats, n=0):
572
601
  -------
573
602
  stats_extract
574
603
  statistics of the nth largest particle
575
- '''
576
- stats_sorted = stats.sort_values(by=['equivalent_diameter'], ascending=False, inplace=False)
604
+ """
605
+ stats_sorted = stats.sort_values(
606
+ by=["equivalent_diameter"], ascending=False, inplace=False
607
+ )
577
608
  stats_extract = stats_sorted.iloc[n]
578
609
  return stats_extract
579
610
 
580
611
 
612
+ def extract_oil(
613
+ stats, probability_threshold=0.85, solidity_threshold=0.95, feret_threshold=0.3
614
+ ):
615
+ """Creates a new stats dataframe containing only oil, based on some thresholds on calculated statistics
616
+
617
+ Parameters
618
+ ----------
619
+ stats : DataFrame
620
+ particle statistics
621
+ probability_threshold : float, optional
622
+ Threshold applied to probability_oil (from the classifier), by default 0.85
623
+ solidity_threshold : float, optional
624
+ Threshold applied to the solidity statistic (area of object / convex hull).
625
+ For droplets, this threshold is used as a crude way of removing operlapping droplets
626
+ by ensuring there are no substantial indents in the alpha shape, by default 0.95
627
+ feret_threshold : float, optional
628
+ Threshold of deformation (minor/major axis) beyond which the droplet is considered
629
+ significantly deformed or at risk of breakup., by default 0.3
630
+
631
+ Returns
632
+ -------
633
+ oilstats: DataFrame
634
+ particle statistics for just oil (a new stats DataFrame containing only oil).
635
+ .. Warning: this returned DataFrame will likely have a shorter length than the original
636
+ and can even be empty for single-image DataFrames if no particles satisfy the thresholds,
637
+ so be carefull to include all analyzed images when calculating volume concentraitons
638
+ """
639
+
640
+ # Select only particles with limited deformation (smaller number -> higher deformation)
641
+ # Select only particles with solidity above threshold
642
+ ma = stats["minor_axis_length"] / stats["major_axis_length"]
643
+ oil_stats = stats[
644
+ np.logical_and(ma > feret_threshold, stats["solidity"] > solidity_threshold)
645
+ ]
646
+
647
+ # If probability_threshold > 0.5, no need to compare against other particle types,
648
+ # since probabilities always sum to 1
649
+ if probability_threshold > 0.5:
650
+ # Select those particles that satisfy the probability threshold
651
+ oil_stats = oil_stats[oil_stats["probability_oil"] > probability_threshold]
652
+
653
+ else:
654
+ # Check probability threshold
655
+ # and
656
+ # Select only particles where the probability of oil is larger than
657
+ # the probabilities of bubble _and_ oily gas
658
+ mask = (
659
+ (oil_stats["probability_oil"] > probability_threshold)
660
+ & (oil_stats["probability_oil"] > oil_stats["probability_bubble"])
661
+ & (oil_stats["probability_oil"] > oil_stats["probability_oily_gas"])
662
+ )
663
+ oil_stats = oil_stats[mask]
664
+
665
+ return oil_stats
666
+
667
+
581
668
  def extract_nth_longest(stats, n=0):
582
- '''Return statistics of the nth longest particle
669
+ """Return statistics of the nth longest particle
583
670
 
584
671
  Parameters
585
672
  ----------
@@ -592,14 +679,16 @@ def extract_nth_longest(stats, n=0):
592
679
  -------
593
680
  stats_extract
594
681
  statistics of the nth largest particle
595
- '''
596
- stats_sorted = stats.sort_values(by=['major_axis_length'], ascending=False, inplace=False)
682
+ """
683
+ stats_sorted = stats.sort_values(
684
+ by=["major_axis_length"], ascending=False, inplace=False
685
+ )
597
686
  stats_extract = stats_sorted.iloc[n]
598
687
  return stats_extract
599
688
 
600
689
 
601
690
  def explode_contrast(im):
602
- '''Eye-candy function for exploding the contrast of a particle iamge (roi)
691
+ """Eye-candy function for exploding the contrast of a particle iamge (roi)
603
692
 
604
693
  Parameters
605
694
  ----------
@@ -610,7 +699,7 @@ def explode_contrast(im):
610
699
  -------
611
700
  im_mod : array
612
701
  image following exploded contrast
613
- '''
702
+ """
614
703
  # re-scale the instensities in the image to chop off some ends
615
704
  p1, p2 = np.percentile(im, (0, 80))
616
705
  im_mod = rescale_intensity(im, in_range=(p1, p2))
@@ -623,8 +712,8 @@ def explode_contrast(im):
623
712
  return im_mod
624
713
 
625
714
 
626
- def bright_norm(im, brightness=1.):
627
- '''Eye-candy function for normalising the image brightness
715
+ def bright_norm(im, brightness=1.0):
716
+ """Eye-candy function for normalising the image brightness
628
717
 
629
718
  Parameters
630
719
  ----------
@@ -637,7 +726,7 @@ def bright_norm(im, brightness=1.):
637
726
  -------
638
727
  im : array
639
728
  image with modified brightness
640
- '''
729
+ """
641
730
  peak = np.median(im.flatten())
642
731
  bm = brightness - peak
643
732
 
@@ -648,7 +737,7 @@ def bright_norm(im, brightness=1.):
648
737
 
649
738
 
650
739
  def nd_rescale(dias, number_distribution, sample_volume):
651
- '''Rescale a number distribution from number per bin per sample volume to number per micron per litre.
740
+ """Rescale a number distribution from number per bin per sample volume to number per micron per litre.
652
741
 
653
742
  Parameters
654
743
  ----------
@@ -663,19 +752,23 @@ def nd_rescale(dias, number_distribution, sample_volume):
663
752
  -------
664
753
  number_distribution_scaled : array
665
754
  scaled number distribution (number per micron per litre)
666
- '''
755
+ """
667
756
 
668
- number_distribution_scaled = np.float64(number_distribution) / sample_volume # nc per size bin per litre
757
+ number_distribution_scaled = (
758
+ np.float64(number_distribution) / sample_volume
759
+ ) # nc per size bin per litre
669
760
 
670
761
  # convert nd to units of nc per micron per litre
671
762
  dd = np.gradient(dias)
672
763
  number_distribution_scaled /= dd
673
- number_distribution_scaled[number_distribution_scaled < 0] = np.nan # and nan impossible values!
764
+ number_distribution_scaled[number_distribution_scaled < 0] = (
765
+ np.nan
766
+ ) # and nan impossible values!
674
767
  return number_distribution_scaled
675
768
 
676
769
 
677
770
  def add_depth_to_stats(stats, time, depth):
678
- '''If you have a depth time-series, use this function to find the depth of each line in stats
771
+ """If you have a depth time-series, use this function to find the depth of each line in stats
679
772
 
680
773
  Parameters
681
774
  ----------
@@ -690,16 +783,16 @@ def add_depth_to_stats(stats, time, depth):
690
783
  -------
691
784
  stats : DataFrame
692
785
  particle statistics now with a 'Depth' column for each particle
693
- '''
786
+ """
694
787
  # get times
695
- sctime = pd.to_datetime(stats['timestamp'])
788
+ sctime = pd.to_datetime(stats["timestamp"])
696
789
  # interpolate depths into the SilCam times
697
- stats['Depth'] = np.interp(np.float64(sctime), np.float64(time), depth)
790
+ stats["Depth"] = np.interp(np.float64(sctime), np.float64(time), depth)
698
791
  return stats
699
792
 
700
793
 
701
794
  def roi_from_export_name(exportname, path):
702
- '''Returns an image from the export_name string in the -STATS.h5 file
795
+ """Returns an image from the export_name string in the -STATS.h5 file
703
796
 
704
797
  Get the exportname like this:
705
798
  ```python
@@ -717,17 +810,17 @@ def roi_from_export_name(exportname, path):
717
810
  -------
718
811
  im : array
719
812
  particle ROI image
720
- '''
813
+ """
721
814
  # the particle number is defined after the time info
722
- pn = exportname.split('-')[1]
815
+ pn = exportname.split("-")[1]
723
816
  # the name is the first bit
724
- name = exportname.split('-')[0] + '.h5'
817
+ name = exportname.split("-")[0] + ".h5"
725
818
 
726
819
  # combine the name with the location of the exported HDF5 files
727
820
  fullname = os.path.join(path, name)
728
821
 
729
822
  # open the H5 file
730
- fh = h5py.File(fullname, 'r')
823
+ fh = h5py.File(fullname, "r")
731
824
 
732
825
  if (fh[pn].dtype) == np.uint8:
733
826
  im = np.float64(fh[pn]) / 255
@@ -738,7 +831,7 @@ def roi_from_export_name(exportname, path):
738
831
 
739
832
 
740
833
  def extract_latest_stats(stats, window_size):
741
- '''Extracts the stats data from within the last number of seconds specified by window_size.
834
+ """Extracts the stats data from within the last number of seconds specified by window_size.
742
835
 
743
836
  Parameters
744
837
  ----------
@@ -751,15 +844,15 @@ def extract_latest_stats(stats, window_size):
751
844
  -------
752
845
  stats_selected : DataFrame
753
846
  particle statistics after specified time window (given by `window_size`)
754
- '''
755
- end = np.max(pd.to_datetime(stats['timestamp']))
756
- start = end - pd.to_timedelta('00:00:' + str(window_size))
757
- stats_selected = stats[pd.to_datetime(stats['timestamp']) > start]
847
+ """
848
+ end = np.max(pd.to_datetime(stats["timestamp"]))
849
+ start = end - pd.to_timedelta("00:00:" + str(window_size))
850
+ stats_selected = stats[pd.to_datetime(stats["timestamp"]) > start]
758
851
  return stats_selected
759
852
 
760
853
 
761
854
  def make_timeseries_vd(stats, pixel_size, path_length, time_reference):
762
- '''Makes a dataframe of time-series volume distribution and d50
855
+ """Makes a dataframe of time-series volume distribution and d50
763
856
  similar to Sequoia LISST-100 output,
764
857
  and exportable to things like Excel or csv.
765
858
 
@@ -813,15 +906,15 @@ def make_timeseries_vd(stats, pixel_size, path_length, time_reference):
813
906
 
814
907
  # time-series of total volume concentration
815
908
  volume_concentration = np.sum(vdarray, axis=1)
816
- '''
909
+ """
817
910
  sample_volume = get_sample_volume(pixel_size, path_length=path_length)
818
911
 
819
912
  vdts = np.zeros((len(time_reference), len(get_size_bins()[0])), dtype=np.float64)
820
913
  d50 = np.zeros((len(time_reference)), dtype=np.float64) * np.nan
821
914
  for i, s in enumerate(tqdm(time_reference)):
822
- nims = count_images_in_stats(stats[stats['timestamp'] == s])
915
+ nims = count_images_in_stats(stats[stats["timestamp"] == s])
823
916
  if nims > 0:
824
- dias, vd = vd_from_stats(stats[stats['timestamp'] == s], pixel_size)
917
+ dias, vd = vd_from_stats(stats[stats["timestamp"] == s], pixel_size)
825
918
  sv = sample_volume * nims
826
919
  vd /= sv
827
920
  d50[i] = d50_from_vd(vd, dias)
@@ -833,36 +926,38 @@ def make_timeseries_vd(stats, pixel_size, path_length, time_reference):
833
926
 
834
927
  time_series = pd.DataFrame(data=[np.squeeze(vdts)], columns=dias)
835
928
 
836
- time_series['D50'] = np.nan
837
- time_series['Time'] = np.nan
929
+ time_series["D50"] = np.nan
930
+ time_series["Time"] = np.nan
838
931
 
839
932
  return time_series
840
933
 
841
934
  time_series = pd.DataFrame(data=np.squeeze(vdts), columns=dias)
842
935
 
843
- time_series['D50'] = d50
844
- time_series['Time'] = pd.to_datetime(time_reference)
936
+ time_series["D50"] = d50
937
+ time_series["Time"] = pd.to_datetime(time_reference)
845
938
 
846
- time_series.sort_values(by='Time', inplace=True, ascending=True)
939
+ time_series.sort_values(by="Time", inplace=True, ascending=True)
847
940
 
848
941
  return time_series
849
942
 
850
943
 
851
944
  def statscsv_to_statshdf(stats_file):
852
- '''Convert old STATS.csv file to a STATS.h5 file
945
+ """Convert old STATS.csv file to a STATS.h5 file
853
946
 
854
947
  Parameters
855
948
  ----------
856
949
  stats_file : str
857
950
  filename of stats file
858
- '''
951
+ """
859
952
  stats = pd.read_csv(stats_file, index_col=False)
860
- assert stats_file[-10:] == '-STATS.csv', f"Stats file {stats_file} should end in '-STATS.csv'."
953
+ assert stats_file[-10:] == "-STATS.csv", (
954
+ f"Stats file {stats_file} should end in '-STATS.csv'."
955
+ )
861
956
  write_stats(stats_file[:-10], stats, append=False)
862
957
 
863
958
 
864
959
  def trim_stats(stats_file, start_time, end_time, write_new=False, stats=[]):
865
- '''Chops a STATS.h5 file given a start and end time
960
+ """Chops a STATS.h5 file given a start and end time
866
961
 
867
962
  Parameters
868
963
  ----------
@@ -884,28 +979,40 @@ def trim_stats(stats_file, start_time, end_time, write_new=False, stats=[]):
884
979
  particle statistics
885
980
  outname : str
886
981
  name of new stats csv file written to disc
887
- '''
982
+ """
888
983
  if len(stats) == 0:
889
- stats = pd.read_hdf(stats_file, 'ParticleStats/stats')
984
+ stats = pd.read_hdf(stats_file, "ParticleStats/stats")
890
985
 
891
986
  start_time = pd.to_datetime(start_time)
892
987
  end_time = pd.to_datetime(end_time)
893
988
 
894
989
  trimmed_stats = stats[
895
- (pd.to_datetime(stats['timestamp']) > start_time) & (pd.to_datetime(stats['timestamp']) < end_time)]
990
+ (pd.to_datetime(stats["timestamp"]) > start_time)
991
+ & (pd.to_datetime(stats["timestamp"]) < end_time)
992
+ ]
896
993
 
897
994
  if np.isnan(trimmed_stats.equivalent_diameter.max()) or len(trimmed_stats) == 0:
898
- logger.info('No data in specified time range!')
899
- outname = ''
995
+ logger.info("No data in specified time range!")
996
+ outname = ""
900
997
  return trimmed_stats, outname
901
998
 
902
- actual_start = pd.to_datetime(trimmed_stats['timestamp'].min()).strftime('D%Y%m%dT%H%M%S.%f')
903
- actual_end = pd.to_datetime(trimmed_stats['timestamp'].max()).strftime('D%Y%m%dT%H%M%S.%f')
999
+ actual_start = pd.to_datetime(trimmed_stats["timestamp"].min()).strftime(
1000
+ "D%Y%m%dT%H%M%S.%f"
1001
+ )
1002
+ actual_end = pd.to_datetime(trimmed_stats["timestamp"].max()).strftime(
1003
+ "D%Y%m%dT%H%M%S.%f"
1004
+ )
904
1005
 
905
1006
  path, name = os.path.split(stats_file)
906
1007
 
907
- outname = os.path.join(path, name.replace('-STATS.h5', '')) + '-Start' + str(actual_start) + '-End' + str(
908
- actual_end) + '-STATS.h5'
1008
+ outname = (
1009
+ os.path.join(path, name.replace("-STATS.h5", ""))
1010
+ + "-Start"
1011
+ + str(actual_start)
1012
+ + "-End"
1013
+ + str(actual_end)
1014
+ + "-STATS.h5"
1015
+ )
909
1016
 
910
1017
  if write_new:
911
1018
  trimmed_stats.to_csv(outname)
@@ -914,7 +1021,7 @@ def trim_stats(stats_file, start_time, end_time, write_new=False, stats=[]):
914
1021
 
915
1022
 
916
1023
  def add_best_guesses_to_stats(stats):
917
- '''
1024
+ """
918
1025
  Calculates the most likely tensorflow classification and adds best guesses
919
1026
  to stats dataframe.
920
1027
 
@@ -927,41 +1034,41 @@ def add_best_guesses_to_stats(stats):
927
1034
  -------
928
1035
  stats : DataFrame
929
1036
  particle statistics from silcam process with new columns for best guess and best guess value
930
- '''
1037
+ """
931
1038
  cols = stats.columns
932
1039
 
933
1040
  p = np.zeros_like(cols) != 0
934
1041
  for i, c in enumerate(cols):
935
- p[i] = str(c).startswith('probability')
1042
+ p[i] = str(c).startswith("probability")
936
1043
 
937
1044
  pinds = np.squeeze(np.argwhere(p))
938
1045
 
939
1046
  parray = np.array(stats.iloc[:, pinds[:]])
940
1047
 
941
- stats['best guess'] = cols[pinds.min() + np.argmax(parray, axis=1)]
942
- stats['best guess value'] = np.max(parray, axis=1)
1048
+ stats["best guess"] = cols[pinds.min() + np.argmax(parray, axis=1)]
1049
+ stats["best guess value"] = np.max(parray, axis=1)
943
1050
  return stats
944
1051
 
945
1052
 
946
1053
  def show_h5_meta(h5file):
947
- '''
1054
+ """
948
1055
  prints metadata from an exported hdf5 file created from silcam process
949
1056
 
950
1057
  Parameters
951
1058
  ----------
952
1059
  h5file : str
953
1060
  h5 filename from exported data from silcam process
954
- '''
955
- with h5py.File(h5file, 'r') as f:
956
- keys = list(f['Meta'].attrs.keys())
1061
+ """
1062
+ with h5py.File(h5file, "r") as f:
1063
+ keys = list(f["Meta"].attrs.keys())
957
1064
 
958
1065
  for k in keys:
959
- logger.info(k + ':')
960
- logger.info(' ' + f['Meta'].attrs[k])
1066
+ logger.info(k + ":")
1067
+ logger.info(" " + f["Meta"].attrs[k])
961
1068
 
962
1069
 
963
1070
  def vd_to_nd(volume_distribution, dias):
964
- '''convert volume distribution to number distribution
1071
+ """convert volume distribution to number distribution
965
1072
 
966
1073
  Parameters
967
1074
  ----------
@@ -974,14 +1081,18 @@ def vd_to_nd(volume_distribution, dias):
974
1081
  -------
975
1082
  number_distribution : array
976
1083
  number distribution as number per micron per bin (scaling is the same unit as the input vd)
977
- '''
978
- DropletVolume = ((4 / 3) * np.pi * ((dias * 1e-6) / 2) ** 3) # the volume of each droplet in m3
979
- number_distribution = volume_distribution / (DropletVolume * 1e9) # the number distribution in each bin
1084
+ """
1085
+ DropletVolume = (
1086
+ (4 / 3) * np.pi * ((dias * 1e-6) / 2) ** 3
1087
+ ) # the volume of each droplet in m3
1088
+ number_distribution = volume_distribution / (
1089
+ DropletVolume * 1e9
1090
+ ) # the number distribution in each bin
980
1091
  return number_distribution
981
1092
 
982
1093
 
983
1094
  def vd_to_nc(volume_distribution, dias):
984
- '''calculate number concentration from volume distribution
1095
+ """calculate number concentration from volume distribution
985
1096
 
986
1097
  Parameters
987
1098
  ----------
@@ -995,7 +1106,7 @@ def vd_to_nc(volume_distribution, dias):
995
1106
  number_concentration : float
996
1107
  number concentration (scaling is the same unit as the input vd).
997
1108
  If vd is a 2d array [time, vd_bins], nc will be the concentration for row
998
- '''
1109
+ """
999
1110
  number_distribution = vd_to_nd(dias, volume_distribution)
1000
1111
  if np.ndim(number_distribution) > 1:
1001
1112
  number_concentration = np.sum(number_distribution, axis=1)
@@ -1 +0,0 @@
1
- __version__ = "2.12.0"
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
File without changes