Source code for monitoring

'''
A data-organization, -storage and analysis scheme for long-term tower monitoring

store binary files on hard disk
time synchronization must be achieved prior to analysis:
  cases
   pc/controller is ahead of controller/pc
   daylight saving
   clock of pc/controller changed
   available information:
     timestamp controller,  → Reference, because it does not change often and there is no daylight saving, though it may not relate to the “real time”
     timestamp pc,
     filetime controller,
     filetime pc
correct (and synchronize) files and store as binary on hard disk
store results in a database structure and link to the file on disk; preferred data-organization:
using different averages 1m, 10m and 1 hour should be possible
    → create multiple databases

_____________________________________________________________________
Database: File Information (file_info_<quantity>.nc)

Coordinates: time (synchronized to controller time but in local timezone (w/ daylight saving), channels
Variables:

File Information:
file_name ('time',)
file_size ('time',)
file_time ('time',)
start_time ('time',)

Content Information:
num_channels ('time',)
units ('channels',)
sample_rate ('time',)
duration ('time',) (in seconds)
length ('time',) (in samples)
error ('time', 'channels')

Statistical Description of the recorded signal:
mean ('time', 'channels')
min ('time', 'channels')
max ('time', 'channels')
var ('time', 'channels')
skewness ('time', 'channels')
kurtosis ('time', 'channels')
q05 ('time', 'channels')
q50 ('time', 'channels')
q95 ('time', 'channels')
rms ('time', 'channels')

_____________________________________________________________________
Database: Statistics (stats_<quantity>.nc)

Coordinates: time, channels
Variables:

Content Information:
num_channels ('time',)
sample_rate ('time',)
length ('time',)
error ('time', 'channels')

Statistical Description of the sliced and processed signal:
mean ('time', 'channels')
min ('time', 'channels')
max ('time', 'channels')
var ('time', 'channels')
skewness ('time', 'channels')
kurtosis ('time', 'channels')
q05 ('time', 'channels')
q50 ('time', 'channels')
q95 ('time', 'channels')
rms ('time', 'channels')


_____________________________________________________________________
Database: Modal results (modal_<quantity>.nc)

Coordinates: time, channels, modes
Variables:

Content information of the sliced signal:
num_channels ('time',)
sample_rate ('time',)
length ('time',)
error ('time', 'channels')

Operational Modal Analysis
num_modes ('time',)
frequencies ('time', 'modes')
damping ('time', 'modes')
model_orders ('time', 'modes')
std_damping ('time', 'modes')
std_frequencies ('time', 'modes')
var_svd_psd ('time', 'channels')
modeshapes ('time', 'modes', 'channels')
MPC ('time', 'modes')
MPD ('time', 'modes')

Statistical description of the singular value PSD:
max_svd_psd ('time', 'channels')
mean_svd_psd ('time', 'channels')
min_svd_psd ('time', 'channels')
skewness_svd_psd ('time', 'channels')
kurtosis_svd_psd ('time', 'channels')
q05_svd_psd ('time', 'channels')
q50_svd_psd ('time', 'channels')
q95_svd_psd ('time', 'channels')
rms_svd_psd ('time', 'channels')
energy_svd_psd ('time', 'channels')

Statistical Description of the sliced and processed signal:
mean ('time', 'channels')
min ('time', 'channels')
max ('time', 'channels')
var ('time', 'channels')
skewness ('time', 'channels')
kurtosis ('time', 'channels')
q05 ('time', 'channels')
q50 ('time', 'channels')
q95 ('time', 'channels')
rms ('time', 'channels')
_____________________________________________________________________
'''
# coding: utf-8

import os
import glob
import sys
import contextlib
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

import re
import pytz
import datetime
import time
import tzlocal
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional

berlin_dst = pytz.timezone('Europe/Berlin')  # cet/cest

from time_convention import TC

# ---------------------------------------------------------------------------
# Site registry — must be defined before any site module is imported
# ---------------------------------------------------------------------------

[docs] @dataclass class Site: """Contract between the generic engine and a site-specific implementation. All callables are pure functions with no side-effects on the engine state. Path / channel / range fields carry site-specific configuration that was previously read directly from config.py by the engine. """ name: str # --- processing callbacks --- transforms: Dict[str, Callable] # quantity -> callable(*Slice, quantity, **kw) -> Slice | None setup_prep: Dict[str, Callable] # quantity -> callable(headers) -> (ref_ch, accel_ch, disp_ch, chan_dofs) error_rules: Dict[str, dict] # quantity -> {kurtosis_max, kurtosis_min} sync_policy: Callable # (start_time, file_time, duration) -> datetime modal_bands: List[tuple] # [(f_lo, f_hi), …] for split_modepairs file_list_fn: Callable # (origin, reduced, file_info) -> list[str] channel_mean_fn: Optional[Callable] # (header, measurement, headers) -> float | None preproc_channels: Dict[str, list] # quantity -> [channel names for OMA pre-processing] # --- site configuration (formerly in config.py) --- db_root_path: str slice_root_path: str modal_conf_dir: str file_root_path: str origins: Dict[str, str] # quantity -> origin tag subpaths: Dict[str, str] # origin tag -> relative filesystem path all_channels: Dict[str, list] # quantity -> required channel list optional_channels: Dict[str, list] # quantity -> optional channel list dtstarts: Dict[str, object] # origin -> earliest datetime ranges: Dict[str, tuple] # channel name -> (min, max) plausibility range
_SITES: Dict[str, Site] = {} _active_site: Optional[Site] = None # --------------------------------------------------------------------------- # Mutable process-level state (not site-specific) # --------------------------------------------------------------------------- from collections import deque as _deque _file_cache: _deque = _deque(maxlen=25) _ds_cache: dict = {} _pid: str = str(os.getpid())
[docs] def register_site(site: Site) -> None: """Add *site* to the module-level site registry under ``site.name``. Parameters ---------- site : Site A fully configured :class:`Site` dataclass instance. """ _SITES[site.name] = site
[docs] def get_site(name: str) -> Site: """Retrieve a previously registered site by name. Parameters ---------- name : str The ``site.name`` used when ``register_site`` was called. Returns ------- Site The registered :class:`Site` instance. Raises ------ KeyError When no site with *name* has been registered. """ return _SITES[name]
[docs] def set_active_site(site: Site) -> None: """Mark *site* as the process-level active site. All engine functions that depend on site configuration (channel lists, path roots, callbacks) read ``_active_site`` instead of reaching into ``config.py`` directly. Parameters ---------- site : Site A :class:`Site` instance that has already been passed to :func:`register_site`. """ global _active_site _active_site = site
[docs] def get_active_site() -> Optional[Site]: """Return the currently active site, or ``None`` if none is set. Returns ------- Site or None The active :class:`Site` instance, or ``None`` if :func:`set_active_site` has not been called yet. """ return _active_site
import math import numpy as np import scipy.stats import scipy.signal import scipy.signal.ltisys import scipy.interpolate import bz2 import fbg_strain_reader import gantner_reader import pandas as pd import xarray as xr from MultiLock import MultiLock from pyOMA.core.PreProcessingTools import GeometryProcessor,PreProcessSignals from pyOMA.core.StabilDiagram import StabilCluster, StabilPlot try: from pyOMA.GUI.StabilGUI import start_stabil_gui except ImportError: start_stabil_gui = None # from GUI.PlotMSHGUI import start_msh_gui from pyOMA.core.VarSSIRef import VarSSIRef #from PLSCF import PLSCF #from SSIData import SSIDataMC reader_tz = tzlocal.get_localzone()
[docs] def get_file_list(origin, reduced=False, file_info=None): """Return a list of raw data file paths for the given origin. Delegates to the active site's ``file_list_fn`` callback. Parameters ---------- origin : str Origin tag (e.g. ``'accel'``, ``'wind'``). reduced : bool, optional When ``True``, the callback should return only files not already present in *file_info* (incremental update mode). file_info : xarray.Dataset or None, optional The current file-info database; used by the callback when *reduced* is ``True`` to filter already-indexed files. Returns ------- list of str Absolute paths to the raw data files. Raises ------ RuntimeError When no site has been registered (``_active_site`` is ``None``) or the site has no ``file_list_fn``. """ site = _active_site if site is None or site.file_list_fn is None: raise RuntimeError( "No file_list_fn registered. Import the appropriate site module first." ) return site.file_list_fn(origin, reduced, file_info)
[docs] def read_file(path): ''' read binary file from path return array, start_time, duration, headers, path may be a csv,dat or bin file or a bzip2ed version of it or an incomplete path refering to the 8 files generated by illumisense (common path part of these files, ends in underscore, has not extension) illumisense files may be .txt or .txt.bz2 we first try to read a .npz version for this file, if it exists then we try to open the bz2 file and pass the file descriptor to the following function then we call the respective function to read the file the illumisense reader must handle bz2 internally Short comparison of slowdown and space saving using bz2. packed files time speedup/slowdown [%] filesize space saving [%] .dat 1,64E-07 100,00 14400768 .dat.bz2 1,27E-06 12,91 10300268 28,47 .csv 1,85E-07 100,00 40320184 .csv.bz2 9,55E-07 19,37 9115468 77,39 .txt 8,32E-07 100,00 3260560549 .txt.bz2 2,29E-06 36,30 217251268 93,34 .npz 3,92E-08 2125,37 .bin 1,26E-04 100,00 34195960 .bin.bz2 1,26E-04 100,00 2882224 91,57 .npz 4,90E-07 25617,82 ''' if path.endswith('_'): assert (os.path.exists(path+'strain-1.txt') or os.path.exists(path+'strain-1.txt.bz2')) else: if not os.path.exists(path): logger.warning('File {} does not exist'.format(os.path.basename(path))) return None logger.info(f'Reading File: {os.path.basename(path)}') for f_dict in _file_cache: if f_dict.get('path', '') == path: logger.debug('Returning File from cache ') file_time = f_dict['file_time'] file_size = f_dict['file_size'] headers = f_dict['headers'] units = f_dict['units'] start_time = f_dict['start_time'] sample_rate = f_dict['sample_rate'] measurement = f_dict['measurement'] return file_time, file_size, headers, units, start_time, sample_rate, measurement if path.endswith('_'): if os.path.exists(path+'strain-1.txt'): file_time = datetime.datetime.fromtimestamp(os.path.getmtime(path+'strain-1.txt'), tz=reader_tz) elif os.path.exists(path+'strain-1.txt.bz2'): file_time = datetime.datetime.fromtimestamp(os.path.getmtime(path+'strain-1.txt.bz2'), tz=reader_tz) else: file_time = datetime.datetime.fromtimestamp(os.path.getmtime(path), tz=reader_tz) if path.endswith('_'): #strain txt files, one for each channel of the interrogator file_size=0 for j in [2,3,4]: if os.path.exists(path+'strain-{}.txt'.format(j)): file_size+=os.path.getsize(path+'strain-{}.txt'.format(j)) if os.path.exists(path+'strain-{}.txt.bz2'.format(j)): file_size+=os.path.getsize(path+'strain-{}.txt.bz2'.format(j)) else: file_size = os.path.getsize(path) file,ext = os.path.splitext(path) if ext == '.bz2': zipfile = bz2.BZ2File(path, 'r') try: zipfile.seek(0,2) zipfile.seek(0,0) except (EOFError, OSError): logger.warning('BZ2File is corrupted: {}'.format(path)) return None file,ext = os.path.splitext(file) else: zipfile = None try: in_dict = np.load(file+ext+'.npz', allow_pickle=True) headers = list(in_dict['headers']) units = list(in_dict['units']) start_time=in_dict['startTimestamp'].item() if isinstance(start_time, pd.Timestamp): start_time = start_time.to_pydatetime()# if from txt: <class 'datetime.datetime'> <class 'pandas._libs.tslibs.timestamps.Timestamp'> if (ext == '.bin' or ext == '') and not start_time.tzinfo: start_time = fbg_strain_reader.localize(start_time) sample_rate=in_dict['sample_rate'].item() measurement=in_dict['measurement'] _file_cache.append({'path': path, 'file_time':file_time, 'file_size':file_size, 'headers':headers, 'units':units, 'start_time':start_time, 'sample_rate':sample_rate, 'measurement':measurement }) return file_time, file_size, headers, units, start_time, sample_rate, measurement except Exception as e: if os.path.exists(file+'.npz'): logger.exception(e) os.remove(file+'.npz') if zipfile is not None: file = zipfile else: file=path try: if ext == '.dat': file_contents = gantner_reader.read_bin(file) # <class 'datetime.datetime'> <class 'datetime.datetime' elif ext == '.csv': file_contents = gantner_reader.read_csv(file, path) elif ext == '.bin': file_contents = fbg_strain_reader.read_bin(file, path=path) elif ext == '': file_contents = fbg_strain_reader.read_strain_txt(file) elif ext==".filepart": return None else: logger.warning('Extension was neither .dat, .csv, .bin, or None but {}'.format(ext)) return None except OSError as e: logger.exception(e) return None if file_contents is None: return None headers, units, start_time, sample_rate, measurement = file_contents _file_cache.append({'path': path, 'file_time':file_time, 'file_size':file_size, 'headers':headers, 'units':units, 'start_time':start_time, 'sample_rate':sample_rate, 'measurement':measurement }) return file_time, file_size, headers, units, start_time, sample_rate, measurement
[docs] def get_file_info(origin: str, create_new: bool=False, **kwargs): """Load or (re-)create the file-info database for *origin*. Parameters ---------- origin : str Origin tag identifying which sensor group to query. create_new : bool, optional When ``True``, call :func:`create_file_info` instead of reading from disk. Defaults to ``False``. **kwargs Forwarded to :func:`create_file_info` when *create_new* is ``True``. Returns ------- xarray.Dataset or None The file-info dataset with an additional ``gap_length`` variable (samples between consecutive files), or ``None`` if the on-disk database is newly created and empty. """ site = _active_site ds_path = os.path.join(site.db_root_path, f'file_info_{origin}.nc') if not os.path.exists(ds_path): ds = xr.Dataset() ds.to_netcdf(ds_path, format='NETCDF4') ds.close() logger.warning(f"DB Path {ds_path} was recreated.") return None if not create_new: stat_result = os.stat(ds_path, follow_symlinks=True) cache_key = f'{origin}_file_info' cached = _ds_cache.get(cache_key) if cached is not None and cached['mtime'] == stat_result.st_mtime: logger.info('Getting file info for {} (cached)'.format(origin)) ds = cached['ds'] else: logger.info('Getting file info for {}'.format(origin)) ds = xr.open_dataset(ds_path) ds.load() ds.close() _ds_cache[cache_key] = {'ds': ds, 'mtime': stat_result.st_mtime} else: ds = create_file_info(origin, **kwargs) compute_gap_lengths(ds) return ds
[docs] def close_to_utc_transition(file_time, close_hours=3): """Return ``True`` when *file_time* is near a DST transition. Thin wrapper around :meth:`TimeConvention.is_near_dst_transition` that also emits an ``INFO`` log message when the guard fires. Parameters ---------- file_time : datetime.datetime The timestamp to check. close_hours : int, optional Half-width of the exclusion window in hours. Defaults to 3. Returns ------- bool ``True`` when *file_time* is within ±*close_hours* of any DST transition in the project timezone. """ result = TC.is_near_dst_transition(file_time, hours=close_hours) if result: logger.info('Timestamp is within +- {} hours of daylight saving transition time: {}'.format(close_hours, file_time)) return result
[docs] def round_dt(dt: np.datetime64, duration: np.timedelta64, ceil: bool=True, floor: bool=False): """Round *dt* up (or down) to the nearest multiple of *duration*. Parameters ---------- dt : numpy.datetime64 The datetime to round. duration : numpy.timedelta64 Rounding grid size (e.g. ``np.timedelta64(120, 'm')`` for 2-hour intervals). ceil : bool, optional Round toward the next grid point (default ``True``). floor : bool, optional Round toward the previous grid point. Setting this to ``True`` forces *ceil* to ``False``. Returns ------- numpy.datetime64 *dt* rounded to the nearest *duration* boundary. """ if floor: ceil=False td = np.timedelta64(duration,'m') mindt = np.array(0,dtype='datetime64[ns]') mindt = np.datetime64('2015-05-20 00:00') # convert dt to timedelta, compute remainder and add it dt = (mindt - dt) % td + dt if floor: dt -= td return dt
[docs] def get_synchronized_time(start_time, file_time, duration): """Return the synchronised start time via the active site's sync policy.""" site = _active_site if site is not None: return site.sync_policy(start_time, file_time, duration) return start_time
[docs] def create_file_info(origin: str, chunksize: int=50, skip_existing: bool=True, **kwargs): ''' file_name, file_size, file_creation_time, num_channels, headers, units, sample_rate, type (qstation, labview), start_time,<- 1st run length (in samples), per channel: {errors, mean, min, max, var, skewness, kurtosis, q05, q95, q50, rms} <- 1st run next_file, gap_length (in_samples) <- 2nd run ''' logger.info('Creating file info for {}'.format(origin)) ds_path = os.path.join(_active_site.db_root_path, 'file_info_{}.nc'.format(origin)) if os.path.exists(ds_path): ds = xr.open_dataset(ds_path) ds.load() ds.close() else: ds = xr.Dataset() ds.to_netcdf(ds_path, format='NETCDF4') ds.close() logger.warn(f"DB Path {ds_path} was recreated.") reduced = kwargs.pop('reduced',True) filtered_list = kwargs.pop('filtered_list',False) if filtered_list and reduced and len(ds.dims)>0: filelist = get_file_list(origin, reduced, ds) else: filelist = get_file_list(origin, reduced) logger.info('Total number of files: {}'.format(len(filelist))) logger.info('Total number of files already read: {}'.format(len(ds.time) if 'time' in ds else 0)) num_workers = kwargs.get('num_workers',1) this_worker = kwargs.get('this_worker',1) jobsize = int(np.ceil(len(filelist)/num_workers)) start = int((this_worker-1)%num_workers) logger.debug('{} {}'.format(start, jobsize)) i = 0 while i*chunksize<jobsize: ds = xr.open_dataset(ds_path) ds.load() changed = False for i, file in enumerate(filelist[start*jobsize:(start+1)*jobsize]): #for file in filelist[i * chunksize:(i+1) * chunksize]: now=time.time() file_name = os.path.basename(file) if 'file_name' in ds : if (file_name == ds.get('file_name')).any(): if skip_existing: logger.debug('{} already present in dataset. Skipping!'.format(file_name)) continue else: logger.info('{} already present in dataset. Updating!'.format(file_name)) ds = ds.where(file_name != ds.get('file_name'), drop=True)# drop the entry for this file_name file_contents = read_file(file) if file_contents is None: logger.warning('File unreadable, consider deleting it manually: {}'.format(file)) continue file_time, file_size, headers, units, start_time, sample_rate,measurement = file_contents if close_to_utc_transition(file_time): continue duration = datetime.timedelta(seconds=measurement.shape[0]/sample_rate) dst = xr.Dataset() dst['file_name'] = (('time'), np.array([file_name],dtype=str)) dst['file_size'] = (('time'), [file_size]) dst['file_time'] = (['time'], [TC.to_posix(file_time)]) dst['start_time'] = (['time'], [TC.to_posix(start_time)]) # convert back using dst['start_time'].astype('datetime64[s]') dst['num_channels'] = (['time'], [measurement.shape[1]]) dst['units'] = (['channels'], np.array(units,dtype=str)) dst['sample_rate'] = (['time'], [sample_rate]) dst['duration']=(['time'],[np.asarray(duration, dtype='timedelta64[s]').astype(np.float64)]) dst['duration'].attrs['units'] = 'seconds' dst['length']= (['time'], [measurement.shape[0]]) this_dict=describe_stats(measurement, headers) for var_name, _value in this_dict.items(): dst[var_name] = (['time','channels'], [this_dict[var_name]]) sync_time = get_synchronized_time(start_time, file_time, duration) dst.coords['channels'] = np.array(headers,dtype=str) # TODO: time is not a unique identifier # if two files of the same quantity and origin were started at the same time (highly unlikely), results will get overwritten dst.coords['time'] = [TC.to_storage_coord(sync_time)] ds=ds.combine_first(dst) changed=True logger.debug(f'Success: , {(file_name==ds["file_name"]).any().item()}, Length: , {len(ds.time)},:, {len(ds.channels)}, Loading Time: , {time.time()-now}, s') i += 1 if changed: now=time.time() # logger.info('saving ') save_ds(ds, ds, ds_path, what='file_info', reload_current=True) # ds.to_netcdf(os.path.join(path,'result_db/file_info_{}.nc'.format(origin)), format='NETCDF4') # logger.debug('{time.time()-now} s') ds.close() return ds
[docs] def describe_stats(measurement, headers=None, quantity=None): """Compute per-channel descriptive statistics for a measurement array. For each column of *measurement* the following statistics are computed: mean, min, max, variance, skewness, kurtosis, 5th/50th/95th percentiles, and RMS. An ``error`` flag is set when a channel contains only NaN values, has min == max (flatline), or violates the plausibility range defined in the active site's ``ranges`` dict. Parameters ---------- measurement : numpy.ndarray, shape (n_samples, n_channels) Raw or preprocessed signal matrix. May contain ``NaN`` values. headers : list of str, optional Channel names in the same order as columns in *measurement*. Used to look up plausibility ranges and site-specific circular-mean functions. Defaults to empty strings. quantity : str, optional Quantity tag (e.g. ``'accel'``) used to look up ``error_rules`` (kurtosis thresholds) in the active site. Returns ------- dict Keys: ``'mean'``, ``'min'``, ``'max'``, ``'var'``, ``'skewness'``, ``'kurtosis'``, ``'q05'``, ``'q50'``, ``'q95'``, ``'rms'``, ``'error'``. Each value is a 1-D ``numpy.ndarray`` of length ``n_channels``. """ if headers is None: headers = ['' for channel in range(measurement.shape[1])] this_dict = {} for key in ['mean','min','max','var','skewness','kurtosis','q05','q50','q95','rms','error']: this_dict[key]=np.array([np.nan for channel in headers]) for channel, header in enumerate(headers): try: if np.isnan(measurement[:,channel]).all(): this_dict['error'][channel]=True continue site = _active_site this_range = site.ranges.get(header, None) if site is not None else None site_mean_fn = site.channel_mean_fn if site is not None else None circular_mean = site_mean_fn(header, measurement, headers) if site_mean_fn is not None else None if circular_mean is not None: mean = circular_mean Wr = np.copy(measurement[:, channel]) Wr -= mean Wr[Wr < -180] += 360 Wr[Wr > 180] -= 360 Wr += mean _nobs, (min_, max_), _, variance, skewness, kurtosis = scipy.stats.describe(Wr, nan_policy='omit') q05, q50, q95 = np.nanpercentile(Wr, q=[5, 50, 95]) rms = np.sqrt(np.nanmean(np.square(Wr))) else: _nobs, (min_,max_), mean, variance, skewness, kurtosis = scipy.stats.describe(measurement[:,channel],nan_policy='omit') q05,q50,q95 = np.nanpercentile(measurement[:,channel], q=[5,50,95]) rms = np.sqrt(np.nanmean(np.square(measurement[:,channel]-mean))) this_dict['mean'][channel]=mean this_dict['min'][channel]=min_ this_dict['max'][channel]=max_ this_dict['var'][channel]=variance this_dict['skewness'][channel]=skewness this_dict['kurtosis'][channel]=kurtosis this_dict['q05'][channel]=q05 this_dict['q50'][channel]=q50 this_dict['q95'][channel]=q95 this_dict['rms'][channel]=rms this_dict['error'][channel]=False if min_ == max_: this_dict['error'][channel]=True if this_range is not None: if max_ > this_range[1]: this_dict['error'][channel]=True if min_ < this_range[0]: this_dict['error'][channel]=True if quantity is not None and _active_site is not None: rules = _active_site.error_rules.get(quantity, {}) if rules.get('kurtosis_max') is not None and kurtosis > rules['kurtosis_max']: this_dict['error'][channel] = True if rules.get('kurtosis_min') is not None and kurtosis < rules['kurtosis_min']: this_dict['error'][channel] = True except Exception: raise return this_dict
def check_and_mark_errors(ds, new = True, check_kurtosis = False): """Check signal plausibility and update the ``error`` flag in a dataset. Parameters ---------- ds : xarray.Dataset A statistics or modal-results dataset that has ``min``, ``max``, ``kurtosis``, and ``error`` data variables with a ``channels`` coordinate. new : bool, optional When ``True`` (default), discard any pre-existing ``error`` flags and compute them from scratch. When ``False``, increment the existing flags (i.e. OR new detections with old ones). check_kurtosis : bool, optional When ``True``, flag channels whose kurtosis falls outside ``[-2, 5]``. Defaults to ``False``. Returns ------- xarray.Dataset *ds* with its ``error`` variable updated in-place. """ for channel in ds.channels: this_range = _active_site.ranges.get(channel.variable.item(), None) if _active_site is not None else None this_range = None error=ds.sel(channels=channel).get('error') if new: error=0 error += ds.sel(channels=channel).get('min')==ds.sel(channels=channel).get('max') if this_range is not None: error += ds.sel(channels=channel).get('max')>this_range[1] error += ds.sel(channels=channel).get('min')<this_range[0] logger.debug(channel.channels.variable.data.item()) if check_kurtosis: logger.debug(channel.channels.variable.data) error += ds.sel(channels=channel).get('kurtosis')>5 error += ds.sel(channels=channel).get('kurtosis')<-2 ds['error'].loc[:,channel] = error return ds
[docs] def get_stats(quantity: str, duration: pd.Timedelta, file_info: xr.Dataset=None, create_new: bool=False, **kwargs): """Load or (re-)create the statistics database for *quantity*. Parameters ---------- quantity : str Quantity tag (e.g. ``'accel'``, ``'wind'``). duration : pandas.Timedelta Analysis window length (e.g. ``pd.Timedelta('2h')`` for 120-minute statistics). file_info : xarray.Dataset or None, optional File-info dataset required when *create_new* is ``True``. create_new : bool, optional When ``True``, run :func:`create_stats` instead of reading from the existing NetCDF file. Defaults to ``False``. **kwargs Forwarded to :func:`create_stats` when *create_new* is ``True``. Returns ------- xarray.Dataset Statistics dataset with variables ``mean``, ``min``, ``max``, ``var``, ``skewness``, ``kurtosis``, ``q05``, ``q50``, ``q95``, ``rms``, and ``error`` indexed by ``(time, channels)``. Raises ------ RuntimeError When *create_new* is ``True`` but *file_info* is ``None``. """ site = _active_site minutes = int(duration.total_seconds()/60) ds_path = os.path.join(site.db_root_path, f'{minutes}-minutes/', 'stats_{}.nc'.format(quantity)) if not os.path.exists(ds_path): logger.warning(f'Path for stats does not exist {ds_path}.') if file_info is None and create_new: raise RuntimeError('File info xarray has to be provided to create a statistical description') if not create_new: stat_result = os.stat(ds_path, follow_symlinks=True) cache_key = f'{quantity}_stats' cached = _ds_cache.get(cache_key) if cached is not None and cached['mtime'] == stat_result.st_mtime: logger.info('Getting statistics for {} (cached)'.format(quantity)) ds = cached['ds'] else: logger.info('Getting statistics for {}'.format(quantity)) ds = xr.open_dataset(ds_path) ds.load() ds.close() _ds_cache[cache_key] = {'ds': ds, 'mtime': stat_result.st_mtime} else: ds = create_stats(quantity, duration, file_info, **kwargs) return ds
[docs] def create_stats(quantity: str, duration: pd.Timedelta, file_info: xr.Dataset, chunksize: int=10, skip_existing: bool=False, **kwargs): ''' mean, min, max, var, skewness, kurtosis, q05, q95, q50, rms ''' logger.info('Creating statistics for {} '.format(quantity)) minutes = int(duration.total_seconds()/60) site = _active_site process_ds_path = os.path.join(site.db_root_path, f'{minutes}-minutes/', f'stats_{quantity}.{_pid}.nc') master_ds_path = os.path.join(site.db_root_path, f'{minutes}-minutes/', 'stats_{}.nc'.format(quantity)) if os.path.exists(process_ds_path): process_ds = xr.open_dataset(process_ds_path) process_ds.load() process_ds.close() else: process_ds = xr.Dataset() os.makedirs(os.path.join(site.db_root_path, f'{minutes}-minutes/'), exist_ok=True) process_ds.to_netcdf(process_ds_path, format='NETCDF4') process_ds.close() if os.path.exists(master_ds_path): master_ds = xr.open_dataset(master_ds_path) master_ds.load() master_ds.close() else: master_ds = xr.Dataset() origin = site.origins[quantity] dtstart = site.dtstarts[origin] dtstart = kwargs.pop('dtstart', dtstart) dtstart = pd.Timestamp(dtstart).to_pydatetime() fi_time_max = (file_info.time + file_info.duration * np.timedelta64(1, 's')).max().values fi_time_max = round_dt(fi_time_max, duration, ceil=True) fi_time_max = pd.Timestamp(fi_time_max).to_pydatetime() until = kwargs.pop('until', fi_time_max) logger.debug(f'Analyzing time range: {dtstart} ... {until} <= {fi_time_max}') # time_iterator consists of non timezone-aware items, # each item will be converted to timezone-aware pd.Timestamp, # thus time_iterator is interpreted to be in "Europe/Berlin" with DST # conversion to UTC by ts.to_datetime64() results in: # even summer hours and uneven winter hours(for 120 minute slices) # the converted UTC times are the indices of the database # the only place, were timezone native times are used get_slice_corrected to generate the filename (=even hours year round) # file_info['start_time'/'file_time'] is using timestamps (which are UTC by definition = seconds since 1.1.1970 (UTC)) # file_info['time'] is derived from tz-aware start_time and file_time in get_synchronized_time and converted to UTC _aware_iter, time_iter_naive = TC.make_index(dtstart, until, minutes) validate_slices = kwargs.pop('validate_slices', False) if skip_existing and not validate_slices and 'time' in master_ds.dims: # drop all time instances without results to try them again, in case previous runs failed before proceeding them stats_ds = master_ds.dropna(dim='time', how='all') # filter out errorneous files to not do them again every time stats_ds = stats_ds.time[~np.logical_or(stats_ds['error'].any(dim='channels'), stats_ds.length.isnull())] time_iterator = np.setdiff1d(time_iter_naive, stats_ds.time.data, assume_unique=True) else: time_iterator = time_iter_naive num_workers = kwargs.get('num_workers',1) this_worker = kwargs.get('this_worker',1) jobsize=int(np.ceil(len(time_iterator)/num_workers)) start = int((this_worker-1)%num_workers) #logger.debug('this_worker: {}, num_workers: {}, duration: {}, quantity: {}'.format(this_worker, num_workers, duration, quantity)) #return for i, time_ in enumerate(time_iterator[start*jobsize:(start+1)*jobsize]): logger.debug(time_) start_time = TC.to_local(time_) # if not (i+1)%50: print('.',end='', flush=True) # if not (i+1)%2500: print('\n') if close_to_utc_transition(start_time): continue if 'time' in master_ds.coords: if (start_time.to_datetime64()==master_ds.coords['time']).any(): if skip_existing: # other checks may be added here if not validate_slices: logger.debug(f'{str(start_time)} already present in master dataset. Skipping!') continue if master_ds.sel(time=start_time)['mean'].isnull().all(): try: # with open(os.devnull, "w") as f, contextlib.redirect_stdout(f): old_level = logger.getEffectiveLevel() logger.setLevel(logging.WARNING) data_slice = get_slice_corrected(start_time, duration, quantity, file_info, **kwargs) logger.setLevel(old_level) except Exception as e: logger.exception(e) data_slice=None if data_slice is not None: logger.info(f'{str(start_time)} empty in master dataset, but slice available. Updating!') else: logger.debug(f'{str(start_time)} empty but present in master dataset. Skipping!') continue else: logger.debug(f'{str(start_time)} already present in master dataset. Skipping!') continue else: logger.info(f'{str(start_time)} already present in master dataset. Updating!') if 'time' in process_ds.coords: if (start_time.to_datetime64()==process_ds.coords['time']).any(): if skip_existing: # other checks may be added here continue else: logger.info(f'{str(start_time)} already present in process dataset. Updating!') process_ds = process_ds.where((start_time.to_datetime64()!=process_ds.coords['time']), drop=True) # other checks may be added here this_ds = xr.Dataset() try: old_level = logger.getEffectiveLevel() logger.setLevel(logging.WARNING) data_slice = get_slice_corrected(start_time, duration, quantity, file_info, **kwargs) logger.setLevel(old_level) except Exception as e: logger.exception(e) data_slice=None if data_slice is None: logger.debug('Returned Slice is empty. Skipping!') else: _actual_start_time, headers, _units, _end_time, sample_rate, measurement = data_slice this_ds['num_channels'] = (['time'], [measurement.shape[1]])#:len(headers), this_ds['sample_rate'] = (['time'], [sample_rate])#:sample_rate, this_ds['length']= (['time'], [measurement.shape[0]]) this_dict = describe_stats(measurement, headers, quantity) for var_name, _value in this_dict.items(): this_ds[var_name] = (['time','channels'], [this_dict[var_name]]) this_ds.coords['channels'] = np.array(headers,dtype=str) # this_ds.coords['time'] = [np.asarray(start_time, dtype='datetime64[ns]')] this_ds.coords['time'] = [np.asarray(start_time.to_datetime64())] process_ds = process_ds.combine_first(this_ds) logger.debug('Success: {}, Length: {}'.format((start_time.to_datetime64()==process_ds.coords['time']).any().item(), len(process_ds.time))) if i>0 and not i%chunksize: # process_ds should not have changed on disk during processing process_ds = save_ds(process_ds, process_ds, process_ds_path, what='stats') # process_ds should not have changed on disk during processing process_ds = save_ds(process_ds, process_ds, process_ds_path, what='stats') # master_ds almost certainly has changed on disk if multiple workers were processing files master_ds = save_ds(process_ds, master_ds, master_ds_path, reload_current=True, what='stats') logger.debug(f'Removing temporary dataset at {process_ds_path}') os.remove(process_ds_path) return master_ds
[docs] def save_ds(new_ds, current_ds, savepath, what='modal', reload_current = False): ''' defined behaviour: when creating new results: save to a unique netcdf file for each process in order to avoid loosing data unique netcdf files will be merged later when updating results: iterate over stats and existing modal results regenerate where necessary save to a unique netcdf file for each process upon process exit lock main netcdf merge main netcdf with processes netcdf while dropping conflicting indexes from main file ''' logger.debug('') now=time.time() with MultiLock(savepath): if reload_current and os.path.exists(savepath): if what=='modal': current_ds = xr.open_dataset(savepath, engine='h5netcdf') else: current_ds = xr.open_dataset(savepath) current_ds.load() current_ds.close() if not 'time' in new_ds: logger.debug('Not saving empty new dataset.') return current_ds logger.info(f'Merging Datasets {os.path.split(savepath)[-1]}') if 'time' in current_ds: old_length=len(current_ds.time) else: old_length = 0 if 'time' in current_ds: # for start_time in new_ds.time: # print('.',end='', flush=True) # current_ds = current_ds.where((start_time!=current_ds.coords['time']), drop=True) dupes, _ind_new, _ind_current = np.intersect1d(new_ds.time, current_ds.time, assume_unique=True, return_indices=True) len_before = len(current_ds.time) current_ds = current_ds.drop_sel(time=dupes) logger.debug(f'dropped {len_before - len(current_ds.time)} results that were already present in the db.') current_ds = current_ds.combine_first(new_ds) # For datasets, ds0.combine_first(ds1) works similarly to xr.merge([ds0, ds1]), # except that xr.merge raises MergeError when there are conflicting values in # variables to be merged, whereas .combine_first defaults to the calling object's values. logger.info('Dataset length before/after: {}/{}, '.format(old_length, len(current_ds.time))) else: logger.warning("'Dataset' object has not attribute 'time'. Overwriting!") current_ds = new_ds logger.info(f'Saving Dataset to {savepath}') tempfile = savepath + '.tmp' if what=='modal': current_ds.to_netcdf(tempfile, engine='h5netcdf', invalid_netcdf=True) elif what=='stats': current_ds.to_netcdf(tempfile, format='NETCDF4') elif what=='file_info': current_ds.to_netcdf(tempfile, format='NETCDF4') if os.path.exists(savepath): os.remove(savepath) os.rename(tempfile, savepath) logger.debug('{} s'.format( now-time.time())) return current_ds
[docs] def compute_gap_lengths(file_info): ''' check file_gaps (assumes files are sorted in time) this function only works reliably if all files have been read in before, therefore it can not be pre-computed and is re-computed every time the script is run in Peaks_*, when the file compression script starts, all data written afterwards to the currently active file was lost therefore we have a gap of some minutes every night around 1 AM CET/CEST other gaps may be due to pc restarts, controller restarts, ... Peaks_* files have to be interleaved sometime, when the recording stopped eg in channel 2 and continued in the next file in channel 3 then there is a gap of (-1) sample which is removed during interleaving ''' file_info['gap_length'] = xr.DataArray( TC.gap_lengths(file_info['start_time'], file_info['duration'], file_info['sample_rate']), dims=['time'], )
[docs] def get_slice(start_time, duration , quantity, file_info, upsample_fs=None): ''' channels 'Tagessekunden' and 'Time' are always dropped ''' time_range = (start_time.to_datetime64(), (start_time + duration).to_datetime64()) site = _active_site channels_required = site.all_channels[quantity] origin = site.origins[quantity] _subpath = site.subpaths[origin] # file_start_time = file_info.time #duration = file_info.duration #duration = ((file_info['length']+1)/file_info['sample_rate']).astype('timedelta64[s]') #duration.astype('float64')*(5/3)*1e-11 #duration in minutes file_end_time = file_info.time + file_info.duration * np.timedelta64(1, 's') # first select all files that end in time_range i.e 'time' is within time_range b1 = file_end_time>=np.datetime64(time_range[0]) # then also select all files, that start in time_range i.e. 'time'+'duration' is within time_range b2 = file_start_time<=np.datetime64(time_range[1]) # combine selectors and truncate dataset b = np.logical_and(b1, b2) file_info = file_info.where(b, drop=True)#.dropna(dim='time', how='all') if len(file_info['file_name']) == 0: logger.info('There is no file for: {} - {}; {} '.format(*time_range, quantity)) return if logger.isEnabledFor(logging.DEBUG): logger.debug(file_info['file_name']) logger.debug(file_info['sample_rate']) logger.debug(file_info['gap_length'][:-1]) logger.info('Extracting signal slice for {} starting at {} until {}'.format(quantity, *np.datetime_as_string(time_range, unit='m'))) if len(file_info['file_name']) > 1 and (file_info['gap_length'][:-1]>0.).any(): if quantity in [ 'temp', 'wind', 'strain_rosettes'] and (file_info['gap_length'][:-1]<32).all(): #gaps of 32 samples are allowed and will be interpolated (in strains) in qstation records there should be bigger gaps or none pass else: logger.info('Files are not consecutive. Gap of max. length {} s between files.'.format((file_info['gap_length']/file_info['sample_rate'])[:-1].data.max())) return # logger.debug(file_info['error']) if 'Tagessekunden' in file_info.channels.data: # file_info = file_info.drop(['Tagessekunden'],'channels') file_info = file_info.drop_sel(channels=['Tagessekunden']) if 'Time' in file_info.channels.data: # file_info = file_info.drop(['Time'],'channels') file_info = file_info.drop_sel(channels=['Time']) # logger.debug(file_info) file_info = file_info.dropna(dim='channels', how='any') # logger.debug(file_info.channels) if channels_required is not None: channels_in_file = set(file_info.channels.data.astype(str)) channels_to_drop = list(channels_in_file.difference(channels_required + site.optional_channels.get(quantity, []))) # file_info = file_info.drop(channels_to_drop,'channels') file_info = file_info.drop_sel(channels=channels_to_drop) # logger.debug(all_channels) if not set(channels_required).issubset(channels_in_file): logger.warning('Not all channels present, missing {}. Skipping!'.format(set(channels_required).difference(channels_in_file))) return channels = list(file_info.dropna(dim='channels', how='any').channels.data.astype(str)) logger.debug('Common Channels: {}'.format( channels)) if len(file_info['file_name']) == 0: raise RuntimeError('How did this happen?') if (file_info['error']==1.).any(): logger.debug('files are (partially) marked as errorneous. Affected channels: {}'.format(list(file_info.where(file_info['error'], drop=True).channels.data))) #return units = list(file_info['units'].isel(time=0).variable.data.astype(str)) logger.debug(units) logger.debug(len(file_info['file_name'])) # ensure time_range is fully covered by the selected files last_end = (file_info.time + file_info['duration'] * np.timedelta64(1, 's')).data[-1] first_start = file_info.time.data[0] if first_start > np.datetime64(time_range[0]) or last_end < np.datetime64(time_range[1]): logger.info('Time range {} - {} is not fully covered by files {}. Skipping!'.format(str(time_range[0]), str(time_range[1]), list(file_info.file_name.data))) return None all_measurements = [] new_start = None new_end = None # loop over all files in the range, load them strip unneeded parts and concatenate them together for file_num in range(len(file_info.time)): file = file_info.isel(time=file_num)['file_name'] gap_length = file_info.isel(time=file_num)['gap_length'] _sample_rate = file_info.isel(time=file_num)['sample_rate'] logger.debug(file.item()) filename = file.item() if 'Peaks' in filename: filename= os.path.join('binary_files_unusable',filename) file_path = os.path.join(site.file_root_path, site.subpaths[origin], filename) with open(os.devnull, "w") as f, contextlib.redirect_stdout(f): file_contents = read_file(file_path) if file_contents is None: logger.warning('File unreadable: {}'.format(file_path)) return None curr_file_time, _curr_file_size, curr_headers, _curr_units, curr_start_time, curr_sample_rate, curr_measurement = file_contents start_index = 0 end_index = curr_measurement.shape[0] # in case of strain measurements files may actually need to be interleaved # then one (or more) more line is needed from the end # start_time/end_time is the time instant between the first/last sample if 'strain' in quantity and time_range[0] < pd.Timestamp('2018-01-12', tz='Europe/Berlin').to_datetime64(): startTimestamp, endTimestamp, firstChannel, lastChannel, firstIndex, lastIndex = fbg_strain_reader.read_bin(file_path, indices_only=True) num_full_lines = (lastIndex+1-firstIndex)/4 assert num_full_lines == end_index-(firstChannel+(3-lastChannel))/4 logger.debug('(lastIndex+1-firstIndex)/4 {}'.format( (lastIndex+1-firstIndex)/4)) logger.debug('end_index-(firstChannel+(3-lastChannel))/4 {}'.format( end_index-(firstChannel+(3-lastChannel))/4)) curr_duration = endTimestamp - startTimestamp - datetime.timedelta(seconds=1/(4*curr_sample_rate)) curr_duration_ = num_full_lines/(curr_sample_rate) curr_sample_rate = num_full_lines/curr_duration.total_seconds() startTimestamp = startTimestamp - datetime.timedelta(seconds=firstChannel/(4*curr_sample_rate)) endTimestamp = endTimestamp + datetime.timedelta(seconds=(3-lastChannel)/(4*curr_sample_rate)) # in some cases labview stopped writing data to the file # in cases of early file ends, the end timestamp is computed from the linenumber and sample rate in fbg_strain_reader # however the file timestamp is still at the regular end of the file # so there is a large amount of data missing # in these cases we should not rely on the file time stamp sync_end_time = endTimestamp - datetime.timedelta(seconds=1/(4*curr_sample_rate)) curr_duration = endTimestamp - startTimestamp - datetime.timedelta(seconds=1/(4*curr_sample_rate)) curr_duration_ = end_index/(curr_sample_rate) assert abs(curr_duration.total_seconds()-curr_duration_)<1/curr_sample_rate #curr_start_time = curr_end_time - curr_duration sync_start_time = startTimestamp else: curr_duration = datetime.timedelta(seconds = (end_index)/curr_sample_rate) # in the case of q.station files, we need to rely on the file time for certain times sync_start_time = get_synchronized_time(curr_start_time, curr_file_time, curr_duration) sync_end_time = sync_start_time + curr_duration sync_start_time = np.datetime64(TC.to_storage_coord(sync_start_time)) sync_end_time = np.datetime64(TC.to_storage_coord(sync_end_time)) # if current file starts earlier than time_range[0] truncate some samples at the start if sync_start_time <= time_range[0]: truncate_time = np.timedelta64(time_range[0] - sync_start_time, 's') start_index = np.floor(truncate_time.astype('int') * curr_sample_rate).astype('int') new_start = sync_start_time + np.timedelta64(int(start_index/curr_sample_rate), 's') logger.debug('Truncating {} samples / {} seconds at the start of the file'.format(start_index, truncate_time.astype(int))) # if this is the first file and it starts later than time_range[0] then break because there is a gap before elif file_num == 0: if sync_start_time>time_range[0]: logger.debug('Gap before file. Skipping') return None # only one file that starts later than time_range elif len(file_info.time) == 1: logger.warning('Bug: only one file that starts later than time_range ') new_start = sync_start_time.item() # if current file ends later than time_range[1] truncate some samples at the end if sync_end_time >= time_range[1]: truncate_time = np.timedelta64(sync_end_time - time_range[1] ,'s') end_index_ = np.ceil(((np.timedelta64(curr_duration,'s') - truncate_time).astype(int) * curr_sample_rate)).astype(int) end_index, end_index_ = end_index_, end_index-end_index_ new_end = sync_end_time - np.timedelta64(int(end_index_/curr_sample_rate), 's') logger.debug('Truncating {} samples / {} seconds at the end of the file'.format(end_index_, truncate_time.astype(int))) # only one file that ends earlier than time_range elif len(file_info.time) ==1: logger.warning('Bug: only one file that ends earlier than time_range.') new_end =sync_end_time.item() # if last file ends earlier than time_range elif file_num == len(file_info.time)-1: logger.warning('Bug: last file ends earlier than time_range') new_end =sync_end_time.item() channel_index=[] for channel in channels: index = curr_headers.index(channel) channel_index.append(index) this_m = curr_measurement[start_index:end_index,channel_index] if this_m.shape[0]>0: all_measurements.append(this_m) else: #account for empty files all_measurements.append(np.zeros((0,len(channels)))) # fill missing rows, except after the last one if gap_length > 0 and file_num != len(file_info.time)-1: rows = int(np.floor(gap_length)) if rows > 0: curr_measurement = np.empty(( rows,len(channels))) curr_measurement[:]=np.NaN all_measurements.append(curr_measurement) logger.debug(all_measurements) #interleave consecutive files at_nan for meas_num in range(max(0,len(all_measurements)-1)): # check if nans in last row of current measurement # and nans in complementary position in first row of next measurement # may be a length-0 file slice -> skip if not all_measurements[meas_num].shape[0]: continue if not all_measurements[meas_num+1].shape[0]: continue a = all_measurements[meas_num ][-1,:] b = all_measurements[meas_num+1][ 0,:] if np.logical_or(np.isnan(a), np.isnan(b)).any(): b[np.isnan(b)] = a[np.isnan(b)] all_measurements[meas_num] = all_measurements[meas_num][:-1,:] this_measurement = np.vstack(all_measurements) this_sample_rate = this_measurement.shape[0]/np.timedelta64(new_end-new_start,'s').astype(int) # slice is empty if not this_measurement.shape[0]: return None # especially temperature measurements are taken only once in ten minutes (at some times) # we need to have some information about the time difference of the first and last sample from the start of the file # thus we upsample to an appropriate sample rate, s.t. these measurements can be used for temperature compensation if upsample_fs is not None: logger.info('Upsampling record from {} Hz to {} Hz'.format(this_sample_rate, upsample_fs)) num_samples_new = round(duration.total_seconds()*upsample_fs) logger.debug('num_samples_new: {}'.format( num_samples_new)) t_org = np.linspace(pd.Timestamp(new_start).value, pd.Timestamp(new_end).value, this_measurement.shape[0]) t_new = np.linspace(pd.Timestamp(time_range[0]).value, pd.Timestamp(time_range[1]).value, num_samples_new) if this_measurement.shape[0]==1: new_m = np.zeros((num_samples_new,this_measurement.shape[1])) new_m[:,:]=this_measurement else: if this_measurement.shape[0]<=2: kind='linear' else: kind='quadratic' new_m=scipy.interpolate.interp1d(t_org, this_measurement, axis=0, bounds_error=False, fill_value='extrapolate', kind=kind)(t_new) this_measurement = new_m new_start = time_range[0] new_end = time_range[1] #sample_rate=upsample_fs this_sample_rate = this_measurement.shape[0]/(duration.total_seconds()) logger.debug('Final length of slice: {} samples / {} seconds / {} channels'.format(this_measurement.shape[0], this_measurement.shape[0]/this_sample_rate, this_measurement.shape[1])) return new_start, list(channels), units, new_end, this_sample_rate, this_measurement
[docs] def get_slice_corrected(start_time: pd.Timestamp, duration: pd.Timedelta, quantity: str, file_info: xr.Dataset=None, **kwargs): """Return a site-transformed signal slice, loading from cache when possible. First checks whether a pre-processed ``.npz`` slice already exists on disk (under ``slice_root_path/<duration>-minutes/slices_<quantity>/``). If it does, the cached slice is returned directly. Otherwise, the raw slice is obtained via :func:`get_slice`, the site's ``transforms`` callback is applied, and the result is saved for future calls. Parameters ---------- start_time : pandas.Timestamp Berlin-aware start of the requested window. duration : pandas.Timedelta Length of the requested window. quantity : str Quantity tag (e.g. ``'accel'``, ``'strain_rosettes'``). file_info : xarray.Dataset or None, optional File-info dataset required when the cache is empty and a raw slice must be assembled. May be ``None`` if a cached slice exists. **kwargs Forwarded to the site's ``transforms`` callback. Returns ------- tuple or None ``(start_time, headers, units, end_time, sample_rate, measurement)`` or ``None`` when no data are available for the requested window. """ st = start_time slice_name = '{}-{:02d}-{:02d}_{:02d}-{:02d}_{}.npz'.format( st.year, st.month, st.day, st.hour, st.minute, quantity) minutes = int(duration.total_seconds()/60) slice_root = os.path.join(_active_site.slice_root_path, f'{minutes}-minutes', 'slices_{}'.format(quantity), '{}'.format(st.year), '{:02d}'.format(st.month)) slice_path = os.path.join(slice_root, slice_name) if os.path.exists(slice_path): logger.info('Loading corrected signal slice for {} at {}'.format(quantity, start_time)) try: in_dict = np.load(slice_path, allow_pickle=True) lstart_time = in_dict['start_time'].item() lheaders = list(in_dict['headers']) lunits = list(in_dict['units']) lend_time = in_dict['end_time'].item() lsample_rate = float(in_dict['sample_rate']) if not np.any([math.isclose(lsample_rate, fs, abs_tol=0.005) for fs in [1/60, 10, 20, 50, 100]]): logger.error('Sample Rate wrong! fs={}'.format(lsample_rate)) lmeasurement = in_dict['measurement'] ustrain=np.array(lunits)=='µstrain' lmeasurement[:,ustrain] *= 1e-6 delta_t = (lend_time-lstart_time).total_seconds() if not math.isclose(delta_t, duration.total_seconds(), abs_tol = 0.1): logger.warning('Duration {} does not match given {}'.format((lend_time - lstart_time), duration)) return lstart_time, lheaders, lunits, lend_time, lsample_rate, lmeasurement except Exception as e: logger.exception(e) os.remove(slice_path) if not os.path.exists(slice_path) and file_info is None: logger.warning(slice_path + ' does not exist, file_info needed for creation of new slice!') return None data_slice = get_slice(start_time, duration, quantity, file_info) logger.debug(f'Correcting slice for {quantity}: {start_time}') if data_slice is None: return None site = _active_site if site is not None: transform = site.transforms.get(quantity) if transform is not None: data_slice = transform( *data_slice, quantity=quantity, start_time_local=start_time, duration=duration, **kwargs, ) if data_slice is None: return None start_time, headers, units, end_time, sample_rate, measurement = data_slice out_dict = {'start_time':start_time, 'headers':headers, 'units':units, 'end_time':end_time, 'sample_rate':sample_rate, 'measurement':measurement } if not os.path.exists(slice_root): os.makedirs(slice_root) np.savez_compressed(slice_path, **out_dict) ustrain=np.array(units)=='µstrain' measurement[:,ustrain] *= 1e-6 return start_time, headers, units, end_time, sample_rate, measurement
[docs] def get_slice_preprocessed(start_time: pd.Timestamp, duration, quantity, file_info=None, **kwargs): ''' remove channels, detrend, filter bandpass and decimate ''' highpass = 0.1 lowpass = 5 target_fs = 10 data_slice = get_slice_corrected(start_time, duration, quantity, file_info, **kwargs) if data_slice is None: return None actual_start_time, headers, units, end_time, sample_rate, measurement = data_slice if np.isnan(measurement).any(): logger.warning("Measurement slice {}: {} contains nan. Skipping!".format(start_time, quantity)) return None nyq = sample_rate/2 dec_fact = round(sample_rate/target_fs) if 'strain' in quantity: site = _active_site keep = site.preproc_channels.get(quantity, []) if site is not None else [] channel_selector = np.array([h in keep for h in headers]) headers = [h for h in headers if h in keep] measurement = measurement[:, channel_selector] b, a = scipy.signal.butter(4, [highpass/nyq, lowpass/nyq], btype='band') ftype=scipy.signal.ltisys.dlti(b,a) meas_dec = scipy.signal.decimate(measurement, dec_fact, axis=0, ftype=ftype, zero_phase=True) measurement = meas_dec sample_rate = sample_rate/dec_fact return actual_start_time, headers, units, end_time, sample_rate, measurement
[docs] def get_modal_results(quantity: str, duration: pd.Timedelta, stats: xr.Dataset=None, create_new: bool=False, **kwargs): """Load or (re-)create the modal-results database for *quantity*. Parameters ---------- quantity : str Quantity tag (e.g. ``'accel'``, ``'strain_rosettes'``). duration : pandas.Timedelta Analysis window length used to locate the database file. stats : xarray.Dataset or None, optional Statistics dataset required when *create_new* is ``True``. create_new : bool, optional When ``True``, run :func:`create_modal_results` instead of reading from disk. Defaults to ``False``. **kwargs Forwarded to :func:`create_modal_results` when *create_new* is ``True``. Returns ------- xarray.Dataset Modal-results dataset with variables ``frequencies``, ``damping``, ``modeshapes``, ``MPC``, ``MPD``, ``num_modes``, ``model_orders``, and associated per-channel PSD statistics, indexed by ``(time, modes, channels)``. Raises ------ RuntimeError When *create_new* is ``True`` but *stats* is ``None``. """ site = _active_site minutes = int(duration.total_seconds()/60) ds_path = os.path.join(site.db_root_path, f'{minutes}-minutes/', 'modal_{}.nc'.format(quantity)) if not os.path.exists(ds_path): logger.warning(f'Path for modal does not exist {ds_path}.') if stats is None and create_new: raise RuntimeError('Statistics xarray has to be provided to run modal analysis.') if not create_new: stat_result = os.stat(ds_path, follow_symlinks=True) cache_key = f'{quantity}_modal' cached = _ds_cache.get(cache_key) if cached is not None and cached['mtime'] == stat_result.st_mtime: logger.info('Getting modal results for {} (cached)'.format(quantity)) ds = cached['ds'] else: logger.info('Getting modal results for {}'.format(quantity)) ds = xr.open_dataset(ds_path, engine='h5netcdf') ds.load() ds.close() _ds_cache[cache_key] = {'ds': ds, 'mtime': stat_result.st_mtime} else: ds = create_modal_results(quantity, duration, stats, **kwargs) return ds
[docs] def create_modal_results(quantity: str, duration: pd.Timedelta, stats: xr.Dataset, chunksize: int=2, skip_existing: bool=True, check_errors: bool=True, filter_errors: bool=True, **kwargs): """Run VarSSI-Ref modal analysis for every time step in *stats*. For each time step that is not already in the master database the function calls, in order: 1. :func:`get_slice_preprocessed` — bandpass-filter and decimate the raw slice. 2. :func:`describe_stats` — compute per-channel signal statistics. 3. :func:`modal_analysis_single` — run VarSSI-Ref, cluster the stabilization diagram, and auto-select physical poles. Results are accumulated in a per-process NetCDF file and then merged into the master database under :class:`MultiLock`. Parameters ---------- quantity : str Quantity tag (e.g. ``'accel'``, ``'strain_rosettes'``). duration : pandas.Timedelta Analysis window length. stats : xarray.Dataset Statistics dataset (from :func:`get_stats`) providing the list of time steps to process and error flags to filter on. chunksize : int, optional Number of windows to accumulate before flushing the per-process NetCDF file to disk. Defaults to 2. skip_existing : bool, optional When ``True`` (default), skip time steps already present in the master database. check_errors : bool, optional When ``True`` (default), call :func:`check_and_mark_errors` on *stats* before iterating. filter_errors : bool, optional When ``True`` (default), skip time steps that have any error flag. **kwargs Supported keys: ``dtstart``, ``until``, ``missing``, ``num_workers``, ``this_worker``. Returns ------- xarray.Dataset The updated master modal-results dataset. """ # for the analysis accel and strain_rosettes should be error free # error has to be extended by checking range and kurtosis (similar to plot_stats() # any error in the considered channels marks the respective slice as errorneous # stats/slices should already be aligned to the desired durations # all channels should be present in accels and strains -> No check on channels # preprocessing: get_slice_preprocessed() # describe: describe_stats() # analyze with < OMA_Method > and postprocess with AOMA (E. Neu, Aachen): modal_analysis_single() logger.info('Creating modal results for {}'.format(quantity)) # prepare modal analysis, config files, geometry, result_folder, etc. site = _active_site PreProcessSignals.load_measurement_file = dummy_load _conf_dir = os.path.join(site.modal_conf_dir, quantity) if check_errors: stats = check_and_mark_errors(stats) minutes = int(duration.total_seconds()/60) process_ds_path = os.path.join(site.db_root_path, f'{minutes}-minutes/', f'modal_{quantity}.{_pid}.nc') master_ds_path = os.path.join(site.db_root_path, f'{minutes}-minutes/', 'modal_{}.nc'.format(quantity)) if os.path.exists(process_ds_path): process_ds = xr.open_dataset(process_ds_path, engine='h5netcdf') process_ds.load() process_ds.close() else: process_ds = xr.Dataset() process_ds.to_netcdf(process_ds_path, engine='h5netcdf') process_ds.close() if os.path.exists(master_ds_path): master_ds = xr.open_dataset(master_ds_path, engine='h5netcdf') master_ds.load() master_ds.close() else: master_ds = xr.Dataset() # cases: # distributed processing -> master_ds inconsistent, changing when worker updates it # single processing -> master_ds consistent # case time_iterator master_ds # proc. of all -> use stats.time -> inconsistent # proc. [dtstart .. (until)] -> stats.time (filtered) -> inconsistent # proc. missing -> difference master_ds.time and stats_time - -> needs consistent # distributed_processing=True dtstart=kwargs.pop('dtstart', None) until = kwargs.pop('until', None) missing = kwargs.pop('missing', False) if dtstart is not None: time_iterator = stats.time[stats.time>=dtstart] if until is not None: time_iterator = time_iterator[time_iterator<=until] elif missing and 'time' in master_ds.dims: distributed_processing =False # drop nan from both ds # compute the set difference # do not dropna, because, then it will try all errorneous files again every time #master_ds = master_ds.dropna(dim='time', how='all') stats_ds = stats.dropna(dim='time', how='all') time_iterator = np.setdiff1d(stats_ds.time, master_ds.time) errorneous_timestamps = stats_ds.time[np.logical_or( stats_ds['error'].any(dim='channels'), stats_ds.length.isnull())] time_iterator = np.setdiff1d(time_iterator, errorneous_timestamps) else: time_iterator = stats.time.values num_workers = kwargs.get('num_workers',1) this_worker = kwargs.get('this_worker',1) jobsize=int(np.ceil(len(time_iterator)/num_workers)) start = int((this_worker-1)%num_workers) if not distributed_processing and num_workers>1: raise RuntimeError("You can't process 'only missing' data using multiple workers") logger.debug(jobsize) i=1 for start_time in time_iterator[start*jobsize:(start+1)*jobsize]: logger.debug('') start_time_naive = pd.Timestamp(start_time) start_time = TC.to_local(start_time) if pd.isnull(start_time): continue # if not (i+1)%50: print('.',end='', flush=True) # if not (i+1)%2500: print('\n') this_stats = stats.sel(time=start_time_naive) if this_stats['error'].any(): logger.warning('Error in slice {}: {}'.format(start_time, this_stats['error'].data)) continue if 'time' in master_ds.coords: if (start_time.to_datetime64()==master_ds.coords['time']).any(): if skip_existing: # other checks may be added here continue else: logger.info(f'{str(start_time)} already present in master dataset. Updating!') # other checks may be added here if 'time' in process_ds.coords: if (start_time.to_datetime64()==process_ds.coords['time']).any(): if skip_existing: # other checks may be added here continue else: logger.info(f'{str(start_time)} already present in process dataset. Updating!') process_ds = process_ds.where((start_time.to_datetime64()!=process_ds.coords['time']), drop=True) # other checks may be added here this_ds = xr.Dataset() if this_stats.length.isnull(): continue # duration=pd.Timedelta(seconds=(this_stats.length/this_stats.sample_rate).variable.data.item()) try: with open(os.devnull, 'w') as f, contextlib.redirect_stdout(f): data_slice = get_slice_preprocessed(start_time, duration, quantity, **kwargs) except Exception as e: logger.warning('Exception while trying to get preprocessed slice: ') logger.warning(e) data_slice=None if data_slice is None: this_ds.coords['time'] = [start_time.to_datetime64()] else: _actual_start_time, headers, _units, _end_time, sample_rate, measurement = data_slice this_ds['num_channels'] = (['time'], [measurement.shape[1]])#:len(headers), this_ds['sample_rate'] = (['time'], [sample_rate])#:sample_rate, this_ds['length']= (['time'], [measurement.shape[0]]) this_dict = describe_stats(measurement, headers, quantity) for var_name in ['mean','min','max','var','skewness','kurtosis','q05','q50','q95','rms','error']: try:# may not be present for every file this_ds[var_name] = (['time','channels'], [this_dict[var_name]]) except KeyError as e: logger.warning(e) continue except Exception: logger.warning('error') this_ds.coords['channels'] = np.array(headers,dtype=str) this_ds.coords['time'] = [start_time.to_datetime64()] try: #remove errorneous channels #if this_ds['error'].any(): # pass now=time.time() with open(os.devnull, "w") as f, contextlib.redirect_stdout(f): results = modal_analysis_single(start_time, data_slice, quantity, duration) n, f, std_f, d, std_d, MPC, _MP, MPD, _MC, msh, s_vals_psd = results logger.info('Modal Analysis took {:.2f} s to return {} modes for : {}'.format(time.time()-now, len(n), start_time)) this_dict = describe_stats(s_vals_psd.T) for var_name in ['mean','min','max','var','skewness','kurtosis','q05','q50','q95','rms']: try:# may not be present for every file this_ds[var_name+'_svd_psd'] = (['time','channels'], [this_dict[var_name]]) except Exception as e: logger.exception(e) energy_f = np.sum(np.power(np.abs(s_vals_psd),2),axis=-1)/s_vals_psd.shape[-1] this_ds['energy_svd_psd'] = (['time','channels'],[energy_f]) this_ds['num_modes'] = (['time'], [len(n)]) this_ds['model_orders'] = (['time','modes'], [n]) this_ds['frequencies'] = (['time','modes'], [f]) this_ds['std_frequencies'] = (['time','modes'], [std_f]) this_ds['damping'] = (['time','modes'], [d]) this_ds['std_damping'] = (['time','modes'], [std_d]) this_ds['MPC'] = (['time','modes'], [MPC]) this_ds['MPD'] = (['time','modes'], [MPD]) #this_ds['modal_contributions'] = (['time','modes'], [MC]) this_ds['modeshapes'] = (['time','modes','channels'], [msh]) # assignment to named modes will not be done here, as this # would require the rejection of a large amount of modes # and this in turn would bias any further analyzes # see function.... for further explanations this_ds.coords['modes'] = list(range(len(n))) except Exception as e: logger.exception('Error. Continuing') logger.exception(e) process_ds = process_ds.combine_first(this_ds) # logger.info('Succes: {}, Length: {}'.format((start_time.to_datetime64()==process_ds.coords['time']).any().item(), len(process_ds.time))) i+=1 if i>0 and not i%chunksize: # process_ds should not have changed on disk during processing process_ds = save_ds(process_ds, process_ds, process_ds_path, what='modal') # process_ds should not have changed on disk during processing process_ds = save_ds(process_ds, process_ds, process_ds_path, what='modal') # master_ds almost certainly has changed on disk if multiple workers were processing files master_ds = save_ds(process_ds, master_ds, master_ds_path, reload_current = True, what='modal') logger.debug(f'Removing temporary dataset at {process_ds_path}') os.remove(process_ds_path) return master_ds
def dummy_load(meas_file, **kwargs): headers = kwargs['headers'] units = kwargs['units'] start_time = kwargs['start_time'] sample_rate = kwargs['sample_rate'] measurement = kwargs['measurement'] return headers, units, start_time, sample_rate, measurement
[docs] def split_modepairs(modal): """Reorder raw poles into named mode-pair slots defined by the active site. For each frequency band in ``site.modal_bands``, the function: 1. Selects time steps where **exactly two** poles fall inside the band. 2. Assigns the lower-frequency pole to slot ``2*i`` and the higher-frequency pole to slot ``2*i+1`` (where *i* is the band index). 3. Merges all per-band results into a single dataset with a new ``modes`` coordinate of size ``2 * len(modal_bands)``. All other dataset variables (damping, modeshapes, MPC, etc.) are reordered to match the new mode assignment. Parameters ---------- modal : xarray.Dataset Raw modal-results dataset as returned by :func:`get_modal_results`, with a sparse ``modes`` dimension. Returns ------- xarray.Dataset Dataset with a dense ``modes`` coordinate of length ``2 * len(site.modal_bands)``, aligned across all time steps. """ site = _active_site modal_bands = site.modal_bands if site is not None else [] modal_sorted = xr.Dataset() for modepair, f_range in enumerate(modal_bands): new_modal = xr.Dataset() # filter frequency range frequencies = modal['frequencies'] f_ind = np.logical_and(frequencies>=f_range[0],frequencies<=f_range[1]) # filter "exactly two modes" f_ind = f_ind.where(f_ind.sum(dim='modes')==2, False) frequencies = frequencies.where(f_ind) for name,data in modal.variables.items(): if name in data.dims:continue if 'modes' in data.dims: #pass mode_ind_0 = frequencies==frequencies.min(dim='modes') data_new_0 = data.where(mode_ind_0).min(dim='modes').expand_dims(dim='modes').rename(name) data_new_0.coords['modes']=('modes',[modepair*2 + 0]) mode_ind_1 = frequencies==frequencies.max(dim='modes') data_new_1 = data.where(mode_ind_1).min(dim='modes').expand_dims(dim='modes').rename(name) data_new_1.coords['modes']=('modes',[modepair*2 + 1]) data_new = xr.merge([data_new_0,data_new_1]) else: data_new = xr.Dataset({name:data}) new_modal = xr.merge([new_modal, data_new]) new_modal.coords['channels'] = modal.coords['channels'] modal_sorted = xr.merge([modal_sorted, new_modal]) logger.debug(modal_sorted) return modal_sorted
def merge_xarrays(path, quantity, what='modal',delete=None): fpath = os.path.join(path,'result_db/{}_{}.nc'.format(what,quantity)) filelist = glob.glob(os.path.join(path,'result_db/{}_{}.*.nc'.format(what,quantity))) if not filelist: logger.warning('List of xarrays is empty. Returning.') return if os.path.exists(fpath): if delete is None: delete = input('Path {} exists. {} files can be merged. Do you want to delete (y/n) or also merge (u) file? '.format(fpath, len(filelist))) assert delete in ['y', 'u', 'n'] if delete =='y': os.remove(fpath) elif delete == 'u': new_path = fpath.strip('.nc')+'_old.nc' logger.debug('cp {} {}'.format(fpath, new_path)) if os.system('cp {} {}'.format(fpath, new_path)) == 0: filelist += [new_path] os.remove(fpath) else: return elif delete=='n': i=0 while True: new_path = fpath.strip('.nc')+'_old_{}.nc'.format(i) if os.path.exists(new_path): i+=1 else: os.rename(fpath,new_path) break del i else: return logger.debug(filelist) arraylist = [] for file in filelist: try: if what=='modal': ds = xr.open_dataset(file, engine='h5netcdf') elif what=='stats' or what=='file_info': ds = xr.open_dataset(file) ds.load() ds.close() logger.debug(ds.dims.get('time',0)) arraylist.append(ds) except: logger.exception(file) raise new_ds = xr.Dataset() for i,ds in enumerate(arraylist): now = time.time() try: new_ds = new_ds.combine_first(ds) except: logger.exception(filelist[i]) raise logger.debug('{} {} s'.format(i, time.time()-now)) ds.close() logger.info('Merged {} files.'.format(len(filelist))) load_ds = None if what=='modal': new_ds.to_netcdf(fpath, engine='h5netcdf') load_ds = xr.open_dataset(fpath, engine='h5netcdf').load() elif what=='stats' or what=='file_info': new_ds.to_netcdf(fpath, format='NETCDF4') load_ds = xr.open_dataset(fpath).load() logger.debug(load_ds) logger.info('Saved dataset to {}'.format(fpath)) #delete = input('Merged ds has {} entries, re-loaded dataset has {} entries. Do you want to delete all source files? (y/n)'.format(new_ds.dims.get('time',0),load_ds.dims.get('time',0))) if delete in ['y', 'u']: for file in filelist: os.rename(file, file+'.old') #os.remove(file) logger.info('Deleted {}'.format(file)) def main(): logger.setLevel(logging.INFO) # sys.argv = [..., this_worker, num_workers, duration_selector, q_selector] #sys.argv+=[10,20,1,3] if len(sys.argv)>2: num_workers = int(sys.argv[2]) if num_workers>1: time.sleep(np.random.random()*num_workers) this_worker = int(sys.argv[1]) else: num_workers=1 this_worker=1 if len(sys.argv)>3: duration_selector = int(sys.argv[3]) else: duration_selector = 3 duration = pd.Timedelta(minutes=[10,30,60,120][duration_selector]) minutes = int(duration.total_seconds()/60) site = _active_site _db_path = os.path.join(site.db_root_path, f'{minutes}-minutes/') if len(sys.argv) > 4: q_selector = int(sys.argv[4]) else: q_selector = 1 quantities = [ 'accel', #0 'wind', #1 'temp', #2 'strain_rosettes',#3 #'strain_bolts' ][q_selector:q_selector+1] for quantity in quantities: origin = site.origins[quantity] _subpath = site.subpaths[origin] logger.info('Quantity: {}, Duration: {}'.format(quantity, minutes)) if 0: file_contents = read_file(os.path.join(site.file_root_path, site.subpaths[origin], 'Wind_kontinuierlich__1_2018-06-13_15-00-00_000000.csv.bz2')) logger.debug(file_contents) meas = file_contents[-1] logger.debug(meas.shape) ts = meas[:, 0] logger.debug(ts[-1]-ts[0]) for i in range(meas.shape[0]): print(ts[i+1]-ts[i]) if (ts[i+1]-ts[i]) != 1: print(i) break return if 0: # create file_info = get_file_info(origin, create_new=True, skip_existing=True, reduced=False) return else: # get file_info = get_file_info(origin, create_new=False) if 0: data_slice = get_slice_corrected(pd.Timestamp('2018-02-27 20:00', tz='Europe/Berlin'), duration, quantity, file_info, file_info_temp=get_file_info(site.origins['temp'])) print(data_slice[:-1]) print(data_slice[-1].shape) print(np.mean(data_slice[-1], axis=0)) _actual_start_time, headers, _units, _end_time, _sample_rate, measurement = data_slice this_dict = describe_stats(measurement, headers, quantity) print(this_dict) return if 1: if 'strain' in quantity: file_info_temp = get_file_info(site.origins['temp']) stats = get_stats(quantity, duration,file_info, create_new=True, skip_existing=True, chunksize=500, file_info_temp=file_info_temp, num_workers=num_workers, this_worker=this_worker) else: stats = get_stats(quantity, duration, file_info, create_new=True, skip_existing=True, chunksize=500, num_workers=num_workers, this_worker=this_worker) return else: stats = get_stats(quantity, duration, ) if 0 and quantity in ['accel', 'strain_rosettes']: _modal = get_modal_results(quantity, duration, stats, skip_existing=True, create_new=True, filter_errors=False, chunksize=20, num_workers=num_workers, this_worker=this_worker) return return if __name__ == '__main__': main()