diff --git a/gwhat/HydroPrint2.py b/gwhat/HydroPrint2.py index b903a4d8e..1875010fa 100644 --- a/gwhat/HydroPrint2.py +++ b/gwhat/HydroPrint2.py @@ -30,7 +30,7 @@ import gwhat.hydrograph4 as hydrograph import gwhat.mplFigViewer3 as mplFigViewer -from gwhat.meteo.weather_viewer import WeatherAvgGraph +from gwhat.meteo.weather_viewer import WeatherViewer from gwhat.colors2 import ColorsReader, ColorsSetupWin from gwhat.common import QToolButtonNormal, QToolButtonSmall @@ -54,7 +54,7 @@ def __init__(self, datamanager, parent=None): self.dmngr.wldsetChanged.connect(self.wldset_changed) self.dmngr.wxdsetChanged.connect(self.wxdset_changed) - self.weather_avg_graph = WeatherAvgGraph(self) + self.weather_avg_graph = WeatherViewer(self) self.page_setup_win = PageSetupWin(self) self.page_setup_win.newPageSetupSent.connect(self.layout_changed) @@ -456,7 +456,7 @@ def show_weather_averages(self): return self.weather_avg_graph.save_fig_dir = self.workdir - self.weather_avg_graph.generate_graph(self.wxdset) + self.weather_avg_graph.set_weather_dataset(self.wxdset) self.weather_avg_graph.show() # ---- Datasets Handlers diff --git a/gwhat/colors2.py b/gwhat/colors2.py index 0c3c217e4..ea22ab63b 100644 --- a/gwhat/colors2.py +++ b/gwhat/colors2.py @@ -64,13 +64,11 @@ def load_colors_db(self): self.save_colors_db() else: - print('Loading the color settings...', end=" ") with open(fname, 'r') as f: reader = list(csv.reader(f, delimiter=',')) for row in reader: self.RGB[row[0]] = [int(x) for x in row[1:]] - print('done') def save_colors_db(self): """Save the color settings to Colors.db.""" @@ -80,7 +78,6 @@ def save_colors_db(self): fcontent.append([key]) fcontent[-1].extend(self.RGB[key]) save_content_to_csv(fname, fcontent) - print('Color settings saved successfully.') class ColorsSetupWin(myqt.DialogWindow): diff --git a/gwhat/common/icons.py b/gwhat/common/icons.py index cdc97f5d4..fdd7b6dd5 100644 --- a/gwhat/common/icons.py +++ b/gwhat/common/icons.py @@ -22,6 +22,7 @@ dirname = os.path.join(__rootdir__, 'ressources', 'icons_png') ICON_NAMES = {'master': 'WHAT', + 'expand_range_vert': 'expand_range_vert', 'info': 'info', 'calc_brf': 'start', 'setup': 'page_setup', @@ -131,3 +132,9 @@ class QToolButtonSmall(QToolButtonBase): def __init__(self, Qicon, *args, **kargs): super(QToolButtonSmall, self).__init__(Qicon, *args, **kargs) self.setIconSize(QSize(20, 20)) + + +class QToolButtonVRectSmall(QToolButtonBase): + def __init__(self, Qicon, *args, **kargs): + super(QToolButtonVRectSmall, self).__init__(Qicon, *args, **kargs) + self.setIconSize(QSize(8, 20)) diff --git a/gwhat/common/widgets.py b/gwhat/common/widgets.py index edff287d6..d9ebdb1d9 100644 --- a/gwhat/common/widgets.py +++ b/gwhat/common/widgets.py @@ -282,7 +282,8 @@ def __init__(self, parent=None, resizable=False, maximize=True): else: self.__resizable = resizable self.setWindowFlags(Qt.Window | - Qt.WindowMinimizeButtonHint) + Qt.WindowMinimizeButtonHint | + Qt.WindowCloseButtonHint) self.setWindowIcon(icons.get_icon('master')) diff --git a/gwhat/meteo/weather_reader.py b/gwhat/meteo/weather_reader.py index 5e4c615f0..fbb28aed3 100644 --- a/gwhat/meteo/weather_reader.py +++ b/gwhat/meteo/weather_reader.py @@ -85,15 +85,16 @@ def __init__(self, filename, *args, **kwargs): 'Ptot': np.array([]), 'Rain': None, 'Snow': None, - 'PET': None} + 'PET': None, + 'Period': (None, None)} - # -------------------------------------------- Import primary data ---- + # ---- Import primary data data = read_weather_datafile(filename) for key in data.keys(): self[key] = data[key] - # ------------------------------------------------- Import missing ---- + # ---- Import missing finfo = filename[:-3] + 'log' if os.path.exists(finfo): @@ -102,22 +103,25 @@ def __init__(self, filename, *args, **kwargs): self['Missing Tavg'] = load_weather_log(finfo, 'Mean Temp (deg C)') self['Missing Ptot'] = load_weather_log(finfo, 'Total Precip (mm)') - # ---------------------------------------------------- format data ---- - - print('Make daily time series continuous.') + # ---- Format Data time = copy(self['Time']) date = [copy(self['Year']), copy(self['Month']), copy(self['Day'])] vbrs = ['Tmax', 'Tavg', 'Tmin', 'Ptot', 'Rain', 'PET'] data = [self[x] for x in vbrs] + # Make daily time series continuous : + time, date, data = make_timeserie_continuous(self['Time'], date, data) self['Time'] = time self['Year'], self['Month'], self['Day'] = date[0], date[1], date[2] for i, vbr in enumerate(vbrs): self[vbr] = data[i] - print('Fill missing with estimated values.') + self['normals']['Period'] = (np.min(self['Year']), + np.max(self['Year'])) + + # Fill missing with estimated values : for vbr in ['Tmax', 'Tavg', 'Tmin', 'PET']: self[vbr] = fill_nan(self['Time'], self[vbr], vbr, 'interp') @@ -125,7 +129,7 @@ def __init__(self, filename, *args, **kwargs): for vbr in ['Ptot', 'Rain', 'Snow']: self[vbr] = fill_nan(self['Time'], self[vbr], vbr, 'zeros') - # ---------------------------------------------- monthly & normals ---- + # ---- Monthly & Normals # Temperature based variables: @@ -136,7 +140,7 @@ def __init__(self, filename, *args, **kwargs): x = calc_monthly_mean(self['Year'], self['Month'], self[vrb]) self['monthly'][vrb] = x[2] - self['normals'][vrb] = calcul_monthly_normals(x[1], x[2]) + self['normals'][vrb] = calcul_monthly_normals(x[0], x[1], x[2]) # Precipitation : @@ -149,11 +153,11 @@ def __init__(self, filename, *args, **kwargs): self['monthly']['Year'] = x[0] self['monthly']['Month'] = x[1] - self['normals']['Ptot'] = calcul_monthly_normals(x[1], x[2]) + self['normals']['Ptot'] = calcul_monthly_normals(x[0], x[1], x[2]) - # ------------------------------------------------- secondary vrbs ---- + # ---- Secondary Variables - # ---- Rain ---- + # Rain if self['Rain'] is None: self['Rain'] = calcul_rain_from_ptot( @@ -166,9 +170,9 @@ def __init__(self, filename, *args, **kwargs): x = calc_monthly_sum(self['Year'], self['Month'], self['Rain']) self['monthly']['Rain'] = x[2] - self['normals']['Rain'] = calcul_monthly_normals(x[1], x[2]) + self['normals']['Rain'] = calcul_monthly_normals(x[0], x[1], x[2]) - # ---- Snow ---- + # Snow if self['Snow'] is None: self['Snow'] = self['Ptot'] - self['Rain'] @@ -180,9 +184,9 @@ def __init__(self, filename, *args, **kwargs): x = calc_monthly_sum(self['Year'], self['Month'], self['Snow']) self['monthly']['Snow'] = x[2] - self['normals']['Snow'] = calcul_monthly_normals(x[1], x[2]) + self['normals']['Snow'] = calcul_monthly_normals(x[0], x[1], x[2]) - # ---- Potential Evapotranspiration ---- + # Potential Evapotranspiration if self['PET'] is None: dates = [self['Year'], self['Month'], self['Day']] @@ -198,7 +202,7 @@ def __init__(self, filename, *args, **kwargs): x = calc_monthly_sum(self['Year'], self['Month'], self['PET']) self['monthly']['PET'] = x[2] - self['normals']['PET'] = calcul_monthly_normals(x[1], x[2]) + self['normals']['PET'] = calcul_monthly_normals(x[0], x[1], x[2]) print('-'*78) @@ -349,7 +353,7 @@ def add_PET_to_weather_datafile(filename): Tavg = data[:, vrbs.index('Mean Temp (deg C)')] x = calc_monthly_mean(Year, Month, Tavg) - Ta = calcul_monthly_normals(x[1], x[2]) + Ta = calcul_monthly_normals(x[0], x[1], x[2]) PET = calcul_Thornthwaite(Dates, Tavg, lat, Ta) @@ -518,10 +522,18 @@ def fill_nan(time, data, name='data', fill_mode='zeros'): # ----- Base functions: monthly downscaling def calc_monthly_sum(yy_dly, mm_dly, x_dly): + """ + Calcul monthly cumulative values from daily values, where yy_dly are the + years, mm_dly are the months (1 to 12), and x_dly are the daily values. + """ return calc_monthly(yy_dly, mm_dly, x_dly, np.sum) def calc_monthly_mean(yy_dly, mm_dly, x_dly): + """ + Calcul monthly mean values from daily values, where yy_dly are the + years, mm_dly are the months (1 to 12), and x_dly are the daily values. + """ return calc_monthly(yy_dly, mm_dly, x_dly, np.mean) @@ -543,10 +555,26 @@ def calc_monthly(yy_dly, mm_dly, x_dly, func): return yy_mly, mm_mly, x_mly -def calcul_monthly_normals(mm_mly, x_mly): +def calcul_monthly_normals(years, months, x_mly, yearmin=None, yearmax=None): + """Calcul the monthly normals from monthly values.""" + if len(years) != len(months) != len(x_mly): + raise ValueError("The dimension of the years, months, and x_mly array" + " must match exactly.") + if np.min(months) < 1 or np.max(months) > 12: + raise ValueError("Months values must be between 1 and 12.") + + # Mark as nan monthly values that are outside the year range that is + # defined by yearmin and yearmax : + x_mly = np.copy(x_mly) + if yearmin is not None: + x_mly[years < yearmin] = np.nan + if yearmax is not None: + x_mly[years > yearmax] = np.nan + + # Calcul the monthly normals : x_norm = np.zeros(12) for i, mm in enumerate(range(1, 13)): - indx = np.where((mm_mly == mm) & (~np.isnan(x_mly)))[0] + indx = np.where((months == mm) & (~np.isnan(x_mly)))[0] if len(indx) > 0: x_norm[i] = np.mean(x_mly[indx]) else: @@ -558,10 +586,18 @@ def calcul_monthly_normals(mm_mly, x_mly): # ----- Base functions: yearly downscaling def calc_yearly_sum(yy_dly, x_dly): + """ + Calcul yearly cumulative values from daily values, where yy_dly are the + years and x_dly are the daily values. + """ return calc_yearly(yy_dly, x_dly, np.sum) def calc_yearly_mean(yy_dly, x_dly): + """ + Calcul yearly mean values from daily values, where yy_dly are the years + and x_dly are the daily values. + """ return calc_yearly(yy_dly, x_dly, np.mean) diff --git a/gwhat/meteo/weather_viewer.py b/gwhat/meteo/weather_viewer.py index 1db6964bf..592f0696f 100644 --- a/gwhat/meteo/weather_viewer.py +++ b/gwhat/meteo/weather_viewer.py @@ -8,17 +8,16 @@ from __future__ import division, unicode_literals -# ---- Standard library imports +# ---- Imports: Standard Libraries import sys import os import os.path as osp -import csv from time import strftime +from datetime import datetime -# ---- Third party imports +# ---- Imports: Third Parties -import xlsxwriter import numpy as np import matplotlib as mpl from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg @@ -26,70 +25,34 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QMenu, QToolButton, QGridLayout, QWidget, QFileDialog, QApplication, QTableWidget, - QTableWidgetItem) + QTableWidgetItem, QLabel, QHBoxLayout, + QHeaderView) -# ---- Local imports +# ---- Imports: Local from gwhat.colors2 import ColorsReader -from gwhat.common import StyleDB, QToolButtonNormal +from gwhat.common import StyleDB from gwhat.common import icons -from gwhat.common.widgets import DialogWindow +from gwhat.common.icons import QToolButtonVRectSmall, QToolButtonNormal +from gwhat.common.widgets import DialogWindow, VSep +from gwhat.widgets.buttons import RangeSpinBoxes from gwhat import __namever__ +from gwhat.meteo.weather_reader import calcul_monthly_normals from gwhat.common.utils import save_content_to_file mpl.rc('font', **{'family': 'sans-serif', 'sans-serif': ['Arial']}) -class LabelDB(object): - - def __init__(self, language): - - # ---- Legend ---- - - self.Pyrly = 'Annual total precipitation = %0.0f mm' - self.Tyrly = 'Average annual air temperature = %0.1f °C' - self.rain = 'Rain' - self.snow = 'Snow' - self.Tmax = 'Temp. max.' - self.Tmin = 'Temp. min.' - self.Tavg = 'Temp. mean' - - # ---- Labels ---- - - self.Tlabel = 'Monthly Air Temperature (°C)' - self.Plabel = 'Monthly Total Precipitation (mm)' - self.month_names = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", - "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] - - if language == 'French': - - # ---- Legend ---- - - self.Pyrly = 'Précipitations totales annuelles = %0.0f mm' - self.Tyrly = 'Température moyenne annuelle = %0.1f °C' - self.rain = 'Pluie' - self.snow = 'Neige' - self.Tmax = 'Températures min.' - self.Tmin = 'Températures max.' - self.Tavg = 'Températures moy.' - - # ---- Labels ---- - - self.Tlabel = 'Températures moyennes mensuelles (°C)' - self.Plabel = 'Précipitations totales mensuelles (mm)' - self.month_names = ["JAN", u"FÉV", "MAR", "AVR", "MAI", "JUN", - "JUL", u"AOÛ", "SEP", "OCT", "NOV", u"DÉC"] - - -class WeatherAvgGraph(DialogWindow): +class WeatherViewer(DialogWindow): """ GUI that allows to plot weather normals, save the graphs to file, see various stats about the dataset, etc... """ def __init__(self, parent=None): - super(WeatherAvgGraph, self).__init__(parent) + super(WeatherViewer, self).__init__(parent, False, False) self.wxdset = None + self.normals = None self.save_fig_dir = os.getcwd() self.meteo_dir = os.getcwd() @@ -97,16 +60,13 @@ def __init__(self, parent=None): self.__initUI__() - # ========================================================================= - def __initUI__(self): - self.setWindowTitle('Weather Averages') self.setWindowIcon(icons.get_icon('master')) - # ---------------------------------------------------- TOOLBAR ---- + # ---- Toolbar - # Widgets : + # Initialize the widgets : menu_save = QMenu() menu_save.addAction('Save normals graph as...', self.save_graph) @@ -134,38 +94,56 @@ def __initUI__(self): "QToolButton::menu-indicator {image: none;}") btn_showStats = QToolButtonNormal(icons.get_icon('showGrid')) - btn_showStats.setToolTip('Show monthly weather normals data table.') + btn_showStats.setToolTip( + "Show the monthly weather normals data table.") btn_showStats.clicked.connect(self.show_monthly_grid) - # Layout : + # Instantiate and define a layout for the year range widget : + + self.year_rng = RangeSpinBoxes() + self.year_rng.setRange(1800, datetime.now().year) + self.year_rng.sig_range_changed.connect(self.update_normals) + + btn_expand = QToolButtonVRectSmall(icons.get_icon('expand_range_vert')) + btn_expand.clicked.connect(self.expands_year_range) + btn_expand.setToolTip("Set the maximal possible year range.") + + lay_expand = QGridLayout() + lay_expand.addWidget(self.year_rng.spb_upper, 0, 0) + lay_expand.addWidget(btn_expand, 0, 1) + lay_expand.setContentsMargins(0, 0, 0, 0) + lay_expand.setSpacing(1) + + qgrid = QHBoxLayout(self.year_rng) + qgrid.setContentsMargins(0, 0, 0, 0) + qgrid.addWidget(QLabel('Year Range :')) + qgrid.addWidget(self.year_rng.spb_lower) + qgrid.addWidget(QLabel('to')) + qgrid.addLayout(lay_expand) + + # Generate the layout of the toolbar : - subgrid_toolbar = QGridLayout() toolbar_widget = QWidget() + subgrid_toolbar = QGridLayout(toolbar_widget) - col = 0 - row = 0 - subgrid_toolbar.addWidget(btn_save, row, col) - col += 1 - subgrid_toolbar.addWidget(self.btn_export, row, col) - col += 1 - subgrid_toolbar.addWidget(btn_showStats, row, col) - col += 1 - subgrid_toolbar.setColumnStretch(col, 4) + buttons = [btn_save, self.btn_export, btn_showStats, VSep(), + self.year_rng] + for col, btn in enumerate(buttons): + subgrid_toolbar.addWidget(btn, 0, col) + subgrid_toolbar.setColumnStretch(subgrid_toolbar.columnCount(), 4) subgrid_toolbar.setSpacing(5) subgrid_toolbar.setContentsMargins(0, 0, 0, 0) - toolbar_widget.setLayout(subgrid_toolbar) + # ---- Main Layout - # -------------------------------------------------- MAIN GRID ---- - - # ---- widgets ---- + # Initialize the widgets : self.fig_weather_normals = FigWeatherNormals() self.grid_weather_normals = GridWeatherNormals() self.grid_weather_normals.hide() - # ---- layout ---- + # Generate the layout : mainGrid = QGridLayout() @@ -176,46 +154,88 @@ def __initUI__(self): row += 1 mainGrid.addWidget(self.grid_weather_normals, row, 0) - mainGrid.setContentsMargins(10, 10, 10, 10) # (L,T,R,B) + mainGrid.setContentsMargins(10, 10, 10, 10) # (L, T, R, B) mainGrid.setSpacing(10) mainGrid.setRowStretch(row, 500) mainGrid.setColumnStretch(0, 500) self.setLayout(mainGrid) - # ========================================================================= - def show_monthly_grid(self): if self.grid_weather_normals.isHidden(): self.grid_weather_normals.show() - self.setFixedHeight(self.size().height()+250) -# self.setFixedWidth(self.size().width()+75) + self.setFixedHeight(self.size().height() + + self.layout().verticalSpacing() + + self.grid_weather_normals.calcul_height()) self.sender().setAutoRaise(False) else: self.grid_weather_normals.hide() - self.setFixedHeight(self.size().height()-250) -# self.setFixedWidth(self.size().width()-75) + self.setFixedHeight(self.size().height() - + self.layout().verticalSpacing() - + self.grid_weather_normals.calcul_height()) self.sender().setAutoRaise(True) - # ========================================================================= - def set_lang(self, lang): + """Sets the language of all the labels in the figure.""" self.language = lang self.fig_weather_normals.set_lang(lang) self.fig_weather_normals.draw() - # ========================================================================= - - def generate_graph(self, wxdset): + def set_weather_dataset(self, wxdset): + """ + Generates the graph, updates the table, and updates the GUI for + the new weather dataset. + """ self.wxdset = wxdset - self.fig_weather_normals.plot_monthly_normals(wxdset['normals']) - self.fig_weather_normals.draw() + # Update the GUI : self.setWindowTitle('Weather Averages for %s' % wxdset['Station Name']) - - self.grid_weather_normals.populate_table(wxdset['normals']) - - # --------------------------------------------------------------------- + self.year_rng.setRange(np.min(wxdset['monthly']['Year']), + np.max(wxdset['monthly']['Year'])) + self.update_normals() + + def expands_year_range(self): + """Sets the maximal possible year range.""" + self.year_rng.spb_upper.setValueSilently( + np.max(self.wxdset['monthly']['Year'])) + self.year_rng.spb_lower.setValueSilently( + np.min(self.wxdset['monthly']['Year'])) + self.update_normals() + + # ---- Normals + + def update_normals(self): + """ + Forces a replot of the normals and an update of the table with the + values calculated over the new range of years. + """ + self.normals = self.calcul_normals() + # Redraw the normals in the graph : + self.fig_weather_normals.plot_monthly_normals(self.normals) + self.fig_weather_normals.draw() + # Update the values in the table : + self.grid_weather_normals.populate_table(self.normals) + + def calcul_normals(self): + """ + Calcul the normal values of the weather dataset for the currently + defined period in the year range widget. + """ + keys = ['Tmax', 'Tmin', 'Tavg', 'Ptot', 'Rain', 'Snow', 'PET'] + monthly = self.wxdset['monthly'] + normals = {} + for key in keys: + if monthly[key] is None: + normals[key] = None + else: + normals[key] = calcul_monthly_normals( + monthly['Year'], monthly['Month'], monthly[key], + self.year_rng.lower_bound, self.year_rng.upper_bound) + + normals['Period'] = (self.year_rng.lower_bound, + self.year_rng.upper_bound) + + return normals def save_graph(self): yrmin = np.min(self.wxdset['Year']) @@ -237,22 +257,26 @@ def save_graph(self): self.save_fig_dir = os.path.dirname(filename) self.fig_weather_normals.figure.savefig(filename) - # ========================================================================= - def save_normals(self): - yrmin = np.min(self.wxdset['Year']) - yrmax = np.max(self.wxdset['Year']) + """ + Save the montly and yearly normals in a file. + """ + # Define a default name for the file : + yrmin = self.normals['Period'][0] + yrmax = self.normals['Period'][1] staname = self.wxdset['Station Name'] defaultname = 'WeatherNormals_%s (%d-%d)' % (staname, yrmin, yrmax) - ddir = os.path.join(self.save_fig_dir, defaultname) + ddir = osp.join(self.save_fig_dir, defaultname) + # Open a dialog to get a save file name : dialog = QFileDialog() filename, ftype = dialog.getSaveFileName( self, 'Save normals', ddir, '*.xlsx;;*.xls;;*.csv') if filename: self.save_fig_dir = osp.dirname(filename) + # Organise the content to save to file. hheader = ['', 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC', 'YEAR'] @@ -265,14 +289,14 @@ def save_normals(self): fcontent = [hheader] for i, (vrb, lbl) in enumerate(zip(vrbs, lbls)): fcontent.append([lbl]) - fcontent[-1].extend(self.wxdset['normals'][vrb].tolist()) + fcontent[-1].extend(self.normals[vrb].tolist()) if vrb in ['Tmin', 'Tavg', 'Tmax']: - fcontent[-1].append(np.mean(self.wxdset['normals'][vrb])) + fcontent[-1].append(np.mean(self.normals[vrb])) else: - fcontent[-1].append(np.sum(self.wxdset['normals'][vrb])) + fcontent[-1].append(np.sum(self.normals[vrb])) save_content_to_file(filename, fcontent) - # ================================================= Export Time Series ==== + # ---- Export Time Series def select_export_file(self): if self.sender() == self.btn_export.menu().actions()[0]: @@ -352,7 +376,47 @@ def export_series_tofile(self, filename, time_frame): QApplication.restoreOverrideCursor() -# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +class FigureLabels(object): + + LANGUAGES = ['english', 'french'] + + def __init__(self, language): + + # Legend : + + self.Pyrly = 'Annual total precipitation = %0.0f mm' + self.Tyrly = 'Average annual air temperature = %0.1f °C' + self.rain = 'Rain' + self.snow = 'Snow' + self.Tmax = 'Temp. max.' + self.Tmin = 'Temp. min.' + self.Tavg = 'Temp. mean' + + # Labels : + + self.Tlabel = 'Monthly Air Temperature (°C)' + self.Plabel = 'Monthly Total Precipitation (mm)' + self.month_names = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", + "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] + + if language.lower() == 'french': + + # Legend : + + self.Pyrly = 'Précipitations totales annuelles = %0.0f mm' + self.Tyrly = 'Température moyenne annuelle = %0.1f °C' + self.rain = 'Pluie' + self.snow = 'Neige' + self.Tmax = 'Températures min.' + self.Tmin = 'Températures max.' + self.Tavg = 'Températures moy.' + + # Labels : + + self.Tlabel = 'Températures moyennes mensuelles (°C)' + self.Plabel = 'Précipitations totales mensuelles (mm)' + self.month_names = ["JAN", "FÉV", "MAR", "AVR", "MAI", "JUN", + "JUL", "AOÛ", "SEP", "OCT", "NOV", "DÉC"] class FigWeatherNormals(FigureCanvasQTAgg): @@ -366,26 +430,23 @@ class FigWeatherNormals(FigureCanvasQTAgg): """ def __init__(self, lang='English'): + lang = lang if lang.lower() in FigureLabels.LANGUAGES else 'English' + self.__figlang = lang + self.__figlabels = FigureLabels(lang) + self.normals = None fw, fh = 8.5, 5. fig = mpl.figure.Figure(figsize=(fw, fh), facecolor='white') - super(FigWeatherNormals, self).__init__(fig) - self.lang = lang - self.normals = None - - labelDB = LabelDB(self.lang) - month_names = labelDB.month_names - - # --------------------------------------------------- Define Margins -- + # Define the Margins : left_margin = 1/fw right_margin = 1/fw - bottom_margin = 0.35/fh + bottom_margin = 0.7/fh top_margin = 0.1/fh - # ------------------------------------------------ Yearly Avg Labels -- + # ---- Yearly Avg Labels # The yearly yearly averages for the mean air temperature and # the total precipitation are displayed in , which is placed on @@ -398,7 +459,7 @@ def __init__(self, lang='English'): right='off', labelbottom='off', labeltop='off', labelleft='off', labelright='off') - # ---- Mean Annual Air Temperature ---- + # Mean Annual Air Temperature : # Places first label at the top left corner of with a horizontal # padding of 5 points and downward padding of 3 points. @@ -410,7 +471,7 @@ def __init__(self, lang='English'): ax3.text(0., 1., 'Mean Annual Air Temperature', fontsize=13, va='top', transform=transform) - # ---- Mean Annual Precipitation ---- + # Mean Annual Precipitation : # Get the bounding box of the first label. @@ -427,7 +488,7 @@ def __init__(self, lang='English'): bbox = ax3.texts[1].get_window_extent(renderer) bbox = bbox.transformed(fig.transFigure.inverted()) - # ---- update geometry ---- + # Update geometry : # Updates the geometry and position of to accomodate the text. @@ -438,24 +499,24 @@ def __init__(self, lang='English'): ax3.set_position([x0, y0, axw, axh]) - # -------------------------------------------------------- Data Axes -- + # ---- Data Axes axh = y0 - bottom_margin y0 = y0 - axh - # ---- Precip ---- + # Precipitation : ax0 = fig.add_axes([x0, y0, axw, axh], zorder=1) ax0.patch.set_visible(False) ax0.spines['top'].set_visible(False) ax0.set_axisbelow(True) - # ---- Air Temp. ---- + # Air Temperature : ax1 = fig.add_axes(ax0.get_position(), frameon=False, zorder=5, sharex=ax0) - # ----------------------------------------------------- INIT ARTISTS -- + # ---- Initialize the Artists # This is only to initiates the artists and to set their parameters # in advance. The plotting of the data is actually done by calling @@ -467,23 +528,22 @@ def __init__(self, lang='English'): y = range(len(XPOS)) colors = ['#990000', '#FF0000', '#FF6666'] - # dashed lines for Tmax, Tavg, and Tmin : + # Dashed lines for Tmax, Tavg, and Tmin : for i in range(3): ax1.plot(XPOS, y, color=colors[i], ls='--', lw=1.5, zorder=100) - # markers for Tavg : + # Markers for Tavg : ax1.plot(XPOS[1:-1], y[1:-1], color=colors[1], marker='o', ls='none', ms=6, zorder=100, mec=colors[1], mfc='white', mew=1.5) - # ------------------------------------------------- XTICKS FORMATING -- + # ---- Xticks Formatting Xmin0 = 0 Xmax0 = 12.001 - # ---- major ---- - + # Major ticks ax0.xaxis.set_ticks_position('bottom') ax0.tick_params(axis='x', direction='out') ax0.xaxis.set_ticklabels([]) @@ -492,72 +552,76 @@ def __init__(self, lang='English'): ax1.tick_params(axis='x', which='both', bottom='off', top='off', labelbottom='off') - # ---- minor ---- - + # Minor ticks ax0.set_xticks(np.arange(Xmin0+0.5, Xmax0+0.49, 1), minor=True) ax0.tick_params(axis='x', which='minor', direction='out', length=0, labelsize=13) - ax0.xaxis.set_ticklabels(month_names, minor=True) - - # ------------------------------------------------- Yticks Formating -- + ax0.xaxis.set_ticklabels(self.fig_labels.month_names, minor=True) - # ---- Precipitation ---- + # ---- Y-ticks Formatting + # Precipitation ax0.yaxis.set_ticks_position('right') ax0.tick_params(axis='y', direction='out', labelsize=13) ax0.tick_params(axis='y', which='minor', direction='out') ax0.yaxis.set_ticklabels([], minor=True) - # ---- Air Temp. ---- - + # Air Temperature ax1.yaxis.set_ticks_position('left') ax1.tick_params(axis='y', direction='out', labelsize=13) ax1.tick_params(axis='y', which='minor', direction='out') ax1.yaxis.set_ticklabels([], minor=True) - # ------------------------------------------------------------- GRID -- + # ---- Grid Parameters # ax0.grid(axis='y', color=[0.5, 0.5, 0.5], linestyle=':', linewidth=1, # dashes=[1, 5]) # ax0.grid(axis='y', color=[0.75, 0.75, 0.75], linestyle='-', # linewidth=0.5) - # ------------------------------------------------------------ XLIMS -- + # ---- Limits of the Axes ax0.set_xlim(Xmin0, Xmax0) - # ------------------------------------------------------ Plot Legend -- + # ---- Legend self.plot_legend() - # =========================================================== Language ==== + @property + def fig_labels(self): + return self.__figlabels + + @property + def fig_language(self): + return self.__figlang def set_lang(self, lang): - self.lang = lang - if self.normals is None: - return + """Sets the language of the figure labels.""" + lang = lang if lang.lower() in FigureLabels.LANGUAGES else 'English' + self.__figlabels = FigureLabels(lang) + self.__figlang = lang + # Update the labels in the plot : self.plot_legend() - self.set_axes_labels() - self.update_yearly_avg() - month_names = LabelDB(self.lang).month_names - self.figure.axes[1].xaxis.set_ticklabels(month_names, minor=True) - - # ============================================================ Legend ===== + self.figure.axes[1].xaxis.set_ticklabels( + self.fig_labels.month_names, minor=True) + if self.normals is not None: + self.set_axes_labels() + self.update_yearly_avg() def plot_legend(self): + """Plot the legend of the figure.""" + ax = self.figure.axes[2] - ax = self.figure.axes[2] # Axe on which the legend is hosted - - # --- bbox transform --- # + # bbox transform : padding = mpl.transforms.ScaledTranslation(5/72, -5/72, self.figure.dpi_scale_trans) transform = ax.transAxes + padding - # --- proxy artists --- # + # Define proxy artists : colors = ColorsReader() colors.load_colors_db() @@ -567,27 +631,26 @@ def plot_legend(self): rec2 = mpl.patches.Rectangle((0, 0), 1, 1, fc=colors.rgb['Rain'], ec='none') - # --- legend entry --- # + # Define the legend labels and markers : lines = [ax.lines[0], ax.lines[1], ax.lines[2], rec2, rec1] - labelDB = LabelDB(self.lang) - labels = [labelDB.Tmax, labelDB.Tavg, labelDB.Tmin, - labelDB.rain, labelDB.snow] + labels = [self.fig_labels.Tmax, self.fig_labels.Tavg, + self.fig_labels.Tmin, self.fig_labels.rain, + self.fig_labels.snow] - # --- plot legend --- # + # Plot the legend : leg = ax.legend(lines, labels, numpoints=1, fontsize=13, borderaxespad=0, loc='upper left', borderpad=0, bbox_to_anchor=(0, 1), bbox_transform=transform) leg.draw_frame(False) - # ========================================================= Plot data ===== - def plot_monthly_normals(self, normals): + """Plot the normals on the figure.""" self.normals = normals - # ------------------------------------------- assign local variables -- + # Assign local variables : Tmax_norm = normals['Tmax'] Tmin_norm = normals['Tmin'] @@ -596,18 +659,9 @@ def plot_monthly_normals(self, normals): Rain_norm = normals['Rain'] Snow_norm = Ptot_norm - Rain_norm - print('Tmax Yearly Avg. = %0.1f' % np.mean(Tmax_norm)) - print('Tmin Yearly Avg. = %0.1f' % np.mean(Tmin_norm)) - print('Tavg Yearly Avg. = %0.1f' % np.mean(Tavg_norm)) - print('Ptot Yearly Acg. = %0.1f' % np.sum(Ptot_norm)) - - # ------------------------------------------------ DEFINE AXIS RANGE -- - - if np.sum(Ptot_norm) < 500: - Yscale0 = 10 # Precipitation (mm) - else: - Yscale0 = 20 + # Define the range of the axis : + Yscale0 = 10 if np.sum(Ptot_norm) < 500 else 20 # Precipitation (mm) Yscale1 = 5 # Temperature (deg C) SCA0 = np.arange(0, 10000, Yscale0) @@ -652,7 +706,7 @@ def plot_monthly_normals(self, normals): Ymin1 = -20 # ---- - # ------------------------------------------------- YTICKS FORMATING -- + # Define the fomatting of the yticks : ax0 = self.figure.axes[1] ax1 = self.figure.axes[2] @@ -674,22 +728,23 @@ def plot_monthly_normals(self, normals): yticks1_minor = np.arange(yticks1[0], yticks1[-1], Yscale1/5.) ax1.set_yticks(yticks1_minor, minor=True) - # --------------------------------------------------- SET AXIS RANGE -- + # Set the range of the axis : ax0.set_ylim(Ymin0, Ymax0) ax1.set_ylim(Ymin1, Ymax1) - # ----------------------------------------------------------- LABELS -- + # ---- LABELS self.set_axes_labels() + self.set_year_range() - # --------------------------------------------------------- PLOTTING -- + # ---- PLOTTING self.plot_precip(Ptot_norm, Snow_norm) self.plot_air_temp(Tmax_norm, Tavg_norm, Tmin_norm) self.update_yearly_avg() - # --------------------------------------------------------- Clipping -- + # ---- Clipping # There is currently a bug regarding this. So we need to do a # workaround @@ -709,58 +764,62 @@ def plot_monthly_normals(self, normals): line.set_clip_box(clip_bbox) def set_axes_labels(self): - labelDB = LabelDB(self.lang) - + """Sets the labels of the y axis.""" + # Set the label fo the precipitation : ax0 = self.figure.axes[1] - ax0.set_ylabel(labelDB.Plabel, va='bottom', fontsize=16, rotation=270) + ax0.set_ylabel(self.fig_labels.Plabel, va='bottom', + fontsize=16, rotation=270) ax0.yaxis.set_label_coords(1.09, 0.5) + # Set the label fo the air temperature : ax1 = self.figure.axes[2] - ax1.set_ylabel(labelDB.Tlabel, va='bottom', fontsize=16) + ax1.set_ylabel(self.fig_labels.Tlabel, va='bottom', fontsize=16) ax1.yaxis.set_label_coords(-0.09, 0.5) - # ========================================================================= + def set_year_range(self): + """Sets the year range label that is displayed below the x axis.""" + if self.normals is not None: + ax0 = self.figure.axes[1] + yearmin, yearmax = self.normals['Period'] + if yearmin == yearmax: + ax0.set_xlabel("%d" % yearmin, fontsize=16, labelpad=10) + else: + ax0.set_xlabel("%d - %d" % (yearmin, yearmax), fontsize=16, + labelpad=10) + + # ---- Plot the Data def plot_precip(self, PNORM, SNORM): - # ---- define vertices manually ---- + # Define the vertices manually : Xmid = np.arange(0.5, 12.5, 1) n = 0.5 # Controls the width of the bins - f = 0.65 # Controls the spacing between the bins + f = 0.75 # Controls the spacing between the bins - Xpos = np.vstack((Xmid - n * f, - Xmid - n * f, - Xmid + n * f, - Xmid + n * f)).transpose().flatten() + Xpos = np.vstack((Xmid - n * f, Xmid - n * f, + Xmid + n * f, Xmid + n * f)).transpose().flatten() - Ptot = np.vstack((PNORM * 0, - PNORM, - PNORM, - PNORM * 0)).transpose().flatten() + Ptot = np.vstack((PNORM * 0, PNORM, + PNORM, PNORM * 0)).transpose().flatten() - Snow = np.vstack((SNORM * 0, - SNORM, - SNORM, - SNORM * 0)).transpose().flatten() + Snow = np.vstack((SNORM * 0, SNORM, + SNORM, SNORM * 0)).transpose().flatten() - # -- plot data -- + # Plot the data : ax = self.figure.axes[1] - for collection in reversed(ax.collections): collection.remove() colors = ColorsReader() colors.load_colors_db() - ax.fill_between(Xpos, 0., Ptot, edgecolor='none', + ax.fill_between(Xpos, 0, Ptot, edgecolor='none', color=colors.rgb['Rain']) - ax.fill_between(Xpos, 0., Snow, edgecolor='none', + ax.fill_between(Xpos, 0, Snow, edgecolor='none', color=colors.rgb['Snow']) - # --------------------------------------------------------------------- - def plot_air_temp(self, Tmax_norm, Tavg_norm, Tmin_norm): for i, Tnorm in enumerate([Tmax_norm, Tavg_norm, Tmin_norm]): T0 = (Tnorm[-1]+Tnorm[0])/2 @@ -768,31 +827,22 @@ def plot_air_temp(self, Tmax_norm, Tavg_norm, Tmin_norm): self.figure.axes[2].lines[i].set_ydata(T) self.figure.axes[2].lines[3].set_ydata(Tavg_norm) - # ========================================================================= - def update_yearly_avg(self): Tavg_norm = self.normals['Tavg'] Ptot_norm = self.normals['Ptot'] - ax = self.figure.axes[0] - # ---- update position ---- + # Update the position of the labels : bbox = ax.texts[0].get_window_extent(self.get_renderer()) bbox = bbox.transformed(ax.transAxes.inverted()) - ax.texts[1].set_position((0, bbox.y0)) - # ---- update labels ---- - - labelDB = LabelDB(self.lang) - - ax.texts[0].set_text(labelDB.Tyrly % np.mean(Tavg_norm)) - ax.texts[1].set_text(labelDB.Pyrly % np.sum(Ptot_norm)) - + # Update the text of the labels : -# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + ax.texts[0].set_text(self.fig_labels.Tyrly % np.mean(Tavg_norm)) + ax.texts[1].set_text(self.fig_labels.Pyrly % np.sum(Ptot_norm)) class GridWeatherNormals(QTableWidget): @@ -807,15 +857,14 @@ def initUI(self): self.setFrameStyle(StyleDB().frame) self.setShowGrid(False) self.setAlternatingRowColors(True) -# self.setMinimumWidth(650) - - # ------------------------------------------------------- Header -- HEADER = ('JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC', 'YEAR') self.setColumnCount(len(HEADER)) self.setHorizontalHeaderLabels(HEADER) + self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.horizontalHeader().setHighlightSections(False) self.setRowCount(7) self.setVerticalHeaderLabels(['Daily Tmax (°C)', 'Daily Tmin (°C)', @@ -823,9 +872,13 @@ def initUI(self): 'Snow (mm)', 'Total Precip (mm)', 'ETP (mm)']) + self.resizeRowsToContents() + self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) + self.verticalHeader().setHighlightSections(False) + def populate_table(self, NORMALS): - # ---- Air Temperature ---- + # ---- Air Temperature for row, key in enumerate(['Tmax', 'Tmin', 'Tavg']): # Months @@ -842,7 +895,7 @@ def populate_table(self, NORMALS): item.setTextAlignment(Qt.AlignCenter) self.setItem(row, 12, item) - # ---- Rain ---- + # ---- Rain row = 3 # Months @@ -859,7 +912,7 @@ def populate_table(self, NORMALS): item.setTextAlignment(Qt.AlignCenter) self.setItem(row, 12, item) - # ---- Snow ---- + # ---- Snow row = 4 # Months @@ -877,7 +930,7 @@ def populate_table(self, NORMALS): item.setTextAlignment(Qt.AlignCenter) self.setItem(row, 12, item) - # ---- Total Precip ---- + # ---- Total Precip row = 5 # Months @@ -893,9 +946,10 @@ def populate_table(self, NORMALS): item.setTextAlignment(Qt.AlignCenter) self.setItem(row, 12, item) - # ---- ETP ---- + # ---- ETP row = 6 + # Months for col in range(12): item = QTableWidgetItem('%0.1f' % NORMALS['PET'][col]) item.setFlags(item.flags() & ~Qt.ItemIsEditable) @@ -908,10 +962,14 @@ def populate_table(self, NORMALS): item.setTextAlignment(Qt.AlignCenter) self.setItem(row, 12, item) - self.resizeColumnsToContents() + def calcul_height(self): + h = self.horizontalHeader().height() + 2*self.frameWidth() + for i in range(self.rowCount()): + h += self.rowHeight(i) + return h -# ---- if __name__ == '__main__' +# %% if __name__ == '__main__' if __name__ == '__main__': from gwhat.meteo.weather_reader import WXDataFrame @@ -927,11 +985,11 @@ def populate_table(self, NORMALS): "FARNHAM (7022320)_2005-2010.out") wxdset = WXDataFrame(fmeteo) - w = WeatherAvgGraph() + w = WeatherViewer() w.save_fig_dir = os.getcwd() - w.set_lang('English') - w.generate_graph(wxdset) + w.set_lang('French') + w.set_weather_dataset(wxdset) w.show() app.exec_() diff --git a/gwhat/projet/reader_projet.py b/gwhat/projet/reader_projet.py index 89dba3105..098f47d91 100644 --- a/gwhat/projet/reader_projet.py +++ b/gwhat/projet/reader_projet.py @@ -106,10 +106,11 @@ def convert_projet_format(self, filename): def close_projet(self): try: self.db.close() - except: - pass # projet is None or already closed + except AttributeError: + # projet is None or already closed. + pass - # ========================================================================= + # ---- Project Properties @property def name(self): @@ -169,7 +170,7 @@ def lon(self): def lon(self, x): self.db.attrs['longitude'] = x - # ======================================================== water level ==== + # ---- Water Levels Dataset Handlers @property def wldsets(self): @@ -232,7 +233,7 @@ def add_wldset(self, name, df): self.db.flush() print('New dataset created sucessfully') - except: + except Exception: print('Unable to save dataset to project db') del self.db['wldsets'][name] @@ -241,7 +242,7 @@ def add_wldset(self, name, df): def del_wldset(self, name): del self.db['wldsets/%s' % name] - # =========================================================== weather ===== + # ---- Weather Dataset Handlers @property def wxdsets(self): @@ -474,12 +475,11 @@ def get_layout(self): return layout -# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: - - class WXDataFrameHDF5(dict): - # This is a wrapper around the h5py group that is used to mimick the - # structure of WXDataFrame in meteo_utils. + """ + This is a wrapper around the h5py group that is used to mimick the + structure of WXDataFrame in gwhat.meteo.weather_reader. + """ def __init__(self, dset, *args, **kwargs): super(WXDataFrameHDF5, self).__init__(*args, **kwargs) self.dset = dset @@ -491,6 +491,11 @@ def __getitem__(self, key): x = {} for vrb in self.dset[key].keys(): x[vrb] = self.dset[key][vrb].value + if key == 'normals' and 'Period' not in x.keys(): + # This is needed for backward compatibility with + # gwhat < 0.2.3 (see PR#142). + x['Period'] = (np.min(self.dset['Year']), + np.max(self.dset['Year'])) return x elif key == 'daily': vrbs = ['Year', 'Month', 'Day', 'Tmin', 'Tavg', 'Tmax', @@ -508,6 +513,6 @@ def name(self): if __name__ == '__main__': - f = 'C:/Users/jnsebgosselin/Desktop/testé/testé.what' - f = 'C:/Users/jsgosselin/OneDrive/WHAT/WHAT/tests/Example.what' + f = ("C:/Users/jsgosselin/GWHAT/" + "gwhat/tests/@ new-prô'jèt!/@ new-prô'jèt!.gwt") pr = ProjetReader(f) diff --git a/gwhat/ressources/icons_png/expand_bottom_left.png b/gwhat/ressources/icons_png/expand_bottom_left.png new file mode 100644 index 000000000..a72d1f4ff Binary files /dev/null and b/gwhat/ressources/icons_png/expand_bottom_left.png differ diff --git a/gwhat/ressources/icons_png/expand_range_vert.png b/gwhat/ressources/icons_png/expand_range_vert.png new file mode 100644 index 000000000..a5a9877f2 Binary files /dev/null and b/gwhat/ressources/icons_png/expand_range_vert.png differ diff --git a/gwhat/ressources/icons_scalable/expand_bottom_left.svg b/gwhat/ressources/icons_scalable/expand_bottom_left.svg new file mode 100644 index 000000000..792ed8023 --- /dev/null +++ b/gwhat/ressources/icons_scalable/expand_bottom_left.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/gwhat/ressources/icons_scalable/expand_range_vert.svg b/gwhat/ressources/icons_scalable/expand_range_vert.svg new file mode 100644 index 000000000..9ab9f8bd1 --- /dev/null +++ b/gwhat/ressources/icons_scalable/expand_range_vert.svg @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/gwhat/widgets/buttons.py b/gwhat/widgets/buttons.py index 8924195c9..7a46f700a 100644 --- a/gwhat/widgets/buttons.py +++ b/gwhat/widgets/buttons.py @@ -6,15 +6,192 @@ # This file is part of GWHAT (Ground-Water Hydrograph Analysis Toolbox). # Licensed under the terms of the GNU General Public License. -# ---- Imports: standard libraries +# ---- Imports: Standard Libraries import sys -# ---- Imports: third parties +# ---- Imports: Third Parties +import numpy as np from PyQt5.QtCore import pyqtSignal as QSignal +from PyQt5.QtCore import pyqtSlot as QSlot from PyQt5.QtCore import QSize, Qt -from PyQt5.QtWidgets import QApplication, QToolButton, QListWidget, QStyle +from PyQt5.QtWidgets import (QApplication, QToolButton, QListWidget, QStyle, + QWidget, QDoubleSpinBox) + + +class SmartSpinBox(QDoubleSpinBox): + """ + A spinbox that can act as a QSpinBox or QDoubleSpinBox that stores its + value in an internal variable so that there is no loss in precision when + the value of the spinbox is set programatically. In addition, the + previous value of the spinbox is stored internally. + + The signal that is emitted when the value of the spinbox changes is also + smarter than the one implemented in the QDoubleSpinBox. The signal also + send the previous value in addition to the new value. + + Finally, it is allowed to enter values that are above or below the range + of the spinbox when editing the value in the line edit. The value will + be corrected to the maximum or minimum value once the editing is finished. + """ + sig_value_changed = QSignal(float, float) + + def __init__(self, val=0, dec=0, step=1, units=None, parent=None, + show_btns=True): + super(SmartSpinBox, self).__init__(parent) + if show_btns is False: + self.setButtonSymbols(QDoubleSpinBox.NoButtons) + self.setAlignment(Qt.AlignCenter) + self.setKeyboardTracking(False) + self.setAccelerated(True) + + self.__current_value = val + self.__previous_value = None + self.setDecimals(dec) + self.setRange(0, 100) + self.setValue(val) + if step is not None: + self.setSingleStep(step) + else: + self.setSingleStep(10**-dec) + if units is not None: + self.setSuffix(units) + + self.editingFinished.connect(self.editValue) + + def keyPressEvent(self, event): + """ + Qt method overrides to block certain key events when we want the + spinbox to act as a QSpinBox instead of a QDoubleSpinBox. + """ + if (event.key() in [Qt.Key_Comma, Qt.Key_Period] and + self.decimals() == 0): + event.accept() + elif event.key() == Qt.Key_Minus and self.__min_value >= 0: + event.accept() + else: + super(SmartSpinBox, self).keyPressEvent(event) + + def editValue(self): + """ + Ensure that the value that was entered by editing the value of the + spin box is within the range of values of the spinbox. + """ + new_value = super(SmartSpinBox, self).value() + new_value = max(min(new_value, self.__max_value), self.__min_value) + self.setValue(new_value) + + def stepBy(self, n): + """ + Qt method overrides to ensure the value remains within the + range of values of the spinbox. + """ + new_value = self.value() + n*self.singleStep() + new_value = max(min(new_value, self.__max_value), self.__min_value) + self.setValue(new_value) + + def value(self): + """ + Qt method override that returns the value stocked in the internal + variable instead of the one displayed in the UI. + """ + return self.__current_value + + def previousValue(self): + """ + Returns the previous value of the spinbox. + """ + return self.__previous_value + + def setValue(self, new_value): + """Qt method override to save the value in an internal variable.""" + self.blockSignals(True) + super(SmartSpinBox, self).setValue(new_value) + self.blockSignals(False) + if new_value != self.__current_value: + self.__previous_value = self.__current_value + self.__current_value = new_value + self.sig_value_changed.emit( + self.__current_value, self.__previous_value) + + def setValueSilently(self, x): + """ + Sets the value of the spinbox silently. + """ + self.blockSignals(True) + self.setValue(x) + self.blockSignals(False) + + def setDecimal(self, x): + """Qt method override to force a reset of the displayed range.""" + super(SmartSpinBox, self).setDecimal(x) + self.setRange(self.__min_value, self.__max_value) + + def setRange(self, xmin, xmax): + """Qt method override to save the range in internal variables.""" + if xmin > xmax: + raise ValueError("xmin must be <= xmax") + self.__max_value = xmax + self.__min_value = xmin + + lenght_int = int(np.ceil(np.log10(max(abs(xmax), abs(xmin)))))+1 + max_edit = '9' * lenght_int + '.' + '9' * self.decimals() + max_edit = int(max_edit) if self.decimals() > 0 else float(max_edit) + super(SmartSpinBox, self).setRange(-max_edit, max_edit) + + new_value = max(min(self.value(), self.__max_value), self.__min_value) + self.setValue(new_value) + + +class RangeSpinBoxes(QWidget): + """ + Consists of two spinboxes that are linked togheter so that one represent a + lower boundary and the other one the upper boundary of a range. + """ + sig_range_changed = QSignal(tuple) + + def __init__(self, min_value=0, max_value=0, orientation=Qt.Horizontal, + parent=None): + super(RangeSpinBoxes, self).__init__(parent) + self.spb_lower = SmartSpinBox() + self.spb_upper = SmartSpinBox() + + self.spb_lower.sig_value_changed.connect( + self.constain_bounds_to_minmax) + self.spb_upper.sig_value_changed.connect( + self.constain_bounds_to_minmax) + + self.setRange(1000, 9999) + + @property + def lower_bound(self): + return self.spb_lower.value() + + @property + def upper_bound(self): + return self.spb_upper.value() + + def setRange(self, min_value, max_value): + """Set the min max values of the range for both spin boxes.""" + if min_value > max_value: + raise ValueError("min_value > max_value") + self.spb_lower.setRange(min_value, max_value) + self.spb_upper.setRange(min_value, max_value) + self.spb_lower.setValueSilently(min_value) + self.spb_upper.setValueSilently(max_value) + + @QSlot(float, float) + def constain_bounds_to_minmax(self, new_value, old_value): + """ + Makes sure that the new value is within the min and max values that + were set for the range. Also makes sure that the + """ + if new_value > self.spb_upper.value(): + self.spb_upper.setValueSilently(new_value) + if new_value < self.spb_lower.value(): + self.spb_lower.setValueSilently(new_value) + self.sig_range_changed.emit((self.lower_bound, self.upper_bound)) class DropDownButton(QToolButton): @@ -102,7 +279,9 @@ def focusOutEvent(self, event): self.hide() -if __name__ == '__main__': # pragma: no cover +# %% if __name__ == '__main__' + +if __name__ == '__main__': import sys app = QApplication(sys.argv)