diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d9abb9f9ff6a5bada6ab6b69c16586eb2c1464fd..78bc10f9eebfb2a6ea73c9214f6e1aaa81ba9e0b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,6 +35,8 @@ default: - pip install --upgrade pip - pip install -r requirements.txt - pip install -r tests/requirements.txt + - apt update + - apt install -y xvfb # =========================================================== # Compliance stage @@ -75,6 +77,8 @@ coverage: stage: test allow_failure: true script: + - export DISPLAY=:99 + - Xvfb :99 & - pip install pytest-cov coverage - pytest --cov=saqc tests --ignore=tests/fuzzy -Werror after_script: @@ -93,6 +97,8 @@ python39: stage: test image: python:3.9 script: + - export DISPLAY=:99 + - Xvfb :99 & - pytest tests -Werror --junitxml=report.xml - python -m saqc --config docs/resources/data/config.csv --data docs/resources/data/data.csv --outfile /tmp/test.csv artifacts: @@ -105,6 +111,8 @@ python310: stage: test image: python:3.10 script: + - export DISPLAY=:99 + - Xvfb :99 & - pytest tests -Werror --junitxml=report.xml - python -m saqc --config docs/resources/data/config.csv --data docs/resources/data/data.csv --outfile /tmp/test.csv artifacts: @@ -116,6 +124,8 @@ python311: stage: test image: python:3.11 script: + - export DISPLAY=:99 + - Xvfb :99 & - pytest tests -Werror --junitxml=report.xml - python -m saqc --config docs/resources/data/config.csv --data docs/resources/data/data.csv --outfile /tmp/test.csv artifacts: diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc336e9f6f83dfab4373d3d703604f079b03f8a..0f09eccdf09c944bd13dc1bd003de1e65ecd6933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,23 @@ SPDX-License-Identifier: GPL-3.0-or-later - `flagGeneric`: target broadcasting - `SaQC`: automatic translation of incoming flags - Option to change the flagging scheme after initialization +- `flagByClick`: manually assign flags using a graphical user interface - `SaQC`: support for selection, slicing and setting of items by use of subscription on SaQC objects (e.g. `qc[key]` and `qc[key] = value`). Selection works with single keys, collections of keys and string slices (e.g. `qc["a":"f"]`). Values can be SaQC objects, pd.Series, Iterable of Series and dict-like with series values. -- `transferFlags` becomes a multivariate function +- `transferFlags` is a multivariate function +- `plot`: added `yscope` keyword +- `setFlags`: function to replace `flagManual` +- `flagUniLOF`: added defaultly applied correction to mitigate phenomenon of overflagging at relatively steep data value slopes. (parameter `slope_correct`). ### Changed ### Removed ### Fixed +- `flagConstants`: fixed flagging of rolling ramps - `Flags`: add meta entry to imported flags - group operations were overwriting existing flags - `SaQC._construct` : was not working for inherit classes (used hardcoded `SaQC` to construct a new instance). ### Deprecated +- `flagManual` in favor of `setFlags` ## [2.5.0](https://git.ufz.de/rdm-software/saqc/-/tags/v2.4.1) - 2023-06-22 [List of commits](https://git.ufz.de/rdm-software/saqc/-/compare/v2.4.1...v2.5.0) diff --git a/docs/requirements.txt b/docs/requirements.txt index 2aeaac87678dc41f9dc1d8b1eb919ec92a0300a3..8c277e95aa5b807e6b859d04b6c5b40dd86dd4b1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,11 +4,11 @@ recommonmark==0.7.1 sphinx==7.2.6 -sphinx-automodapi==0.16.0 +sphinx-automodapi==0.17.0 sphinxcontrib-fulltoc==1.2.0 sphinx-markdown-tables==0.0.17 jupyter-sphinx==0.5.3 -sphinx_autodoc_typehints==1.25.2 -sphinx-tabs==3.4.4 +sphinx_autodoc_typehints==2.0.0 +sphinx-tabs==3.4.5 sphinx-design==0.5.0 -pydata-sphinx-theme==0.14.4 +pydata-sphinx-theme==0.15.2 diff --git a/requirements.txt b/requirements.txt index 4d8b456b9988f3e05b7885dea4a0e5d052f261d0..c9553ab49916a6ba411ae6533e51b90a48285995 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,12 +5,12 @@ Click==8.1.7 docstring_parser==0.15 dtw==1.4.0 -matplotlib==3.8.2 -numpy==1.26.2 +matplotlib==3.8.3 +numpy==1.26.4 outlier-utils==0.0.5 -pyarrow==14.0.2 +pyarrow==15.0.0 pandas==2.1.4 -scikit-learn==1.3.2 -scipy==1.11.4 +scikit-learn==1.4.1.post1 +scipy==1.12.0 typing_extensions==4.5.0 fancy-collections==0.2.1 diff --git a/saqc/core/core.py b/saqc/core/core.py index 916bcf3e48bcb984fe6cba28b5270297130bef33..43448cd8ef05f2c8817f28c862750f2417715431 100644 --- a/saqc/core/core.py +++ b/saqc/core/core.py @@ -12,7 +12,7 @@ import warnings from copy import copy as shallowcopy from copy import deepcopy from functools import partial -from typing import Any, Hashable, Iterable, MutableMapping, overload +from typing import Any, Hashable, Iterable, MutableMapping import numpy as np import pandas as pd @@ -32,7 +32,7 @@ from saqc.funcs import FunctionsMixin # warnings pd.set_option("mode.chained_assignment", "warn") -pd.options.mode.copy_on_write = False +pd.set_option("mode.copy_on_write", True) np.seterr(invalid="ignore") diff --git a/saqc/funcs/constants.py b/saqc/funcs/constants.py index a3af13d614af1f036cf2571a29fdc2cd27692052..3e4afba3a4b125f2dcf68c94a4cd0cdb3bff098b 100644 --- a/saqc/funcs/constants.py +++ b/saqc/funcs/constants.py @@ -51,18 +51,19 @@ class ConstantsMixin: thresh : Maximum total change allowed per window. + window : + Size of the moving window. This determines the number of observations used + for calculating the absolute change per window. + Each window will either contain a fixed number of periods (integer defined window), + or will have a fixed temporal extension (offset defined window). + min_periods : Minimum number of observations in window required to generate - a flag. Must be an integer greater or equal `2`, because a + a flag. This can be used to exclude underpopulated *offset* defined windows from + flagging. (Integer defined windows will always contain exactly *window* samples). + Must be an integer greater or equal `2`, because a single value would always be considered constant. Defaults to `2`. - - window : - Size of the moving window. This is the number of observations used - for calculating the statistic. Each window will be a fixed size. - If it is an offset then this will be the time period of each window. - Each window will be a variable sized based on the observations included - in the time-period. """ d: pd.Series = self._data[field] validateWindow(window, index=d.index) @@ -73,7 +74,7 @@ class ConstantsMixin: rolling = d.rolling(window=window, min_periods=min_periods) starting_points_mask = rolling.max() - rolling.min() <= thresh - removeRollingRamps(starting_points_mask, window=window, inplace=True) + starting_points_mask = removeRollingRamps(starting_points_mask, window=window) # mimic forward rolling by roll over inverse [::-1] rolling = starting_points_mask[::-1].rolling( diff --git a/saqc/funcs/flagtools.py b/saqc/funcs/flagtools.py index 3167097c3addf74b1dd8492c5458912484a19c8c..029665a3f624beea6614fb2f577d4e8d1046175a 100644 --- a/saqc/funcs/flagtools.py +++ b/saqc/funcs/flagtools.py @@ -97,6 +97,64 @@ class FlagtoolsMixin: self._flags[unflagged, field] = flag return self + @flagging() + def setFlags( + self, + field: str, + data: str | list | np.array | pd.Series, + override: bool = False, + flag: float = BAD, + **kwargs, + ) -> "SaQC": + """ + Include flags listed in external data. + + Parameters + ---------- + + data : + Determines which timestamps to set flags at, depending on the passed type: + + * 1-d `array` or `List` of timestamps or `pandas.Index`: flag `field` with `flag` at every timestamp in `f_data` + * 2-d `array` or List of tuples: for all elements `t[k]` out of f_data: + flag `field` with `flag` at every timestamp in between `t[k][0]` and `t[k][1]` + * pd.Series: flag `field` with `flag` in between any index and data value of the passed series + * str: use the variable timeseries `f_data` as flagging template + * pd.Series: flag `field` with `flag` in between any index and data value of the passed series + * 1-d `array` or `List` of timestamps: flag `field` with `flag` at every timestamp in `f_data` + * 2-d `array` or List of tuples: for all elements `t[k]` out of f_data: + flag `field` with `flag` at every timestamp in between `t[k][0]` and `t[k][1]` + override : + determines if flags shall be assigned although the value in question already has a flag assigned. + """ + to_flag = pd.Series(False, index=self._data[field].index) + + # check if f_data is meant to denote timestamps: + if (isinstance(data, (list, np.ndarray, pd.Index))) and not isinstance( + data[0], (tuple, np.ndarray) + ): + set_idx = pd.DatetimeIndex(data).intersection(to_flag.index) + to_flag[set_idx] = True + else: # f_data denotes intervals: + if isinstance(data, (str, pd.Series)): + if isinstance(data, str): + flags_data = self._data[data] + else: + flags_data = data + intervals = flags_data.items() + else: + intervals = data + for s in intervals: + to_flag[s[0] : s[1]] = True + + # elif isinstance(f_data, list): + if not override: + to_flag &= (self._flags[field] < flag) & ( + self._flags[field] >= kwargs["dfilter"] + ) + self._flags[to_flag.values, field] = flag + return self + @register(mask=["field"], demask=["field"], squeeze=["field"]) def flagManual( self: "SaQC", @@ -115,6 +173,9 @@ class FlagtoolsMixin: The method allows to integrate pre-existing flagging information. + .. deprecated:: 2.6.0 + Deprecated Function. See :py:meth:`~saqc.SaQC.setFlags`. + Parameters ---------- mdata : @@ -222,6 +283,11 @@ class FlagtoolsMixin: 2000-05-01 True dtype: bool """ + warnings.warn( + "`flagManual` is deprecated and will be removed in version 2.9 of saqc. " + "Please use `setFlags` for similar functionality.", + DeprecationWarning, + ) validateChoice( method, "method", ["left-open", "right-open", "closed", "plain", "ontime"] ) diff --git a/saqc/funcs/outliers.py b/saqc/funcs/outliers.py index 7b5a4b93094c53b856963af5ee3c57e0910b08ae..dfb1a84ef8a51ddba8a2f7a43b31ec60e8549499 100644 --- a/saqc/funcs/outliers.py +++ b/saqc/funcs/outliers.py @@ -179,6 +179,8 @@ class OutliersMixin: p: int = 1, density: Literal["auto"] | float = "auto", fill_na: bool = True, + slope_correct: bool = True, + min_offset: float = None, flag: float = BAD, **kwargs, ) -> "SaQC": @@ -247,6 +249,15 @@ class OutliersMixin: fill_na : If True, NaNs in the data are filled with a linear interpolation. + slope_correct : + if True, a correction is applied, that removes outlier cluster that actually + just seem to be steep slopes + + min_offset : + If set, only those outlier cluster will be flagged, that are preceeded and succeeeded + by sufficiently large value "jumps". Defaults to estimating the sufficient value jumps from + the median over the absolute step sizes between data points. + See Also -------- :ref:`introduction to outlier detection with @@ -366,8 +377,47 @@ class OutliersMixin: s_mask = ((_s - _s.mean()) / _s.std()).iloc[: int(s.shape[0])].abs() > 3 else: s_mask = s < -abs(thresh) - s_mask = ~isflagged(qc._flags[field], kwargs["dfilter"]) & s_mask + + if slope_correct: + g_mask = s_mask.diff() + g_mask = g_mask.cumsum() + dat = self._data[field] + od_groups = dat.interpolate("linear").groupby(by=g_mask) + first_vals = od_groups.first() + last_vals = od_groups.last() + max_vals = od_groups.max() + min_vals = od_groups.min() + if min_offset is None: + if density == "auto": + d_diff = dat.diff() + eps = d_diff.abs().median() + if eps == 0: + eps = d_diff[d_diff != 0].abs().median() + else: + eps = density + eps = 3 * eps + else: + eps = min_offset + up_slopes = (min_vals + eps >= last_vals.shift(1)) & ( + max_vals - eps <= first_vals.shift(-1) + ) + down_slopes = (max_vals - eps <= last_vals.shift(1)) & ( + min_vals + eps >= first_vals.shift(-1) + ) + slopes = up_slopes | down_slopes + odd_return_pred = (max_vals > last_vals.shift(1)) & ( + min_vals < last_vals.shift(1) + ) + odd_return_succ = (max_vals > first_vals.shift(-1)) & ( + min_vals < first_vals.shift(-1) + ) + returns = odd_return_succ | odd_return_pred + corrections = returns | slopes + for s_id in corrections[corrections].index: + correct_idx = od_groups.get_group(s_id).index + s_mask[correct_idx] = False + qc._flags[s_mask, field] = flag qc = qc.dropField(tmp_field) return qc diff --git a/saqc/funcs/scores.py b/saqc/funcs/scores.py index 18f4c6f62abc8d6426d65c6cd99af3e212b728f8..9998c153477390add9805a11f1eb2c27c21ca09d 100644 --- a/saqc/funcs/scores.py +++ b/saqc/funcs/scores.py @@ -503,9 +503,10 @@ class ScoresMixin: filled = pd.Series(False, index=vals.index) if density == "auto": - density = vals.diff().abs().median() + v_diff = vals.diff() + density = v_diff.abs().median() if density == 0: - density = vals.diff().abs().mean() + density = v_diff[v_diff != 0].abs().median() elif isinstance(density, Callable): density = density(vals) if isinstance(density, pd.Series): diff --git a/saqc/funcs/tools.py b/saqc/funcs/tools.py index 7da335d548321df1dd4d9fa0fb0fab91843c414c..e0f53fabbc32a8b6a89c2d0af1a68267f1dfc565 100644 --- a/saqc/funcs/tools.py +++ b/saqc/funcs/tools.py @@ -8,29 +8,154 @@ from __future__ import annotations import pickle +import tkinter as tk import warnings from typing import TYPE_CHECKING, Optional import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np +import pandas as pd from typing_extensions import Literal -from saqc import FILTER_NONE, UNFLAGGED +from saqc import BAD, FILTER_NONE, UNFLAGGED from saqc.core import processing, register from saqc.lib.checking import validateChoice from saqc.lib.docs import DOC_TEMPLATES from saqc.lib.plotting import makeFig -from saqc.lib.tools import periodicMask +from saqc.lib.selectionGUI import MplScroller, SelectionOverlay +from saqc.lib.tools import periodicMask, toSequence if TYPE_CHECKING: from saqc import SaQC _MPL_DEFAULT_BACKEND = mpl.get_backend() +_TEST_MODE = False class ToolsMixin: + @register(mask=[], demask=[], squeeze=[], multivariate=True) + def flagByClick( + self: "SaQC", + field: str | list[str], + max_gap: str | None = None, + gui_mode: Literal["GUI", "overlay"] = "GUI", + selection_marker_kwargs: dict | None = None, + dfilter: float = BAD, + **kwargs, + ) -> "SaQC": + """ + Pop up GUI for adding or removing flags by selection of points in the data plot. + + * Left click and Drag the selection area over the points you want to add to selection. + + * Right clack and drag the selection area over the points you want to remove from selection + + * press 'shift' to switch between rectangle and span selector + + * press 'enter' or click "Assign Flags" to assign flags to the selected points and end session + + * press 'escape' or click "Discard" to end Session without assigneing flags to selection + + * activate the sliders attached to each axes to bind the respective variable. When using the + span selector, points from all bound variables will be added synchronously. + + + Note, that you can only mark already flagged values, if `dfilter` is set accordingly. + + Note, that you can use `flagByClick` to "unflag" already flagged values, when setting `dfilter` above the flag to + "unset", and setting `flag` to a flagging level associated with your "unflagged" level. + + Parameters + ---------- + max_gap : + If ``None``, all data points will be connected, resulting in long linear + lines, in case of large data gaps. ``NaN`` values will be removed before + plotting. If an offset string is passed, only points that have a distance + below ``max_gap`` are connected via the plotting line. + gui_mode : + * ``"GUI"`` (default), spawns TK based pop-up GUI, enabling scrolling and binding for subplots + * ``"overlay"``, spawns matplotlib based pop-up GUI. May be less conflicting, but does not support + scrolling or binding. + """ + data, flags = self._data.copy(), self._flags.copy() + + flag = kwargs.get("flag", BAD) + scrollbar = True if gui_mode == "GUI" else False + selection_marker_kwargs = selection_marker_kwargs or {} + + if not scrollbar: + plt.rcParams["toolbar"] = "toolmanager" + + if not _TEST_MODE: + plt.close("all") + mpl.use(_MPL_DEFAULT_BACKEND) + else: + mpl.use("Agg") + + # make base figure, the gui will wrap + fig = makeFig( + data=data, + field=field, + flags=flags, + level=UNFLAGGED, + mode="subplots", + max_gap=max_gap, + history="valid", + xscope=None, + ax_kwargs={"ncols": 1}, + scatter_kwargs={}, + plot_kwargs={}, + ) + + overlay_data = [] + for f in field: + overlay_data.extend([(data[f][flags[f] < dfilter]).dropna()]) + + if scrollbar: # spawn TK based GUI + root = tk.Tk() + scroller = MplScroller(root, fig=fig) + root.protocol("WM_DELETE_WINDOW", scroller.assignAndQuitFunc()) + scroller.pack(side="top", fill="both", expand=True) + + else: # only use figure window overlay + scroller = None + + selector = SelectionOverlay( + fig.axes, + data=overlay_data, + selection_marker_kwargs=selection_marker_kwargs, + parent=scroller, + ) + if _TEST_MODE & scrollbar: + root.after(2000, root.destroy) + # return self + + if scrollbar: + root.attributes("-fullscreen", True) + root.mainloop() + if not _TEST_MODE: + root.destroy() + else: # show figure if only overlay is used + plt.show(block=~_TEST_MODE) + plt.rcParams["toolbar"] = "toolbar2" + + # disconnect mouse events when GUI is closed + selector.disconnect() + + # assign flags only if selection was confirmed by user + if selector.confirmed: + for k in range(selector.N): + to_flag = selector.index[k][selector.marked[k]] + + new_col = pd.Series(np.nan, index=self._flags[field[k]].index) + new_col.loc[to_flag] = flag + self._flags.history[field[k]].append( + new_col, {"func": "flagByClick", "args": (), "kwargs": kwargs} + ) + return self + @register( mask=[], demask=[], @@ -211,7 +336,8 @@ class ToolsMixin: max_gap: str | None = None, mode: Literal["subplots", "oneplot"] | str = "oneplot", history: Literal["valid", "complete"] | list[str] | None = "valid", - xscope: slice | None = None, + xscope: slice | str | None = None, + yscope: tuple | list[tuple] | dict = None, store_kwargs: dict | None = None, ax: mpl.axes.Axes | None = None, ax_kwargs: dict | None = None, @@ -266,6 +392,11 @@ class ToolsMixin: Determine a chunk of the data to be plotted. ``xscope`` can be anything, that is a valid argument to the ``pandas.Series.__getitem__`` method. + yscope : + Either a tuple of 2 scalars that determines all plots' y-view limits, or a list of those + tuples, determining the different variables y-view limits (must match number of variables) + or a dictionary with variables as keys and the y-view tuple as values. + ax : If not ``None``, plot into the given ``matplotlib.Axes`` instance, instead of a newly created ``matplotlib.Figure``. This option offers a possibility to integrate @@ -366,9 +497,20 @@ class ToolsMixin: marker_kwargs = marker_kwargs or {} plot_kwargs = plot_kwargs or {} + if ( + (yscope is not None) + and (len(yscope) == 2) + and not isinstance(yscope[0], (list, tuple)) + ): + yscope = tuple(yscope) + if yscope is not None: + + ax_kwargs.update({"ylim": yscope}) + if not path: mpl.use(_MPL_DEFAULT_BACKEND) else: + plt.close("all") # supress matplotlib deprecation warning mpl.use("Agg") fig = makeFig( diff --git a/saqc/lib/plotting.py b/saqc/lib/plotting.py index 72a6cbce11a0680f8b97dee0cace66fbfcb76228..5dd0e944b93d93e1e0b15ddd723bb88d0b4fd5e3 100644 --- a/saqc/lib/plotting.py +++ b/saqc/lib/plotting.py @@ -250,7 +250,7 @@ def makeFig( mode, ) - # readability formattin fo the x-tick labels: + # readability formattin for the x-tick labels: fig.autofmt_xdate() return fig @@ -278,7 +278,7 @@ def _instantiateAxesContext( next(_scatter_kwargs["marker"]) # assign variable specific labels/titles - for axis_spec in ["xlabel", "ylabel", "title"]: + for axis_spec in ["xlabel", "ylabel", "title", "ylim"]: spec = _ax_kwargs.get(axis_spec, None) if isinstance(spec, list): _ax_kwargs[axis_spec] = spec[var_num] diff --git a/saqc/lib/selectionGUI.py b/saqc/lib/selectionGUI.py new file mode 100644 index 0000000000000000000000000000000000000000..6001a3f43ac82b0e64a4a1b96e87e8c8057d95f6 --- /dev/null +++ b/saqc/lib/selectionGUI.py @@ -0,0 +1,365 @@ +#! /usr/bin/env python + +# SPDX-FileCopyrightText: 2021 Helmholtz-Zentrum für Umweltforschung GmbH - UFZ +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# -*- coding: utf-8 -*- + +import tkinter as tk + +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.backend_tools import ToolBase +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +from matplotlib.dates import date2num +from matplotlib.widgets import RectangleSelector, Slider, SpanSelector + +ASSIGN_SHORTCUT = "enter" +DISCARD_SHORTCUT = "escape" +LEFT_MOUSE_BUTTON = 1 +RIGHT_MOUSE_BUTTON = 3 +SELECTION_MARKER_DEFAULT = {"zorder": 10, "c": "red", "s": 50, "marker": "x"} +# if scrollable GUI: determines number of figures per x-size of the screen +FIGS_PER_SCREEN = 2 +# or hight in inches (if given overrides number of figs per screen): +FIG_HIGHT_INCH = None +SLIDER_WIDTH_INCH, SLIDER_HIGHT_INCH = 0.3, 0.2 +BFONT = ("Times", "16") +VARFONT = ("Times", "12") +CP_WIDTH = 15 +SELECTOR_DICT = {"rect": "Rectangular", "span": "Span"} + + +class MplScroller(tk.Frame): + def __init__(self, parent, fig): + tk.Frame.__init__(self, parent) + # frame - canvas - window combo that enables scrolling: + self.canvas = tk.Canvas(self, borderwidth=0, background="#ffffff") + self.canvas.bind_all("<Button-4>", lambda x: self.mouseWheeler(-1)) + self.canvas.bind_all("<Button-5>", lambda x: self.mouseWheeler(1)) + + self.frame = tk.Frame(self.canvas, background="#ffffff") + self.vert_scrollbar = tk.Scrollbar( + self, orient="vertical", command=self.canvas.yview + ) + self.canvas.configure(yscrollcommand=self.vert_scrollbar.set) + + self.vert_scrollbar.pack(side="right", fill="y") + self.canvas.pack(side="left", fill="both", expand=True) + self.canvas.create_window( + (4, 4), window=self.frame, anchor="nw", tags="self.frame" + ) + + self.frame.bind("<Configure>", self.scrollAreaCallBack) + + # keeping references + self.parent = parent + self.fig = fig + + self.control_panel = tk.Frame(self.canvas, bg="DarkGray") + self.control_panel.pack(side=tk.LEFT, anchor="n") + + # adding buttons + self.quit_button = tk.Button( + self.control_panel, + text="Discard and Quit.", + command=self.assignAndQuitFunc, + bg="red", + width=CP_WIDTH, + relief="flat", + overrelief="groove", + ) + self.quit_button.grid(column=3, row=0, pady=5.5, padx=2.25) + + # selector info display + self.current_slc_entry = tk.StringVar(self.control_panel) + + tk.Label( + self.control_panel, + textvariable=self.current_slc_entry, + width=CP_WIDTH, + # font=BFONT, + ).grid(column=1, row=0, pady=5.5, padx=2.25) + + # adjusting content to the scrollable view + self.figureSizer() + self.figureShifter() + + # add scrollable content + self.scrollContentGenerator() + + # add sliders that enable binding variables + self.binding_sliders = [None] * len(self.fig.axes) + self.binding_status = [False] * len(self.fig.axes) + self.makeSlider() + + def mouseWheeler(self, direction): + self.canvas.yview_scroll(direction, "units") + + def assignationGenerator(self, selector): + tk.Button( + self.control_panel, + text="Assign Flags", + command=lambda s=selector: self.assignAndQuitFunc(s), + bg="green", + width=CP_WIDTH, + ).grid(column=0, row=0, pady=5.5, padx=2.25) + + def assignAndQuitFunc(self, selector=None): + if selector: + selector.confirmed = True + plt.close(self.fig) + self.quit() + + def scrollContentGenerator(self): + canvas = FigureCanvasTkAgg(self.fig, master=self.frame) + toolbar = NavigationToolbar2Tk(canvas, self.canvas) + toolbar.update() + toolbar.pack(side=tk.TOP) + canvas.get_tk_widget().pack() + canvas.draw() + + def scrollAreaCallBack(self, event): + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def figureSizer(self): + manager = plt.get_current_fig_manager() + if hasattr(manager, "window"): + window = plt.get_current_fig_manager().window + f_size = list(window.wm_maxsize()) + else: # for testing mode + f_size = [1, 1] + px = 1 / plt.rcParams["figure.dpi"] + f_size = [ws * px for ws in f_size] + if not FIG_HIGHT_INCH: + f_size[1] = f_size[1] * len(self.fig.axes) * FIGS_PER_SCREEN**-1 + else: + f_size[1] = FIG_HIGHT_INCH * len(self.fig.axes) + self.fig.set_size_inches(f_size[0], f_size[1]) + + def figureShifter(self): + manager = plt.get_current_fig_manager() + if hasattr(manager, "window"): + window = manager.window + screen_hight = window.wm_maxsize()[1] + else: # for testing mode + screen_hight = 10 + fig_hight = self.fig.get_size_inches() + ratio = fig_hight[1] / screen_hight + to_shift = ratio + for k in range(len(self.fig.axes)): + b = self.fig.axes[k].get_position().bounds + self.fig.axes[k].set_position((b[0], b[1] + to_shift, b[2], b[3])) + + def makeSlider(self): + fig_sz = self.fig.get_size_inches() + slider_width, slider_hight = ( + SLIDER_WIDTH_INCH / fig_sz[0], + SLIDER_HIGHT_INCH / fig_sz[1], + ) + for ax in enumerate(self.fig.axes): + b0 = ax[1].get_position().get_points() + b0_ax = plt.axes([b0[0, 0], b0[1, 1], slider_width, slider_hight]) + self.binding_sliders[ax[0]] = Slider(b0_ax, "", 0, 1, valinit=0, valstep=1) + self.binding_sliders[ax[0]].valtext.set_visible(False) + self.binding_sliders[ax[0]].on_changed( + lambda val, ax_num=ax[0]: self.bindFunc(val, ax_num) + ) + + def bindFunc(self, val, ax_num): + self.binding_status[ax_num] = bool(val) + + +class AssignFlagsTool(ToolBase): + description = "Assign flags to selection." + + def __init__(self, *args, callback, **kwargs): + super().__init__(*args, **kwargs) + self.callback = callback + + def trigger(self, *args, **kwargs): + self.callback() + + +class SelectionOverlay: + def __init__( + self, ax, data, selection_marker_kwargs=SELECTION_MARKER_DEFAULT, parent=None + ): + self.parent = parent + self.N = len(data) + self.ax = ax + self.marker_handles = self.N * [None] + for k in range(self.N): + self.ax[k].set_xlim(auto=True) + + self.canvas = self.ax[0].figure.canvas + self.selection_marker_kwargs = { + **SELECTION_MARKER_DEFAULT, + **selection_marker_kwargs, + } + self.rc_rect = None + self.lc_rect = None + self.current_slc = "rect" + self.spawn_selector(type=self.current_slc) + self.marked = [np.zeros(data[k].shape[0]).astype(bool) for k in range(self.N)] + self.confirmed = False + self.index = [data[k].index for k in range(self.N)] + self.data = [data[k].values for k in range(self.N)] + self.numidx = [date2num(self.index[k]) for k in range(self.N)] + if (not parent) and (not (matplotlib.get_backend() == "agg")): + # add assignment button to the toolbar + self.canvas.manager.toolmanager.add_tool( + "Assign Flags", AssignFlagsTool, callback=self.assignAndCloseCB + ) + self.canvas.manager.toolbar.add_tool("Assign Flags", "Flags") + self.canvas.manager.toolmanager.remove_tool("help") + elif parent: + parent.assignationGenerator(self) + + self.canvas.mpl_connect("key_press_event", self.keyPressEvents) + self.canvas.draw_idle() + + def onLeftSelectFunc(self, ax_num): + return lambda x, y, z=ax_num: self.onLeftSelect(x, y, z) + + def onRightSelectFunc(self, ax_num): + return lambda x, y, z=ax_num: self.onRightSelect(x, y, z) + + def onLeftSelect(self, eclick, erelease, ax_num=0, _select_to=True): + ax_num = np.array([ax_num]) + s_mask = {} + if (self.current_slc == "span") and (self.parent is not None): + stati = np.array(self.parent.binding_status) + if stati.any(): + stati_w = np.where(stati)[0] + if ax_num[0] in stati_w: + ax_num = stati_w + + if self.current_slc == "rect": + upper_left = ( + min(eclick.xdata, erelease.xdata), + max(eclick.ydata, erelease.ydata), + ) + + bottom_right = ( + max(eclick.xdata, erelease.xdata), + min(eclick.ydata, erelease.ydata), + ) + x_cut = (self.numidx[ax_num[0]] > upper_left[0]) & ( + self.numidx[ax_num[0]] < bottom_right[0] + ) + y_cut = (self.data[ax_num[0]] > bottom_right[1]) & ( + self.data[ax_num[0]] < upper_left[1] + ) + s_mask.update({ax_num[0]: x_cut & y_cut}) + + if self.current_slc == "span": + for a in ax_num: + x_cut = (self.numidx[a] > eclick) & (self.numidx[a] < erelease) + s_mask.update({a: x_cut}) + + for num in ax_num: + self.marked[num][s_mask[num]] = _select_to + xl = self.ax[num].get_xlim() + yl = self.ax[num].get_ylim() + marker_artist = self.ax[num].scatter( + self.index[num][self.marked[num]], + self.data[num][self.marked[num]], + **self.selection_marker_kwargs, + ) + + if self.marker_handles[num] is not None: + self.marker_handles[num].remove() + self.marker_handles[num] = marker_artist + self.ax[num].set_xlim(xl) + self.ax[num].set_ylim(yl) + + self.canvas.draw_idle() + + def onRightSelect(self, eclick, erelease, ax_num=0): + self.onLeftSelect(eclick, erelease, ax_num=ax_num, _select_to=False) + + def disconnect(self): + for k in range(self.N): + self.lc_rect[k].disconnect_events() + self.rc_rect[k].disconnect_events() + + def spawn_selector(self, type="rect"): + if self.rc_rect: + for k in range(self.N): + self.rc_rect[k].disconnect_events() + self.lc_rect[k].disconnect_events() + if type == "rect": + self.lc_rect = [ + RectangleSelector( + self.ax[k], + self.onLeftSelectFunc(k), + button=[1], + use_data_coordinates=True, + useblit=True, + ) + for k in range(self.N) + ] + self.rc_rect = [ + RectangleSelector( + self.ax[k], + self.onRightSelectFunc(k), + button=[3], + use_data_coordinates=True, + useblit=True, + ) + for k in range(self.N) + ] + elif type == "span": + self.lc_rect = [ + SpanSelector( + self.ax[k], + self.onLeftSelectFunc(k), + "horizontal", + button=[1], + useblit=True, + ) + for k in range(self.N) + ] + self.rc_rect = [ + SpanSelector( + self.ax[k], + self.onRightSelectFunc(k), + "horizontal", + button=[3], + useblit=True, + ) + for k in range(self.N) + ] + if self.parent: + self.parent.current_slc_entry.set(SELECTOR_DICT[self.current_slc]) + + def assignAndCloseCB(self, val=None): + self.confirmed = True + plt.close(self.ax[0].figure) + + def discardAndCloseCB(self, val=None): + plt.close(self.ax[0].figure) + + def keyPressEvents(self, event): + if event.key == ASSIGN_SHORTCUT: + if self.parent is None: + self.assignAndCloseCB() + else: + self.parent.assignAndQuitFunc(self) + if event.key == DISCARD_SHORTCUT: + if self.parent is None: + self.discardAndCloseCB() + else: + self.parent.assignAndQuitFunc(None) + + elif event.key == "shift": + if self.current_slc == "rect": + self.current_slc = "span" + self.spawn_selector("span") + + elif self.current_slc == "span": + self.current_slc = "rect" + self.spawn_selector("rect") diff --git a/tests/core/test_flags.py b/tests/core/test_flags.py index cc8949bf620cdabb4da6aa5ef72f9552a35d379c..c9628d49cd7f333e9fc7527dd05db48ddaf0cffc 100644 --- a/tests/core/test_flags.py +++ b/tests/core/test_flags.py @@ -122,7 +122,7 @@ def test_copy(data: Union[pd.DataFrame, DictOfSeries, Dict[str, pd.Series]]): # the underling series data is the same for c in shallow.columns: - assert shallow._data[c].index is flags._data[c].index + assert shallow._data[c].index.equals(flags._data[c].index) # the underling series data was copied for c in deep.columns: diff --git a/tests/core/test_history.py b/tests/core/test_history.py index e8279f46237482d0d90c7d29bc00480c664015e1..cb3412c370f90046bf14a062176932ed0bee9984 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -143,7 +143,7 @@ def test_copy(data): assert is_equal(deep, shallow) # underling pandas data was only copied with deep=True - assert shallow.hist.index is hist.hist.index + assert shallow.hist.index.equals(hist.hist.index) assert deep.hist.index is not hist.hist.index diff --git a/tests/funcs/test_flagtools.py b/tests/funcs/test_flagtools.py index eb4081bdf5750b20b29412d9d67a5900c298be0f..6bda00301e2bd493dadff6fa2c2205b46467419b 100644 --- a/tests/funcs/test_flagtools.py +++ b/tests/funcs/test_flagtools.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd import pytest +import saqc from saqc import BAD as B from saqc import UNFLAGGED as U from saqc import SaQC @@ -218,3 +219,82 @@ def test_transferFlags(): assert qc3._history["a"].hist.iloc[:, 0].equals(qc3._history["x"].hist.squeeze()) assert qc3._history["a"].hist.iloc[:, 1].equals(qc3._history["y"].hist.squeeze()) assert qc3._history["a"].hist.iloc[:, 2].equals(qc3._history["z"].hist.squeeze()) + + +@pytest.mark.parametrize( + "f_data", + [ + ( + pd.Series( + ["2000-01-01T00:30:00", "2000-01-01T01:30:00"], + index=["2000-01-01T00:00:00", "2000-01-01T01:00:00"], + ) + ), + ( + np.array( + [ + ("2000-01-01T00:00:00", "2000-01-01T00:30:00"), + ("2000-01-01T01:00:00", "2000-01-01T01:30:00"), + ] + ) + ), + ( + [ + ("2000-01-01T00:00:00", "2000-01-01T00:30:00"), + ("2000-01-01T01:00:00", "2000-01-01T01:30:00"), + ] + ), + ("maint"), + ], +) +def test_setFlags_intervals(f_data): + start = ["2000-01-01T00:00:00", "2000-01-01T01:00:00"] + end = ["2000-01-01T00:30:00", "2000-01-01T01:30:00"] + maint_data = pd.Series(data=end, index=pd.DatetimeIndex(start), name="maint") + data = pd.Series( + np.arange(30), + index=pd.date_range("2000", freq="11min", periods=30), + name="data", + ) + qc = saqc.SaQC([data, maint_data]) + qc = qc.setFlags("data", data=f_data) + assert (qc.flags["data"].iloc[np.r_[0:3, 6:9]] > 0).all() + assert (qc.flags["data"].iloc[np.r_[4:6, 10:30]] < 0).all() + + +@pytest.mark.parametrize( + "f_data", + [ + ( + np.array( + [ + "2000-01-01T00:00:00", + "2000-01-01T00:30:00", + "2000-01-01T01:00:00", + "2000-01-01T01:30:00", + ] + ) + ), + ( + [ + "2000-01-01T00:00:00", + "2000-01-01T00:30:00", + "2000-01-01T01:00:00", + "2000-01-01T01:30:00", + ] + ), + ], +) +def test_setFlags_ontime(f_data): + start = ["2000-01-01T00:00:00", "2000-01-01T01:00:00"] + end = ["2000-01-01T00:30:00", "2000-01-01T01:30:00"] + maint_data = pd.Series(data=end, index=pd.DatetimeIndex(start), name="maint") + data = pd.Series( + np.arange(30), + index=pd.date_range("2000", freq="11min", periods=30), + name="data", + ) + qc = saqc.SaQC([data, maint_data]) + qc = qc.setFlags("data", data=f_data) + assert qc.flags["data"].iloc[0] > 0 + assert (qc.flags["data"].iloc[1:] < 0).all() diff --git a/tests/funcs/test_functions.py b/tests/funcs/test_functions.py index 3065d9f66c05a87e2af73fa3553d12477f9358b5..8812569ae3b584983e3e41f1d5266994458341ab 100644 --- a/tests/funcs/test_functions.py +++ b/tests/funcs/test_functions.py @@ -179,19 +179,21 @@ def test_flagManual(data, field): ] for kw in kwargs_list: - qc = SaQC(data, flags).flagManual(field, **kw) + with pytest.deprecated_call(): + qc = SaQC(data, flags).flagManual(field, **kw) isflagged = qc._flags[field] > UNFLAGGED assert isflagged[isflagged].index.equals(index_exp) # flag not exist in mdata - qc = SaQC(data, flags).flagManual( - field, - mdata=mdata, - mflag="i do not exist", - method="ontime", - mformat="mflag", - flag=BAD, - ) + with pytest.deprecated_call(): + qc = SaQC(data, flags).flagManual( + field, + mdata=mdata, + mflag="i do not exist", + method="ontime", + mformat="mflag", + flag=BAD, + ) isflagged = qc._flags[field] > UNFLAGGED assert isflagged[isflagged].index.equals(pd.DatetimeIndex([])) @@ -220,14 +222,15 @@ def test_flagManual(data, field): ] bound_drops = {"right-open": [1], "left-open": [0], "closed": []} for method in ["right-open", "left-open", "closed"]: - qc = qc.flagManual( - field, - mdata=mdata, - mflag=1, - method=method, - mformat="mflag", - flag=BAD, - ) + with pytest.deprecated_call(): + qc = qc.flagManual( + field, + mdata=mdata, + mflag=1, + method=method, + mformat="mflag", + flag=BAD, + ) isflagged = qc._flags[field] > UNFLAGGED for flag_i in flag_intervals: f_i = isflagged[slice(flag_i[0], flag_i[-1])].index diff --git a/tests/funcs/test_tools.py b/tests/funcs/test_tools.py index 028bae5d8480872b7a1b070ec92c72b711753e99..231ab5faef59e1b9aa1111bd8bce4803acc3430d 100644 --- a/tests/funcs/test_tools.py +++ b/tests/funcs/test_tools.py @@ -32,10 +32,27 @@ def test_makeFig(tmp_path): outfile = str(Path(tmp_path, "test.png")) # the filesystem's temp dir d_saqc = d_saqc.plot(field="data", path=outfile) - d_saqc = d_saqc.plot(field="data", path=outfile, history="valid", stats=True) + d_saqc = d_saqc.plot( + field="data", path=outfile, history="valid", yscope=[(-50, 1000)] + ) with pytest.deprecated_call(): d_saqc = d_saqc.plot(field="data", path=outfile, history="complete") d_saqc = d_saqc.plot( - field="data", path=outfile, ax_kwargs={"ylabel": "data is data"}, stats=True + field="data", + path=outfile, + ax_kwargs={"ylabel": "data is data"}, + yscope=(100, 150), + ) + + +@pytest.mark.filterwarnings("ignore::UserWarning") +def test_flagByClick(): + saqc.funcs.tools._TEST_MODE = True + data = pd.DataFrame( + {f"d{k}": np.random.randint(0, 100, 100) for k in range(10)}, + index=pd.date_range("2000", freq="1d", periods=100), ) + qc = saqc.SaQC(data) + qc = qc.flagByClick(data.columns, gui_mode="overlay") + qc = qc.flagByClick(data.columns) diff --git a/tests/requirements.txt b/tests/requirements.txt index fe7b3b98b3d9e1f2533100e87395f32bf45d6933..fe4007473f8b81486001de7b2b4c7cf4fe31ea98 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -2,9 +2,9 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -beautifulsoup4==4.12.2 -hypothesis==6.92.2 -Markdown==3.5.1 +beautifulsoup4==4.12.3 +hypothesis==6.98.15 +Markdown==3.5.2 pytest==7.4.4 pytest-lazy-fixture==0.6.3 requests==2.31.0