smos 0.3.1__py2.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.
smos/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ import sys
2
+
3
+ if sys.version_info[:2] >= (3, 8):
4
+ # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
5
+ from importlib.metadata import PackageNotFoundError, version # pragma: no cover
6
+ else:
7
+ from importlib_metadata import PackageNotFoundError, version # pragma: no cover
8
+
9
+ try:
10
+ # Change here if project is renamed and does not equal the package name
11
+ dist_name = __name__
12
+ __version__ = version(dist_name)
13
+ except PackageNotFoundError: # pragma: no cover
14
+ __version__ = "unknown"
15
+ finally:
16
+ del version, PackageNotFoundError
smos/grid.py ADDED
@@ -0,0 +1,57 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from pygeogrids.grids import CellGrid, BasicGrid, lonlat2cell
4
+ from ease_grid import EASE2_grid
5
+ import numpy as np
6
+
7
+
8
+ class EASE25CellGrid(CellGrid):
9
+ """ CellGrid version of EASE25 Grid as used in SMOS IC """
10
+
11
+ def __init__(self, bbox=None):
12
+ """
13
+ Parameters
14
+ ----------
15
+ bbox: tuple, optional (default: None)
16
+ (min_lon, min_lat, max_lon, max_lat)
17
+ Bounding box to create subset for, if None is passed a global
18
+ grid is used.
19
+ """
20
+
21
+ ease25 = EASE2_grid(25000)
22
+ lons, lats = ease25.londim, ease25.latdim
23
+
24
+ lons, lats = np.meshgrid(lons, lats)
25
+ assert lons.shape == lats.shape
26
+ shape = lons.shape
27
+
28
+ lats = np.flipud(lats) # flip lats, so that origin in bottom left
29
+ lons, lats = lons.flatten(), lats.flatten()
30
+
31
+ globgrid = BasicGrid(lons, lats, shape=shape)
32
+ sgpis = globgrid.activegpis
33
+
34
+ self.bbox = bbox
35
+ if self.bbox:
36
+ sgpis = globgrid.get_bbox_grid_points(
37
+ latmin=self.bbox[1], latmax=self.bbox[3],
38
+ lonmin=self.bbox[0], lonmax=self.bbox[2])
39
+
40
+ self.cellsize = 5.
41
+
42
+ super(EASE25CellGrid, self).__init__(lon=globgrid.arrlon,
43
+ lat=globgrid.arrlat,
44
+ subset=sgpis,
45
+ cells=lonlat2cell(globgrid.arrlon,
46
+ globgrid.arrlat,
47
+ self.cellsize),
48
+ shape=shape)
49
+
50
+ self.subset_shape = (len(np.unique(self.activearrlat)),
51
+ len(np.unique(self.activearrlon)))
52
+
53
+ def cut(self) -> CellGrid:
54
+ # create a new grid from the active subset
55
+ return BasicGrid(lon=self.activearrlon, lat=self.activearrlat,
56
+ gpis=self.activegpis, subset=None,
57
+ shape=self.subset_shape).to_cell_grid(self.cellsize)
smos/interface.py ADDED
@@ -0,0 +1,518 @@
1
+ # -*- coding: utf-8 -*-
2
+ # The MIT License (MIT)
3
+ #
4
+ # Copyright (c) 2021, TU Wien
5
+ #
6
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ # of this software and associated documentation files (the "Software"), to deal
8
+ # in the Software without restriction, including without limitation the rights
9
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the Software is
11
+ # furnished to do so, subject to the following conditions:
12
+ #
13
+ # The above copyright notice and this permission notice shall be included in
14
+ # all copies or substantial portions of the Software.
15
+ #
16
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ # SOFTWARE.
23
+
24
+ import os
25
+ import warnings
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ from pynetcf.time_series import GriddedNcOrthoMultiTs
30
+ from pygeogrids.netcdf import load_grid
31
+
32
+ from pygeobase.io_base import ImageBase, MultiTemporalImageBase
33
+ from pygeobase.object_base import Image
34
+ from datetime import timedelta, datetime
35
+ from netCDF4 import Dataset, date2num, num2date
36
+ from smos.grid import EASE25CellGrid
37
+ from smos import dist_name as subdist_name
38
+ from smos import dist_name, __version__
39
+ from collections import OrderedDict
40
+
41
+
42
+ class SMOSTs(GriddedNcOrthoMultiTs):
43
+
44
+ _t0_vars = {'sec': 'UTC_Seconds', 'days': 'Days'}
45
+ _t0_unit = 'days since 2000-01-01'
46
+
47
+ def __init__(self, ts_path, grid_path=None, index_add_time=False,
48
+ drop_missing=True, **kwargs):
49
+ """
50
+ Class for reading SMOS time series after reshuffling images.
51
+ Missing images are represented in time series as lines where all
52
+ variables are NaN.
53
+
54
+ Parameters
55
+ ----------
56
+ ts_path : str
57
+ Directory where the netcdf time series files are stored
58
+ grid_path : str, optional (default: None)
59
+ Path to grid file, that is used to organize the location of time
60
+ series to read. If None is passed, grid.nc is searched for in the
61
+ ts_path.
62
+ index_add_time : bool, optional (default: False)
63
+ Add overpass time stamps to the data frame index. This needs the
64
+ 'Days' and 'UTC_Seconds' variable available in the time series files.
65
+ drop_missing : bool, optional (default: True)
66
+ Drop Lines in TS where ALL variables are missing.
67
+
68
+ Optional keyword arguments that are passed to the Gridded Base:
69
+ ------------------------------------------------------------------------
70
+ parameters : list, optional (default: None)
71
+ Specific variable names to read, if None are selected, all are read.
72
+ offsets : dict, optional (default:None)
73
+ Offsets (values) that are added to the parameters (keys)
74
+ scale_factors : dict, optional (default:None)
75
+ Offset (value) that the parameters (key) is multiplied with
76
+ ioclass_kws: dict
77
+ Optional keyword arguments to pass to OrthoMultiTs class:
78
+ ----------------------------------------------------------------
79
+ read_bulk : boolean, optional (default:False)
80
+ if set to True the data of all locations is read into memory,
81
+ and subsequent calls to read_ts read from the cache and not from disk
82
+ this makes reading complete files faster#
83
+ read_dates : boolean, optional (default:False)
84
+ if false dates will not be read automatically but only on specific
85
+ request useable for bulk reading because currently the netCDF
86
+ num2date routine is very slow for big datasets
87
+ autofill : bool, (default: True)
88
+ Fill missing values with nans
89
+ """
90
+
91
+ if grid_path is None:
92
+ grid_path = os.path.join(ts_path, "grid.nc")
93
+
94
+ self.drop_missing = drop_missing
95
+ grid = load_grid(grid_path)
96
+ super(SMOSTs, self).__init__(ts_path, grid, **kwargs)
97
+
98
+ self.index_add_time = index_add_time
99
+ if (self.parameters is not None) and self.index_add_time:
100
+ for v in self._t0_vars.values():
101
+ self.parameters.append(v)
102
+
103
+ def _to_datetime(self, df:pd.DataFrame) -> pd.DataFrame:
104
+ # convert Days and UTC_Seconds to actual datetimes
105
+ units = self._t0_unit
106
+
107
+ df['_date'] = df.index.values
108
+
109
+ if (self._t0_vars['days'] not in df.columns) or \
110
+ (self._t0_vars['sec'] not in df.columns):
111
+ raise KeyError(f"Could not find {self._t0_vars['days']} or {self._t0_vars['sec']} "
112
+ f"in reshuffled data.")
113
+
114
+
115
+ # days + (seconds / seconds per day)
116
+ num = df[self._t0_vars['days']].dropna() + (df[self._t0_vars['sec']].dropna() / 86400.)
117
+ if len(num) == 0:
118
+ df.loc[num.index, '_datetime_UTC'] = []
119
+ else:
120
+ df.loc[num.index, '_datetime_UTC'] = \
121
+ pd.DatetimeIndex(num2date(num.values, units=units,
122
+ calendar='standard', only_use_cftime_datetimes=False))
123
+
124
+ df = df.set_index('_datetime_UTC')
125
+ df = df[df.index.notnull()]
126
+ return df
127
+
128
+ def read(self, *args, **kwargs):
129
+ """
130
+ Read time series by grid point index, or by lonlat. Convert columns to
131
+ ints if possible (if there are no Nans in it).
132
+
133
+ Parameters
134
+ ----------
135
+ lon: float
136
+ Location longitude
137
+ lat : float
138
+ Location latitude
139
+ .. OR
140
+ gpi : int
141
+ Grid point Index
142
+
143
+ Returns
144
+ -------
145
+ df : pd.DataFrame
146
+ Time Series data at the selected location
147
+ """
148
+
149
+ ts = super(SMOSTs, self).read(*args, **kwargs)
150
+
151
+ if self.drop_missing:
152
+ ts = ts.dropna(how='all')
153
+
154
+ for col in ts: # convert to ints, if possible
155
+ if (not np.isnan(ts[col]).any()) and \
156
+ (np.all(np.mod(ts[col].values, 1) == 0.)):
157
+ ts[col] = ts[col].astype(int)
158
+
159
+ if self.index_add_time:
160
+ ts = self._to_datetime(ts)
161
+
162
+ return ts
163
+
164
+
165
+ class SMOSImg(ImageBase):
166
+ """
167
+ Class for reading one SMOS netcdf image file.
168
+
169
+ Parameters
170
+ ----------
171
+ filename: str
172
+ filename of the SMOS nc image file
173
+ mode: str, optional (default: 'r')
174
+ mode of opening the file, only 'r' is implemented at the moment
175
+ parameters : str or list, optional (default: None)
176
+ one or list of parameters to read. We add 'Quality_Flags' if 'read_flags'
177
+ is not None. All parameters are described in docs/varnames.rst. If
178
+ None are passed, all parameters are read.
179
+ flatten: bool, optional (default: False)
180
+ If set then the data is read into 1D arrays. This is used to e.g
181
+ reshuffle the data.
182
+ grid : pygeogrids.CellGrid, optional (default: EASE25CellGrid)
183
+ Grid that the image data is organised on, by default the global EASE25
184
+ grid is used.
185
+ read_flags : tuple or None, optional (default: (0, 1))
186
+ Filter values to read based on the selected QUALITY_FLAGS.
187
+ Values for locations that are not assigned any of the here passed flags
188
+ are replaces with NaN (by default only the missing-data, i.e. flag=2,
189
+ locations are filtered out). If None is passed, no flags are considered.
190
+ float_fillval : float or None, optional (default: np.nan)
191
+ Fill Value for masked pixels, this is only applied to float variables.
192
+ Therefore e.g. mask variables are never filled but use the fill value
193
+ as in the data.
194
+ """
195
+
196
+ def __init__(self, filename, mode='r', parameters=None, flatten=False,
197
+ grid=EASE25CellGrid(bbox=None), read_flags=(0, 1), float_fillval=np.nan):
198
+
199
+ super(SMOSImg, self).__init__(filename, mode=mode)
200
+
201
+ if parameters is None:
202
+ parameters = []
203
+ if type(parameters) != list:
204
+ parameters = [parameters]
205
+
206
+ self.read_flags = read_flags
207
+ self.parameters = parameters
208
+ self.flatten = flatten
209
+
210
+ self.grid = grid
211
+
212
+ self.image_missing = False
213
+ self.img = None # to be loaded
214
+ self.glob_attrs = None
215
+
216
+ self.float_fillval = float_fillval
217
+
218
+ def get_global_attrs(self, exclude=('history', 'NCO', 'netcdf_version_id', 'contact',
219
+ 'institution', 'creation_time')):
220
+ return {k: v for k, v in self.glob_attrs.items() if k not in exclude}
221
+
222
+ def _read_empty(self):
223
+ """
224
+ Create an empty image for filling missing dates, this is necessary
225
+ for reshuffling as img2ts cannot handle missing days.
226
+
227
+ Returns
228
+ -------
229
+ empty_img : dict
230
+ Empty arrays of image size for each object parameter
231
+ """
232
+ self.image_missing = True
233
+
234
+ return_img = {}
235
+ return_metadata = {}
236
+
237
+ try:
238
+ rows, cols = self.grid.subset_shape
239
+ except AttributeError:
240
+ rows, cols = self.grid.shape
241
+
242
+ for param in self.parameters:
243
+ data = np.full((rows, cols), np.nan)
244
+ return_img[param] = data.flatten()
245
+ return_metadata[param] = {'image_missing': 1}
246
+
247
+ return return_img, return_metadata
248
+
249
+
250
+ def _read_img(self) -> (dict, dict):
251
+ raise NotImplementedError
252
+
253
+
254
+ def read(self, timestamp):
255
+ """
256
+ Read a single SMOS image, if it exists, otherwise fill an empty image
257
+
258
+ Parameters
259
+ --------
260
+ timestamp : datetime
261
+ Time stamp for the image to read.
262
+ """
263
+
264
+ if timestamp is None:
265
+ raise ValueError("No time stamp passed")
266
+
267
+ try:
268
+ return_img, return_metadata = self._read_img()
269
+ except IOError:
270
+ warnings.warn('Error loading image for {}, '
271
+ 'generating empty image instead'.format(timestamp.date()))
272
+ return_img, return_metadata = self._read_empty()
273
+
274
+ if self.flatten:
275
+ self.img = Image(self.grid.activearrlon, self.grid.activearrlat,
276
+ return_img, return_metadata, timestamp)
277
+
278
+ else:
279
+ try:
280
+ shape = self.grid.subset_shape
281
+ except AttributeError:
282
+ shape = self.grid.shape
283
+
284
+ rows, cols = shape
285
+ for key in return_img:
286
+ return_img[key] = np.flipud(return_img[key].reshape(rows, cols))
287
+
288
+ self.img = Image(self.grid.activearrlon.reshape(rows, cols),
289
+ np.flipud(self.grid.activearrlat.reshape(rows, cols)),
290
+ return_img,
291
+ return_metadata,
292
+ timestamp)
293
+ return self.img
294
+
295
+ def write(self, image, **kwargs):
296
+ """
297
+ Write the image to a separate output path. E.g. after reading only
298
+ a subset of the parameters, or when reading a spatial subset (with a
299
+ subgrid). If there is already a file, the new image is appended along
300
+ the time dimension.
301
+
302
+ Parameters
303
+ ----------
304
+ image : str
305
+ Path to netcdf file to create.
306
+ kwargs
307
+ Additional kwargs are given to netcdf4.Dataset
308
+ """
309
+
310
+ if self.img is None:
311
+ raise IOError("No data found for current image, load data first")
312
+
313
+ if self.img.timestamp is None:
314
+ raise IOError("No time stamp found for current image.")
315
+
316
+ lons = np.unique(self.img.lon.flatten())
317
+ lats = np.flipud(np.unique(self.img.lat.flatten()))
318
+
319
+ mode = 'w' if not os.path.isfile(image) else 'a'
320
+ ds = Dataset(image, mode=mode, **kwargs)
321
+
322
+ ds.set_auto_scale(True)
323
+ ds.set_auto_mask(True)
324
+
325
+ units = 'Days since 2000-01-01 00:00:00'
326
+
327
+ if mode == 'w':
328
+ ds.createDimension('timestamp', None) # stack dim
329
+ ds.createDimension('lat', len(lats))
330
+ ds.createDimension('lon', len(lons))
331
+
332
+ # this is not the obs time, but an image time stamp
333
+ ds.createVariable('timestamp', datatype=np.double, dimensions=('timestamp',),
334
+ zlib=True, chunksizes=None)
335
+ ds.createVariable('lat', datatype='float64', dimensions=('lat',), zlib=True)
336
+ ds.createVariable('lon', datatype='float64', dimensions=('lon',), zlib=True)
337
+
338
+ ds.variables['timestamp'].setncatts({'long_name': 'timestamp',
339
+ 'units': units})
340
+ ds.variables['lat'].setncatts({'long_name': 'latitude', 'units': 'Degrees_North',
341
+ 'valid_range': (-90, 90)})
342
+ ds.variables['lon'].setncatts({'long_name': 'longitude', 'units': 'Degrees_East',
343
+ 'valid_range': (-180, 180)})
344
+
345
+ ds.variables['lon'][:] = lons
346
+ ds.variables['lat'][:] = lats
347
+ ds.variables['timestamp'][:] = np.array([])
348
+
349
+ this_global_attrs = \
350
+ OrderedDict([('subset_img_creation_time', str(datetime.now())),
351
+ ('subset_img_bbox_corners_latlon', str(self.grid.bbox)),
352
+ ('subset_software', f"{dist_name} | {subdist_name} | {__version__}")])
353
+ glob_attrs = self.glob_attrs
354
+ for k in ['ease_global', 'history', 'creation_time', 'NCO']:
355
+ try:
356
+ glob_attrs.pop(k)
357
+ except KeyError:
358
+ continue
359
+ glob_attrs.update(this_global_attrs)
360
+ ds.setncatts(glob_attrs)
361
+
362
+ idx = ds.variables['timestamp'].shape[0]
363
+ ds.variables['timestamp'][idx] = date2num(self.img.timestamp, units=units)
364
+
365
+ for var, vardata in self.img.data.items():
366
+
367
+ if var not in ds.variables.keys():
368
+ ds.createVariable(var, vardata.dtype, dimensions=('timestamp', 'lat', 'lon'),
369
+ zlib=True, complevel=6)
370
+ ds.variables[var].setncatts(self.img.metadata[var])
371
+
372
+ ds.variables[var][-1] = vardata
373
+
374
+ ds.close()
375
+
376
+ def read_masked_data(self, **kwargs):
377
+ raise NotImplementedError
378
+
379
+ def flush(self):
380
+ pass
381
+
382
+ def close(self):
383
+ pass
384
+
385
+
386
+ class SMOSDs(MultiTemporalImageBase):
387
+ """
388
+ Class for reading SMOS images in nc format. Images are orgnaised in subdirs
389
+ for each year.
390
+
391
+ Parameters
392
+ ----------
393
+ data_path : str
394
+ Path to the nc files
395
+ parameter : str or list, optional (default: None)
396
+ one or list of parameters to read, see SMOS documentation
397
+ for more information (default: 'Soil_Moisture').
398
+ flatten: bool, optional (default: False)
399
+ If set then the data is read into 1D arrays. This is used to e.g
400
+ reshuffle the data.
401
+ grid : pygeogrids.CellGrid, optional (default: EASE25CellGrid)
402
+ Grid that the image data is organised on, by default the global EASE25
403
+ grid is used.
404
+ read_flags : tuple or None, optional (default: (0, 1))
405
+ Filter values to read based on the selected QUALITY_FLAGS.
406
+ Values for locations that are not assigned any of the here passed flags
407
+ are replaces with NaN (by default only the missing-data, i.e. flag=2,
408
+ locations are filtered out).
409
+ float_fillval : float or None, optional (default: np.nan)
410
+ Fill Value for masked pixels, this is only applied to float variables.
411
+ Therefore e.g. mask variables are never filled but use the fill value
412
+ as in the data.
413
+ """
414
+
415
+
416
+ def __init__(self, data_path, ioclass, parameters=None, flatten=False,
417
+ grid=EASE25CellGrid(bbox=None), filename_templ=None,
418
+ read_flags=(0, 1), float_fillval=np.nan, additional_kws=None):
419
+
420
+ ioclass_kws = {'parameters': parameters,
421
+ 'flatten': flatten,
422
+ 'grid': grid,
423
+ 'read_flags': read_flags,
424
+ 'float_fillval': float_fillval}
425
+
426
+ if additional_kws:
427
+ ioclass_kws.update(additional_kws)
428
+
429
+ sub_path = ['%Y']
430
+
431
+ super().__init__(data_path,
432
+ ioclass=ioclass,
433
+ fname_templ=filename_templ,
434
+ datetime_format="%Y%m%d",
435
+ subpath_templ=sub_path,
436
+ exact_templ=False,
437
+ ioclass_kws=ioclass_kws)
438
+
439
+ def _assemble_img(self, timestamp, mask=False, **kwargs):
440
+ img = None
441
+ try:
442
+ filepath = self._build_filename(timestamp)
443
+ except IOError:
444
+ filepath = None
445
+
446
+ if self._open(filepath):
447
+ kwargs['timestamp'] = timestamp
448
+ if mask is False:
449
+ img = self.fid.read(**kwargs)
450
+ else:
451
+ img = self.fid.read_masked_data(**kwargs)
452
+
453
+ return img
454
+
455
+ def read(self, timestamp, **kwargs):
456
+ return self._assemble_img(timestamp, **kwargs)
457
+
458
+ def write_multiple(self, root_path, start_date, end_date, stackfile='stack.nc',
459
+ **kwargs):
460
+ """
461
+ Create multiple netcdf files or a netcdf stack in the passed directoy for
462
+ a range of time stamps. Note that stacking gets slower when the stack gets larger.
463
+ Empty images (if no original data can be loaded) are excluded here as
464
+ well.
465
+
466
+ Parameters
467
+ ----------
468
+ root : str
469
+ Directory where the files / the stack are/is stored
470
+ start_date : datetime
471
+ Start date of images to write down
472
+ end_date
473
+ Last date of images to write down
474
+ stackfile : str, optional (default: 'stack.nc')
475
+ Name of the stack file to create in root_path. If no name is passed
476
+ we create single images instead of a stack with the same name as
477
+ the original images (faster).
478
+ kwargs:
479
+ kwargs that are passed to the image reading function
480
+ """
481
+ timestamps = self.tstamps_for_daterange(start_date, end_date)
482
+ for t in timestamps:
483
+ self.read(t, **kwargs)
484
+ if self.fid.image_missing:
485
+ continue
486
+ if stackfile is None:
487
+ subdir = os.path.join(root_path, str(t.year))
488
+ if not os.path.exists(subdir): os.makedirs(subdir)
489
+ filepath = os.path.join(subdir, os.path.basename(self.fid.filename))
490
+ else:
491
+ filepath = os.path.join(root_path, stackfile)
492
+ print(f"{'Write' if not stackfile else 'Stack'} image for {str(t)}...")
493
+ self.fid.write(filepath)
494
+
495
+ def tstamps_for_daterange(self, start_date, end_date):
496
+ """
497
+ return timestamps for daterange,
498
+ Parameters
499
+ ----------
500
+ start_date: datetime.datetime
501
+ start of date range
502
+ end_date: datetime.datetime
503
+ end of date range
504
+ Returns
505
+ -------
506
+ timestamps : list
507
+ list of datetime objects of each available image between
508
+ start_date and end_date
509
+ """
510
+ img_offsets = np.array([timedelta(hours=0)])
511
+
512
+ timestamps = []
513
+ diff = end_date - start_date
514
+ for i in range(diff.days + 1):
515
+ daily_dates = start_date + timedelta(days=i) + img_offsets
516
+ timestamps.extend(daily_dates.tolist())
517
+
518
+ return timestamps
smos/misc.py ADDED
@@ -0,0 +1,91 @@
1
+ import pandas as pd
2
+ import os
3
+ from datetime import date
4
+ import typing as t
5
+ import yaml
6
+
7
+ def _get_first_and_last_file(path: str):
8
+ # Get list of all years (folders) in the path
9
+ years = sorted([folder for folder in os.listdir(path) if folder.isdigit()], key=int)
10
+
11
+ if not years:
12
+ return None, None
13
+
14
+ # Get the first year and last year
15
+ first_year = years[0]
16
+ last_year = years[-1]
17
+
18
+ # Handle the first year
19
+ first_year_path = os.path.join(path, first_year)
20
+ first_months = sorted([folder for folder in os.listdir(first_year_path) if folder.isdigit()], key=int)
21
+
22
+ if first_months:
23
+ first_month = first_months[0]
24
+ first_month_path = os.path.join(first_year_path, first_month)
25
+ first_days = sorted([folder for folder in os.listdir(first_month_path) if folder.isdigit()], key=int)
26
+
27
+ if first_days:
28
+ first_day = first_days[0]
29
+ first_day_path = os.path.join(first_month_path, first_day)
30
+ first_files = sorted(os.listdir(first_day_path))
31
+ first_file = first_files[0] if first_files else None
32
+ else:
33
+ first_day_path = first_month_path
34
+ first_files = sorted(os.listdir(first_day_path))
35
+ first_file = first_files[0] if first_files else None
36
+ else:
37
+ first_month_path = first_year_path
38
+ first_files = sorted(os.listdir(first_month_path))
39
+ first_file = first_files[0] if first_files else None
40
+
41
+ # Handle the last year
42
+ last_year_path = os.path.join(path, last_year)
43
+ last_months = sorted([folder for folder in os.listdir(last_year_path) if folder.isdigit()], key=int, reverse=True)
44
+
45
+ if last_months:
46
+ last_month = last_months[0]
47
+ last_month_path = os.path.join(last_year_path, last_month)
48
+ last_days = sorted([folder for folder in os.listdir(last_month_path) if folder.isdigit()], key=int, reverse=True)
49
+
50
+ if last_days:
51
+ last_day = last_days[0]
52
+ last_day_path = os.path.join(last_month_path, last_day)
53
+ last_files = sorted(os.listdir(last_day_path))
54
+ else:
55
+ last_day_path = last_month_path
56
+ last_files = sorted(os.listdir(last_day_path))
57
+ else:
58
+ last_month_path = last_year_path
59
+ last_files = sorted(os.listdir(last_month_path))
60
+
61
+ return first_file, last_files[-1] if last_files else None
62
+
63
+
64
+ def _get_date(f: str) -> t.Union[date, None]:
65
+ for e in f.split('_'):
66
+ try:
67
+ dt = pd.to_datetime(e).to_pydatetime().date()
68
+ return dt
69
+ except Exception:
70
+ continue
71
+ return None
72
+
73
+
74
+ def get_first_last_day_images(img_path: str) -> \
75
+ (t.Union[date, None], t.Union[date, None]):
76
+ f, l = _get_first_and_last_file(img_path)
77
+ first_day = _get_date(f) if f is not None else f
78
+ last_day = _get_date(l) if l is not None else f
79
+
80
+ return first_day, last_day
81
+
82
+ def read_summary_yml(path: str) -> dict:
83
+ """
84
+ Read image summary and return fields as dict.
85
+ """
86
+ path = os.path.join(path, 'overview.yml')
87
+
88
+ with open(path, 'r') as stream:
89
+ props = yaml.safe_load(stream)
90
+
91
+ return props