From 2bf667615f0cf650258a9745ad849463242533f6 Mon Sep 17 00:00:00 2001 From: Matthias Cuntz Date: Sat, 13 Jan 2024 18:58:52 +0100 Subject: [PATCH] Allow groups in netcdf files --- CHANGELOG.rst | 4 +- src/ncvue/__init__.py | 48 +-- src/ncvue/ncvcontour.py | 41 +-- src/ncvue/ncvmain.py | 18 +- src/ncvue/ncvmap.py | 112 +++--- src/ncvue/ncvmethods.py | 747 ++++++++++++++++++++-------------------- src/ncvue/ncvscatter.py | 39 ++- src/ncvue/ncvue.py | 19 +- src/ncvue/ncvwidgets.py | 226 +++++++++--- src/ncvue/tooltip.py | 192 +++++++++++ 10 files changed, 907 insertions(+), 539 deletions(-) create mode 100644 src/ncvue/tooltip.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b6fcaba..459b5a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,9 @@ Changelog --------- -v4.2 (??? 2024) +v4.2 (Jan 2024) + * Use local copy of `tooltip.py` from idle. + * Allow groups in netcdf files. * Made ``ncvue`` work with newer matplotlib versions, updating colormaps and using matplotlib.pyplot.style 'seaborn-v0_8-dark'. * Made ``ncvue`` work with newer Tcl/Tk versions (ttk.Style.theme_use). diff --git a/src/ncvue/__init__.py b/src/ncvue/__init__.py index 8a0865a..a50094b 100644 --- a/src/ncvue/__init__.py +++ b/src/ncvue/__init__.py @@ -58,50 +58,54 @@ * v4.1, final add_cyclic and has_cyclic as committed to cartopy, Nov 2021, Matthias Cuntz * v4.1.2, ncvue is gui_script entry_point, Jun 2022, Matthias Cuntz + * v4.2, allow groups in netcdf files, Jan 2024, Matthias Cuntz """ -# version, author -try: - from ._version import __version__ -except ImportError: # pragma: nocover - # package is not installed - __version__ = "0.0.0.dev0" -__author__ = "Matthias Cuntz" - # helper functions +# copy from idle +from .tooltip import TooltipBase, OnHoverTooltipBase, Hovertip # general helper function -from .ncvutils import DIMMETHODS -from .ncvutils import add_cyclic, has_cyclic, clone_ncvmain -from .ncvutils import format_coord_contour, format_coord_map -from .ncvutils import format_coord_scatter, get_slice -from .ncvutils import list_intersection, set_axis_label, set_miss -from .ncvutils import spinbox_values, vardim2var, zip_dim_name_length - +from .ncvutils import DIMMETHODS +from .ncvutils import add_cyclic, has_cyclic, clone_ncvmain +from .ncvutils import format_coord_contour, format_coord_map +from .ncvutils import format_coord_scatter, get_slice +from .ncvutils import list_intersection, set_axis_label, set_miss +from .ncvutils import spinbox_values, vardim2var, zip_dim_name_length +# # common methods of all panels from .ncvmethods import analyse_netcdf, get_miss, get_slice_miss from .ncvmethods import set_dim_lat, set_dim_lon, set_dim_var from .ncvmethods import set_dim_x, set_dim_y, set_dim_y2, set_dim_z - +# # adding widgets with labels, etc. from .ncvwidgets import Tooltip from .ncvwidgets import add_checkbutton, add_combobox, add_entry, add_imagemenu from .ncvwidgets import add_menu, add_scale, add_spinbox, add_tooltip - +# # scatter/line panel from .ncvscatter import ncvScatter # contour panel from .ncvcontour import ncvContour # map panel -from .ncvmap import ncvMap +from .ncvmap import ncvMap # main window with panels -from .ncvmain import ncvMain - +from .ncvmain import ncvMain +# # main calling program from .ncvue import ncvue +# +# version, author +try: + from ._version import __version__ +except ImportError: # pragma: nocover + # package is not installed + __version__ = "0.0.0.dev0" +__author__ = "Matthias Cuntz" -__all__ = ["DIMMETHODS", - "add_cyclic", "clone_ncvmain", +__all__ = ['TooltipBase', 'OnHoverTooltipBase', 'Hovertip', + "DIMMETHODS", + "add_cyclic", "has_cyclic", "clone_ncvmain", "format_coord_contour", "format_coord_map", "format_coord_scatter", "get_slice", "list_intersection", "set_axis_label", "set_miss", diff --git a/src/ncvue/ncvcontour.py b/src/ncvue/ncvcontour.py index b6b27aa..0aea777 100644 --- a/src/ncvue/ncvcontour.py +++ b/src/ncvue/ncvcontour.py @@ -25,6 +25,7 @@ * Write coordinates and value on bottom of plotting canvas, May 2021, Matthias Cuntz * Address fi.variables[name] directly by fi[name], Jan 2024, Matthias Cuntz + * Allow groups in netcdf files, Jan 2024, Matthias Cuntz """ from __future__ import absolute_import, division, print_function @@ -370,17 +371,17 @@ def newnetcdf(self): if self.top.fi: self.top.fi.close() # reset empty defaults of top - self.top.dunlim = '' # name of unlimited dimension - self.top.time = None # datetime variable - self.top.tname = '' # datetime variable name - self.top.tvar = '' # datetime variable name in netcdf - self.top.dtime = None # decimal year - self.top.latvar = '' # name of latitude variable - self.top.lonvar = '' # name of longitude variable - self.top.latdim = '' # name of latitude dimension - self.top.londim = '' # name of longitude dimension - self.top.maxdim = 0 # maximum num of dims of all variables - self.top.cols = [] # variable list + self.top.dunlim = [] # name of unlimited dimension + self.top.time = [] # datetime variable + self.top.tname = [] # datetime variable name + self.top.tvar = [] # datetime variable name in netcdf + self.top.dtime = [] # decimal year + self.top.latvar = [] # name of latitude variable + self.top.lonvar = [] # name of longitude variable + self.top.latdim = [] # name of latitude dimension + self.top.londim = [] # name of longitude dimension + self.top.maxdim = 0 # maximum num of dims of all variables + self.top.cols = [] # variable list # open new netcdf file self.top.fi = nc.Dataset(ncfile, 'r') analyse_netcdf(self.top) @@ -611,13 +612,13 @@ def redraw(self): if (z != ''): # z axis gz, vz = vardim2var(z, self.fi.groups.keys()) - if vz == self.tname: + if vz == self.tname[gz]: # should throw an error later if mesh: - zz = self.dtime + zz = self.dtime[gz] zlab = 'Year' else: - zz = self.time + zz = self.time[gz] zlab = 'Date' else: zz = self.fi[vz] @@ -630,12 +631,12 @@ def redraw(self): if (y != ''): # y axis gy, vy = vardim2var(y, self.fi.groups.keys()) - if vy == self.tname: + if vy == self.tname[gy]: if mesh: - yy = self.dtime + yy = self.dtime[gy] ylab = 'Year' else: - yy = self.time + yy = self.time[gy] ylab = 'Date' else: yy = self.fi[vy] @@ -644,12 +645,12 @@ def redraw(self): if (x != ''): # x axis gx, vx = vardim2var(x, self.fi.groups.keys()) - if vx == self.tname: + if vx == self.tname[gx]: if mesh: - xx = self.dtime + xx = self.dtime[gx] xlab = 'Year' else: - xx = self.time + xx = self.time[gx] xlab = 'Date' else: xx = self.fi[vx] diff --git a/src/ncvue/ncvmain.py b/src/ncvue/ncvmain.py index 6b3867a..b45c08d 100644 --- a/src/ncvue/ncvmain.py +++ b/src/ncvue/ncvmain.py @@ -24,6 +24,8 @@ * Written Nov-Dec 2020 by Matthias Cuntz (mc (at) macu (dot) de) * Added check_new_netcdf method that re-initialises all panels if netcdf file changed, Jan 2021, Matthias Cuntz + * Address fi.variables[name] directly by fi[name], Jan 2024, Matthias Cuntz + * Allow groups in netcdf files, Jan 2024, Matthias Cuntz """ from __future__ import absolute_import, division, print_function @@ -80,13 +82,17 @@ def __init__(self, master, **kwargs): self.tab_map = ncvMap(self) mapfirst = False - if self.top.latvar: - gl, vl = vardim2var(self.top.latvar, self.top.fi.groups.keys()) - if np.prod(self.top.fi.variables[vl].shape) > 1: + if any(self.top.latvar): + idx = [ i for i, l in enumerate(self.top.latvar) if l ] + gl, vl = vardim2var(self.top.latvar[idx[0]], + self.top.fi.groups.keys()) + if np.prod(self.top.fi[vl].shape) > 1: mapfirst = True - if self.top.lonvar: - gl, vl = vardim2var(self.top.lonvar, self.top.fi.groups.keys()) - if np.prod(self.top.fi.variables[vl].shape) > 1: + if any(self.top.lonvar): + idx = [ i for i, l in enumerate(self.top.lonvar) if l ] + gl, vl = vardim2var(self.top.lonvar[idx[0]], + self.top.fi.groups.keys()) + if np.prod(self.top.fi[vl].shape) > 1: mapfirst = True if mapfirst: diff --git a/src/ncvue/ncvmap.py b/src/ncvue/ncvmap.py index 7da7b8d..e4d56b2 100644 --- a/src/ncvue/ncvmap.py +++ b/src/ncvue/ncvmap.py @@ -29,6 +29,7 @@ * Work with files without an unlimited (time) dimension (set_tstep), Oct 2021, Matthias Cuntz * Address fi.variables[name] directly by fi[name], Jan 2024, Matthias Cuntz + * Allow groups in netcdf files, Jan 2024, Matthias Cuntz """ from __future__ import absolute_import, division, print_function @@ -428,12 +429,14 @@ def __init__(self, master, **kwargs): command=self.entered_clon, tooltip=tstr) # set lat/lon - if self.latvar: - self.lat.set(self.latvar) + if any(self.latvar): + idx = [ i for i, l in enumerate(self.latvar) if l ] + self.lat.set(self.latvar[idx[0]]) self.inv_lat.set(0) set_dim_lat(self) - if self.lonvar: - self.lon.set(self.lonvar) + if any(self.lonvar): + idx = [ i for i, l in enumerate(self.lonvar) if l ] + self.lon.set(self.lonvar[idx[0]]) self.inv_lon.set(0) self.shift_lon.set(0) set_dim_lon(self) @@ -460,10 +463,14 @@ def __init__(self, master, **kwargs): self.anim_first = True # True: stops in self.update at first call self.anim_running = True # True/False: animation running or not self.anim_inc = 1 # 1/-1: forward or backward run + maxtime = 1 + for vz in self.tvar: + if vz: + maxtime = max(self.fi[vz].size, maxtime) self.anim = animation.FuncAnimation(self.figure, self.update, init_func=self.redraw, interval=self.delayval.get(), - repeat=irepeat) + repeat=irepeat, save_count=maxtime) # # Bindings @@ -549,17 +556,17 @@ def newnetcdf(self): if self.top.fi: self.top.fi.close() # reset empty defaults of top - self.top.dunlim = '' # name of unlimited dimension - self.top.time = None # datetime variable - self.top.tname = '' # datetime variable name - self.top.tvar = '' # datetime variable name in netcdf - self.top.dtime = None # decimal year - self.top.latvar = '' # name of latitude variable - self.top.lonvar = '' # name of longitude variable - self.top.latdim = '' # name of latitude dimension - self.top.londim = '' # name of longitude dimension - self.top.maxdim = 0 # maximum num of dims of all variables - self.top.cols = [] # variable list + self.top.dunlim = [] # name of unlimited dimension + self.top.time = [] # datetime variable + self.top.tname = [] # datetime variable name + self.top.tvar = [] # datetime variable name in netcdf + self.top.dtime = [] # decimal year + self.top.latvar = [] # name of latitude variable + self.top.lonvar = [] # name of longitude variable + self.top.latdim = [] # name of latitude dimension + self.top.londim = [] # name of longitude dimension + self.top.maxdim = 0 # maximum num of dims of all variables + self.top.cols = [] # variable list # open new netcdf file self.top.fi = nc.Dataset(ncfile, 'r') analyse_netcdf(self.top) @@ -580,12 +587,15 @@ def next_t(self): """ Command called if next frame button was pressed. """ - it = int(self.vdval[self.iunlim].get()) - if it < self.nunlim - 1: + try: + it = int(self.vdval[self.iunlim].get()) + except ValueError: + it = -1 + if (it < self.nunlim - 1) and (it >= 0): it += 1 self.set_tstep(it) self.update(it, isframe=True) - else: + elif it == self.nunlim - 1: rep = self.repeat.get() if rep != 'once': if rep == 'repeat': @@ -628,12 +638,15 @@ def prev_t(self): """ Command called if previous frame button was pressed. """ - it = int(self.vdval[self.iunlim].get()) + try: + it = int(self.vdval[self.iunlim].get()) + except ValueError: + it = -1 if it > 0: it -= 1 self.set_tstep(it) self.update(it, isframe=True) - else: + elif it == 0: rep = self.repeat.get() if rep != 'once': if rep == 'repeat': @@ -808,7 +821,7 @@ def get_vminmax(self): v = self.v.get() if (v != ''): gz, vz = vardim2var(v, self.fi.groups.keys()) - if vz == self.tname: + if vz == self.tname[gz]: return (0, 1) vv = self.fi[vz] imiss = get_miss(self, vv) @@ -930,12 +943,14 @@ def reinit(self): self.lon.set(columns[0]) self.lat['values'] = columns self.lat.set(columns[0]) - if self.latvar: - self.lat.set(self.latvar) + if any(self.latvar): + idx = [ i for i, l in enumerate(self.latvar) if l ] + self.lat.set(self.latvar[idx[0]]) self.inv_lat.set(0) set_dim_lat(self) - if self.lonvar: - self.lon.set(self.lonvar) + if any(self.lonvar): + idx = [ i for i, l in enumerate(self.lonvar) if l ] + self.lon.set(self.lonvar[idx[0]]) self.inv_lon.set(0) self.shift_lon.set(0) set_dim_lon(self) @@ -959,13 +974,20 @@ def set_tstep(self, it): Sets the time dimension spinbox, sets the time step scale, write the time on top. """ - if self.dunlim: + v = self.v.get() + gz, vz = vardim2var(v, self.fi.groups.keys()) + try: + has_unlim = self.dunlim[gz] in self.fi[vz].dimensions + except IndexError: + has_unlim = False # datetime + if self.dunlim[gz] and has_unlim: self.vdval[self.iunlim].set(it) self.tstepval.set(it) + time = self.time[gz] try: - self.timelbl.set(np.around(self.time[it], 4)) + self.timelbl.set(np.around(time[it], 4)) except TypeError: - self.timelbl.set(self.time[it]) + self.timelbl.set(time[it]) def set_unlim(self, v): """ @@ -982,14 +1004,14 @@ def set_unlim(self, v): self.dunlim == ''` or `self.dunlim` not in var.dimensions. """ gz, vz = vardim2var(v, self.fi.groups.keys()) - if vz == self.tname: + if vz == self.tname[gz]: self.iunlim = 0 - self.nunlim = self.time.size + self.nunlim = self.time[gz].size else: - if self.dunlim: - if self.dunlim in self.fi[vz].dimensions: + if self.dunlim[gz]: + if self.dunlim[gz] in self.fi[vz].dimensions: self.iunlim = ( - self.fi[vz].dimensions.index(self.dunlim)) + self.fi[vz].dimensions.index(self.dunlim[gz])) else: self.iunlim = 0 else: @@ -1057,13 +1079,13 @@ def redraw(self): if (v != ''): # variable gz, vz = vardim2var(v, self.fi.groups.keys()) - if vz == self.tname: + if vz == self.tname[gz]: # should throw an error later if mesh: - vv = self.dtime + vv = self.dtime[gz] vlab = 'Year' else: - vv = self.time + vv = self.time[gz] vlab = 'Date' else: vv = self.fi[vz] @@ -1078,12 +1100,12 @@ def redraw(self): if (y != ''): # y axis gy, vy = vardim2var(y, self.fi.groups.keys()) - if vy == self.tname: + if vy == self.tname[gy]: if mesh: - yy = self.dtime + yy = self.dtime[gy] ylab = 'Year' else: - yy = self.time + yy = self.time[gy] ylab = 'Date' else: yy = self.fi[vy] @@ -1094,12 +1116,12 @@ def redraw(self): if (x != ''): # x axis gx, vx = vardim2var(x, self.fi.groups.keys()) - if vx == self.tname: + if vx == self.tname[gx]: if mesh: - xx = self.dtime + xx = self.dtime[gx] xlab = 'Year' else: - xx = self.time + xx = self.time[gx] xlab = 'Date' else: xx = self.fi[vx] @@ -1316,8 +1338,8 @@ def update(self, frame, isframe=False): inv_lat = self.inv_lat.get() shift_lon = self.shift_lon.get() gz, vz = vardim2var(v, self.fi.groups.keys()) - if vz == self.tname: - vz = self.tvar + if vz == self.tname[gz]: + vz = self.tvar[gz] vv = self.fi[vz] # slice try: diff --git a/src/ncvue/ncvmethods.py b/src/ncvue/ncvmethods.py index 784d5bc..5bfdcce 100644 --- a/src/ncvue/ncvmethods.py +++ b/src/ncvue/ncvmethods.py @@ -61,6 +61,7 @@ * Do not default the unlimited dimension to 'all' if no lon/lat were found, (get_dim_var) Oct 2021, Matthias Cuntz * Address fi.variables[name] directly by fi[name], Jan 2024, Matthias Cuntz + * Allow groups in netcdf files, Jan 2024, Matthias Cuntz """ from __future__ import absolute_import, division, print_function @@ -115,353 +116,365 @@ def analyse_netcdf(self): except ModuleNotFoundError: import netCDF4 as cf # - # search unlimited dimension - self.dunlim = '' - for dd in self.fi.dimensions: - if self.fi.dimensions[dd].isunlimited(): - self.dunlim = dd - break - # - # search for time variable and make datetime variable - self.time = None - self.tname = '' - self.tvar = '' - self.dtime = None - for vv in self.fi.variables: - isunlim = False - if self.dunlim: - if vv.lower() == self.fi.dimensions[self.dunlim].name.lower(): - isunlim = True - if ( isunlim or vv.lower().startswith('time_') or - (vv.lower() == 'time') or (vv.lower() == 'datetime') or - (vv.lower() == 'date') ): - self.tvar = vv - if vv.lower() == 'datetime': - self.tname = 'date' - else: - self.tname = 'datetime' - try: - tunit = self.fi[self.tvar].units - except AttributeError: - tunit = '' - # assure 01, etc. if values < 10 - if tunit.find('since') > 0: - tt = tunit.split() - dd = tt[2].split('-') - dd[0] = ('000' + dd[0])[-4:] - dd[1] = ('0' + dd[1])[-2:] - dd[2] = ('0' + dd[1])[-2:] - tt[2] = '-'.join(dd) - tunit = ' '.join(tt) - try: - tcal = self.fi[self.tvar].calendar - except AttributeError: - tcal = 'standard' - time = self.fi[self.tvar][:] - # time dimension "day as %Y%m%d.%f" from cdo. - if ' as ' in tunit: - itunit = tunit.split()[2] - dtime = [] - for tt in time: - stt = str(tt).split('.') - sstt = ('00' + stt[0])[-8:] + '.' + stt[1] - dtime.append(dt.datetime.strptime(sstt, itunit)) - ntime = cf.date2num(dtime, - 'days since 0001-01-01 00:00:00') - self.dtime = cf.num2date(ntime, - 'days since 0001-01-01 00:00:00') - else: - try: - self.dtime = cf.num2date(time, tunit, calendar=tcal) - except ValueError: - self.dtime = None - if self.dtime is not None: - ntime = len(self.dtime) - if (tcal == '360_day'): - ndays = [360.] * ntime - elif (tcal == '365_day'): - ndays = [365.] * ntime - elif (tcal == 'noleap'): - ndays = [365.] * ntime - elif (tcal == '366_day'): - ndays = [366.] * ntime - elif (tcal == 'all_leap'): - ndays = [366.] * ntime + # Check for groups (ngroups > 0) + groups = list(self.fi.groups.keys()) + ngroups = len(groups) + + for ig in range(max(ngroups, 1)): + if ngroups > 0: + fi = self.fi[groups[ig]] + gname = groups[ig] + '/' + else: + fi = self.fi + gname = '' + # + # search unlimited dimension + self.dunlim.append('') + for dd in fi.dimensions: + if fi.dimensions[dd].isunlimited(): + self.dunlim[ig] = dd + break + # + # search for time variable and make datetime variable + self.time.append(None) + self.tname.append('') + self.tvar.append('') + self.dtime.append(None) + for vv in fi.variables: + isunlim = False + if self.dunlim[ig]: + if vv.lower() == fi.dimensions[self.dunlim[ig]].name.lower(): + isunlim = True + if ( isunlim or vv.lower().startswith('time_') or + (vv.lower() == 'time') or (vv.lower() == 'datetime') or + (vv.lower() == 'date') ): + self.tvar[ig] = gname + vv + if vv.lower() == 'datetime': + self.tname[ig] = gname + 'date' else: - ndays = [ 365. + - float((((t.year % 4) == 0) & - ((t.year % 100) != 0)) | - ((t.year % 400) == 0)) - for t in self.dtime ] - self.dtime = np.array([ - t.year + - (t.dayofyr - 1 + t.hour / 24. + - t.minute / 1440 + t.second / 86400.) / ndays[i] - for i, t in enumerate(self.dtime) ]) - # make datetime variable - if self.time is None: - try: - ttime = cf.num2date( - time, tunit, calendar=tcal, - only_use_cftime_datetimes=False, - only_use_python_datetimes=True) - self.time = np.array([ dd.isoformat() - for dd in ttime ], - dtype='datetime64[ms]') - except: - self.time = None - if self.time is None: - try: - # self.time = cf.num2date(time, tunit, - ttime = cf.num2date(time, tunit, - calendar=tcal) - self.time = np.array([ dd.isoformat() - for dd in ttime ], - dtype='datetime64[ms]') - except: - self.time = None - if self.time is None: - # if not possible use decimal year - self.time = self.dtime - if self.time is None: - # could not interpret time at all, - # e.g. if units = "months since ..." - self.time = time - self.dtime = time - # print('time: ', self.time) - # print('dtime: ', self.dtime) - break - # - # construct list of variable names with dimensions - if self.time is not None: - addt = [ - self.tname + ' ' + - str(tuple(zip_dim_name_length(self.fi[self.tvar])))] - self.cols += addt - ivars = [] - for vv in self.fi.variables: - # ss = self.fi[vv].shape - ss = tuple(zip_dim_name_length(self.fi[vv])) - self.maxdim = max(self.maxdim, len(ss)) - ivars.append((vv, ss, len(ss))) - self.cols += sorted([ vv[0] + ' ' + str(vv[1]) - for vv in ivars ]) - # - # search for lat/lon variables - self.latvar = '' - self.lonvar = '' - # first sweep: *name must be "latitude" and - # units must be "degrees_north" - if not self.latvar: - for vv in self.fi.variables: - try: - sname = self.fi[vv].standard_name - except AttributeError: - try: - sname = self.fi[vv].long_name - except AttributeError: - sname = self.fi[vv].name - if sname.lower() == 'latitude': - try: - sunit = self.fi[vv].units - except AttributeError: - sunit = '' - if sunit.lower() == 'degrees_north': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - try: - sname = self.fi[vv].standard_name - except AttributeError: - try: - sname = self.fi[vv].long_name - except AttributeError: - sname = self.fi[vv].name - if sname.lower() == 'longitude': - try: - sunit = self.fi[vv].units - except AttributeError: - sunit = '' - if sunit.lower() == 'degrees_east': - self.lonvar = vv - # second sweep: name must start with lat and - # units must be "degrees_north" - if not self.latvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - if sname[0:3].lower() == 'lat': - try: - sunit = self.fi[vv].units - except AttributeError: - sunit = '' - if sunit.lower() == 'degrees_north': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - if sname[0:3].lower() == 'lon': - try: - sunit = self.fi[vv].units - except AttributeError: - sunit = '' - if sunit.lower() == 'degrees_east': - self.lonvar = vv - # third sweep: name must contain lat and - # units must be "degrees_north" - if not self.latvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - sname = sname.lower() - if sname.find('lat') >= 0: - try: - sunit = self.fi[vv].units - except AttributeError: - sunit = '' - if sunit.lower() == 'degrees_north': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - sname = sname.lower() - if sname.find('lon') >= 0: - try: - sunit = self.fi[vv].units - except AttributeError: - sunit = '' - if sunit.lower() == 'degrees_east': - self.lonvar = vv - # fourth sweep: axis is 'Y' or 'y' - if not self.latvar: - for vv in self.fi.variables: - try: - saxis = self.fi[vv].axis - except AttributeError: - saxis = '' - if saxis.lower() == 'y': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - try: - saxis = self.fi[vv].axis - except AttributeError: - saxis = '' - if saxis.lower() == 'x': - self.lonvar = vv - # fifth sweep: same as first but units can be "degrees" - if not self.latvar: - for vv in self.fi.variables: - try: - sname = self.fi[vv].standard_name - except AttributeError: + self.tname[ig] = gname + 'datetime' try: - sname = self.fi[vv].long_name + tunit = self.fi[self.tvar[ig]].units except AttributeError: - sname = self.fi[vv].name - if sname.lower() == 'latitude': + tunit = '' + # assure 01, etc. if values < 10 + if tunit.find('since') > 0: + tt = tunit.split() + dd = tt[2].split('-') + dd[0] = ('000' + dd[0])[-4:] + dd[1] = ('0' + dd[1])[-2:] + dd[2] = ('0' + dd[1])[-2:] + tt[2] = '-'.join(dd) + tunit = ' '.join(tt) try: - sunit = self.fi[vv].units + tcal = self.fi[self.tvar[ig]].calendar except AttributeError: - sunit = '' - if sunit.lower() == 'degrees': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - try: - sname = self.fi[vv].standard_name - except AttributeError: + tcal = 'standard' + time = self.fi[self.tvar[ig]][:] + # time dimension "day as %Y%m%d.%f" from cdo. + if ' as ' in tunit: + itunit = tunit.split()[2] + dtime = [] + for tt in time: + stt = str(tt).split('.') + sstt = ('00' + stt[0])[-8:] + '.' + stt[1] + dtime.append(dt.datetime.strptime(sstt, itunit)) + ntime = cf.date2num(dtime, + 'days since 0001-01-01 00:00:00') + self.dtime[ig] = cf.num2date(ntime, + 'days since 0001-01-01 00:00:00') + else: + try: + self.dtime[ig] = cf.num2date(time, tunit, calendar=tcal) + except ValueError: + self.dtime[ig] = None + if self.dtime[ig] is not None: + ntime = len(self.dtime[ig]) + if (tcal == '360_day'): + ndays = [360.] * ntime + elif (tcal == '365_day'): + ndays = [365.] * ntime + elif (tcal == 'noleap'): + ndays = [365.] * ntime + elif (tcal == '366_day'): + ndays = [366.] * ntime + elif (tcal == 'all_leap'): + ndays = [366.] * ntime + else: + ndays = [ 365. + + float((((t.year % 4) == 0) & + ((t.year % 100) != 0)) | + ((t.year % 400) == 0)) + for t in self.dtime[ig] ] + self.dtime[ig] = np.array([ + t.year + + (t.dayofyr - 1 + t.hour / 24. + + t.minute / 1440 + t.second / 86400.) / ndays[i] + for i, t in enumerate(self.dtime[ig]) ]) + # make datetime variable + if self.time[ig] is None: + try: + ttime = cf.num2date( + time, tunit, calendar=tcal, + only_use_cftime_datetimes=False, + only_use_python_datetimes=True) + self.time[ig] = np.array([ dd.isoformat() + for dd in ttime ], + dtype='datetime64[ms]') + except: + self.time[ig] = None + if self.time[ig] is None: + try: + # self.time = cf.num2date(time, tunit, + ttime = cf.num2date(time, tunit, + calendar=tcal) + self.time[ig] = np.array([ dd.isoformat() + for dd in ttime ], + dtype='datetime64[ms]') + except: + self.time[ig] = None + if self.time[ig] is None: + # if not possible use decimal year + self.time[ig] = self.dtime[ig] + if self.time[ig] is None: + # could not interpret time at all, + # e.g. if units = "months since ..." + self.time[ig] = time + self.dtime[ig] = time + # print('time: ', self.time[ig]) + # print('dtime: ', self.dtime[ig]) + break + # + # construct list of variable names with dimensions + if self.time[ig] is not None: + addt = [ + self.tname[ig] + ' ' + + str(tuple(zip_dim_name_length(self.fi[self.tvar[ig]])))] + self.cols += addt + ivars = [] + for vv in fi.variables: + vname = gname + vv + ss = tuple(zip_dim_name_length(fi[vv])) + self.maxdim = max(self.maxdim, len(ss)) + ivars.append((vname, ss, len(ss))) + self.cols += sorted([ vv[0] + ' ' + str(vv[1]) + for vv in ivars ]) + # + # search for lat/lon variables + self.latvar.append('') + self.lonvar.append('') + # first sweep: *name must be "latitude" and + # units must be "degrees_north" + if not self.latvar[ig]: + for vv in fi.variables: try: - sname = self.fi[vv].long_name + sname = fi[vv].standard_name except AttributeError: - sname = self.fi[vv].name - if sname.lower() == 'longitude': + try: + sname = fi[vv].long_name + except AttributeError: + sname = fi[vv].name + if sname.lower() == 'latitude': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees_north': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: try: - sunit = self.fi[vv].units + sname = fi[vv].standard_name except AttributeError: - sunit = '' - if sunit.lower() == 'degrees': - self.lonvar = vv - # sixth sweep: same as second but units can be "degrees" - if not self.latvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - if sname[0:3].lower() == 'lat': + try: + sname = fi[vv].long_name + except AttributeError: + sname = fi[vv].name + if sname.lower() == 'longitude': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees_east': + self.lonvar[ig] = gname + vv + # second sweep: name must start with lat and + # units must be "degrees_north" + if not self.latvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + if sname[0:3].lower() == 'lat': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees_north': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + if sname[0:3].lower() == 'lon': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees_east': + self.lonvar[ig] = gname + vv + # third sweep: name must contain lat and + # units must be "degrees_north" + if not self.latvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + sname = sname.lower() + if sname.find('lat') >= 0: + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees_north': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + sname = sname.lower() + if sname.find('lon') >= 0: + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees_east': + self.lonvar[ig] = gname + vv + # fourth sweep: axis is 'Y' or 'y' + if not self.latvar[ig]: + for vv in fi.variables: try: - sunit = self.fi[vv].units + saxis = fi[vv].axis except AttributeError: - sunit = '' - if sunit.lower() == 'degrees': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - if sname[0:3].lower() == 'lon': + saxis = '' + if saxis.lower() == 'y': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: try: - sunit = self.fi[vv].units + saxis = fi[vv].axis except AttributeError: - sunit = '' - if sunit.lower() == 'degrees': - self.lonvar = vv - # seventh sweep: same as third but units can be "degrees" - if not self.latvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - sname = sname.lower() - if sname.find('lat') >= 0: + saxis = '' + if saxis.lower() == 'x': + self.lonvar[ig] = gname + vv + # fifth sweep: same as first but units can be "degrees" + if not self.latvar[ig]: + for vv in fi.variables: try: - sunit = self.fi[vv].units + sname = fi[vv].standard_name except AttributeError: - sunit = '' - if sunit.lower() == 'degrees': - self.latvar = vv - if not self.lonvar: - for vv in self.fi.variables: - sname = self.fi[vv].name - sname = sname.lower() - if sname.find('lon') >= 0: + try: + sname = fi[vv].long_name + except AttributeError: + sname = fi[vv].name + if sname.lower() == 'latitude': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: try: - sunit = self.fi[vv].units + sname = fi[vv].standard_name except AttributeError: - sunit = '' - if sunit.lower() == 'degrees': - self.lonvar = vv - # - # determine lat/lon dimensions - self.latdim = '' - self.londim = '' - if self.latvar: - latshape = self.fi[self.latvar].shape - if (len(latshape) < 1) or (len(latshape) > 2): - estr = 'Something went wrong determining lat/lon:' - estr += ' latitude variable is not 1D or 2D.' - print(estr) - estr = 'latitude variable with dimensions:' - ldim = self.fi[self.latvar].dimensions - print(estr, self.latvar, ldim) - self.latvar = '' - else: - self.latdim = self.fi[self.latvar].dimensions[0] - if self.lonvar: - lonshape = self.fi[self.lonvar].shape - if len(lonshape) == 1: - self.londim = self.fi[self.lonvar].dimensions[0] - elif len(lonshape) == 2: - self.londim = self.fi[self.lonvar].dimensions[1] - else: - estr = 'Something went wrong determining lat/lon:' - estr += ' longitude variable is not 1D or 2D.' - print(estr) - estr = 'longitude variable with dimensions:' - ldim = self.fi[self.lonvar].dimensions - print(estr, self.lonvar, ldim) - self.lonvar = '' - # - # add units to lat/lon name - if self.latvar: - idim = tuple(zip_dim_name_length(self.fi[self.latvar])) - self.latvar = self.latvar + ' ' + str(idim) - if self.lonvar: - idim = tuple(zip_dim_name_length(self.fi[self.lonvar])) - self.lonvar = self.lonvar + ' ' + str(idim) + try: + sname = fi[vv].long_name + except AttributeError: + sname = fi[vv].name + if sname.lower() == 'longitude': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees': + self.lonvar[ig] = gname + vv + # sixth sweep: same as second but units can be "degrees" + if not self.latvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + if sname[0:3].lower() == 'lat': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + if sname[0:3].lower() == 'lon': + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees': + self.lonvar[ig] = gname + vv + # seventh sweep: same as third but units can be "degrees" + if not self.latvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + sname = sname.lower() + if sname.find('lat') >= 0: + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees': + self.latvar[ig] = gname + vv + if not self.lonvar[ig]: + for vv in fi.variables: + sname = fi[vv].name + sname = sname.lower() + if sname.find('lon') >= 0: + try: + sunit = fi[vv].units + except AttributeError: + sunit = '' + if sunit.lower() == 'degrees': + self.lonvar[ig] = gname + vv + # + # determine lat/lon dimensions + self.latdim.append('') + self.londim.append('') + if self.latvar[ig]: + latshape = self.fi[self.latvar[ig]].shape + if (len(latshape) < 1) or (len(latshape) > 2): + estr = 'Something went wrong determining lat/lon:' + estr += ' latitude variable is not 1D or 2D.' + print(estr) + estr = 'latitude variable with dimensions:' + ldim = self.fi[self.latvar[ig]].dimensions + print(estr, self.latvar[ig], ldim) + self.latvar[ig] = '' + else: + self.latdim[ig] = self.fi[self.latvar[ig]].dimensions[0] + if self.lonvar[ig]: + lonshape = self.fi[self.lonvar[ig]].shape + if len(lonshape) == 1: + self.londim[ig] = self.fi[self.lonvar[ig]].dimensions[0] + elif len(lonshape) == 2: + self.londim[ig] = self.fi[self.lonvar[ig]].dimensions[1] + else: + estr = 'Something went wrong determining lat/lon:' + estr += ' longitude variable is not 1D or 2D.' + print(estr) + estr = 'longitude variable with dimensions:' + ldim = self.fi[self.lonvar[ig]].dimensions + print(estr, self.lonvar[ig], ldim) + self.lonvar[ig] = '' + # + # add units to lat/lon name + if self.latvar[ig]: + idim = tuple(zip_dim_name_length(self.fi[self.latvar[ig]])) + self.latvar[ig] = self.latvar[ig] + ' ' + str(idim) + if self.lonvar[ig]: + idim = tuple(zip_dim_name_length(self.fi[self.lonvar[ig]])) + self.lonvar[ig] = self.lonvar[ig] + ' ' + str(idim) # @@ -591,8 +604,8 @@ def set_dim_lat(self): if lat != '': # set real dimensions gl, vl = vardim2var(lat, self.fi.groups.keys()) - if vl == self.tname: - vl = self.tvar + if vl == self.tname[gl]: + vl = self.tvar[gl] ll = self.fi[vl] for i in range(ll.ndim): ww = max(4, int(np.ceil(np.log10(ll.shape[i])))) @@ -643,8 +656,8 @@ def set_dim_lon(self): if lon != '': # set real dimensions gl, vl = vardim2var(lon, self.fi.groups.keys()) - if vl == self.tname: - vl = self.tvar + if vl == self.tname[gl]: + vl = self.tvar[gl] ll = self.fi[vl] for i in range(ll.ndim): ww = max(4, int(np.ceil(np.log10(ll.shape[i])))) @@ -699,13 +712,13 @@ def set_dim_var(self): if v != '': # set real dimensions gz, vz = vardim2var(v, self.fi.groups.keys()) - if vz == self.tname: - vz = self.tvar + if vz == self.tname[gz]: + vz = self.tvar[gz] vv = self.fi[vz] nall = 0 - if self.latdim: - if self.latdim in vv.dimensions: - i = vv.dimensions.index(self.latdim) + if self.latdim[gz]: + if self.latdim[gz] in vv.dimensions: + i = vv.dimensions.index(self.latdim[gz]) ww = max(5, int(np.ceil(np.log10(vv.shape[i])))) # 5~median self.vd[i].config(values=spinbox_values(vv.shape[i]), width=ww, state=tk.NORMAL) @@ -720,9 +733,9 @@ def set_dim_var(self): else: tstr = "Single dimension: 0" self.vdtip[i].set(tstr) - if self.londim: - if self.londim in vv.dimensions: - i = vv.dimensions.index(self.londim) + if self.londim[gz]: + if self.londim[gz] in vv.dimensions: + i = vv.dimensions.index(self.londim[gz]) ww = max(5, int(np.ceil(np.log10(vv.shape[i])))) # 5~median self.vd[i].config(values=spinbox_values(vv.shape[i]), width=ww, state=tk.NORMAL) @@ -741,9 +754,9 @@ def set_dim_var(self): ww = max(5, int(np.ceil(np.log10(vv.shape[i])))) # 5~median self.vd[i].config(values=spinbox_values(vv.shape[i]), width=ww, state=tk.NORMAL) - if ( (vv.dimensions[i] != self.latdim) and - (vv.dimensions[i] != self.londim) and - (vv.dimensions[i] != self.dunlim) and + if ( (vv.dimensions[i] != self.latdim[gz]) and + (vv.dimensions[i] != self.londim[gz]) and + (vv.dimensions[i] != self.dunlim[gz]) and (nall <= 1) and (vv.shape[i] > 1) ): nall += 1 self.vdval[i].set('all') @@ -756,8 +769,8 @@ def set_dim_var(self): else: tstr = "Single dimension: 0" self.vdtip[i].set(tstr) - elif ((vv.dimensions[i] != self.latdim) and - (vv.dimensions[i] != self.londim)): + elif ((vv.dimensions[i] != self.latdim[gz]) and + (vv.dimensions[i] != self.londim[gz])): self.vdval[i].set(0) self.vdlblval[i].set(vv.dimensions[i]) if vv.shape[i] > 1: @@ -804,12 +817,12 @@ def set_dim_x(self): if x != '': # set real dimensions gx, vx = vardim2var(x, self.fi.groups.keys()) - if vx == self.tname: - vx = self.tvar + if vx == self.tname[gx]: + vx = self.tvar[gx] xx = self.fi[vx] nall = 0 - if self.dunlim in xx.dimensions: - i = xx.dimensions.index(self.dunlim) + if self.dunlim[gx] in xx.dimensions: + i = xx.dimensions.index(self.dunlim[gx]) ww = max(5, int(np.ceil(np.log10(xx.shape[i])))) # 5~median self.xd[i].config(values=spinbox_values(xx.shape[i]), width=ww, state=tk.NORMAL) @@ -825,7 +838,7 @@ def set_dim_x(self): tstr = "Single dimension: 0" self.xdtip[i].set(tstr) for i in range(xx.ndim): - if xx.dimensions[i] != self.dunlim: + if xx.dimensions[i] != self.dunlim[gx]: ww = max(5, int(np.ceil(np.log10(xx.shape[i])))) self.xd[i].config(values=spinbox_values(xx.shape[i]), width=ww, state=tk.NORMAL) @@ -879,12 +892,12 @@ def set_dim_y(self): if y != '': # set real dimensions gy, vy = vardim2var(y, self.fi.groups.keys()) - if vy == self.tname: - vy = self.tvar + if vy == self.tname[gy]: + vy = self.tvar[gy] yy = self.fi[vy] nall = 0 - if self.dunlim in yy.dimensions: - i = yy.dimensions.index(self.dunlim) + if self.dunlim[gy] in yy.dimensions: + i = yy.dimensions.index(self.dunlim[gy]) ww = max(5, int(np.ceil(np.log10(yy.shape[i])))) # 5~median self.yd[i].config(values=spinbox_values(yy.shape[i]), width=ww, state=tk.NORMAL) @@ -900,7 +913,7 @@ def set_dim_y(self): tstr = "Single dimension: 0" self.ydtip[i].set(tstr) for i in range(yy.ndim): - if yy.dimensions[i] != self.dunlim: + if yy.dimensions[i] != self.dunlim[gy]: ww = max(5, int(np.ceil(np.log10(yy.shape[i])))) self.yd[i].config(values=spinbox_values(yy.shape[i]), width=ww, state=tk.NORMAL) @@ -954,12 +967,12 @@ def set_dim_y2(self): if y2 != '': # set real dimensions gy2, vy2 = vardim2var(y2, self.fi.groups.keys()) - if vy2 == self.tname: - vy2 = self.tvar + if vy2 == self.tname[gy2]: + vy2 = self.tvar[gy2] yy2 = self.fi[vy2] nall = 0 - if self.dunlim in yy2.dimensions: - i = yy2.dimensions.index(self.dunlim) + if self.dunlim[gy2] in yy2.dimensions: + i = yy2.dimensions.index(self.dunlim[gy2]) ww = max(5, int(np.ceil(np.log10(yy2.shape[i])))) # 5~median self.y2d[i].config(values=spinbox_values(yy2.shape[i]), width=ww, state=tk.NORMAL) @@ -975,7 +988,7 @@ def set_dim_y2(self): tstr = "Single dimension: 0" self.y2dtip[i].set(tstr) for i in range(yy2.ndim): - if yy2.dimensions[i] != self.dunlim: + if yy2.dimensions[i] != self.dunlim[gy2]: ww = max(5, int(np.ceil(np.log10(yy2.shape[i])))) self.y2d[i].config(values=spinbox_values(yy2.shape[i]), width=ww, state=tk.NORMAL) @@ -1030,12 +1043,12 @@ def set_dim_z(self): if z != '': # set real dimensions gz, vz = vardim2var(z, self.fi.groups.keys()) - if vz == self.tname: - vz = self.tvar + if vz == self.tname[gz]: + vz = self.tvar[gz] zz = self.fi[vz] nall = 0 - if self.dunlim in zz.dimensions: - i = zz.dimensions.index(self.dunlim) + if self.dunlim[gz] in zz.dimensions: + i = zz.dimensions.index(self.dunlim[gz]) ww = max(5, int(np.ceil(np.log10(zz.shape[i])))) # 5~median self.zd[i].config(values=spinbox_values(zz.shape[i]), width=ww, state=tk.NORMAL) @@ -1051,7 +1064,7 @@ def set_dim_z(self): tstr = "Single dimension: 0" self.zdtip[i].set(tstr) for i in range(zz.ndim): - if zz.dimensions[i] != self.dunlim: + if zz.dimensions[i] != self.dunlim[gz]: ww = max(5, int(np.ceil(np.log10(zz.shape[i])))) self.zd[i].config(values=spinbox_values(zz.shape[i]), width=ww, state=tk.NORMAL) diff --git a/src/ncvue/ncvscatter.py b/src/ncvue/ncvscatter.py index 5a190e8..eb9a617 100644 --- a/src/ncvue/ncvscatter.py +++ b/src/ncvue/ncvscatter.py @@ -27,6 +27,7 @@ * Write left-hand side and right-hand side values on bottom of plotting canvas, May 2021, Matthias Cuntz * Address fi.variables[name] directly by fi[name], Jan 2024, Matthias Cuntz + * Allow groups in netcdf files, Jan 2024, Matthias Cuntz """ from __future__ import absolute_import, division, print_function @@ -488,17 +489,17 @@ def newnetcdf(self): if self.top.fi: self.top.fi.close() # reset empty defaults of top - self.top.dunlim = '' # name of unlimited dimension - self.top.time = None # datetime variable - self.top.tname = '' # datetime variable name - self.top.tvar = '' # datetime variable name in netcdf - self.top.dtime = None # decimal year - self.top.latvar = '' # name of latitude variable - self.top.lonvar = '' # name of longitude variable - self.top.latdim = '' # name of latitude dimension - self.top.londim = '' # name of longitude dimension - self.top.maxdim = 0 # maximum num of dims of all variables - self.top.cols = [] # variable list + self.top.dunlim = [] # name of unlimited dimension + self.top.time = [] # datetime variable + self.top.tname = [] # datetime variable name + self.top.tvar = [] # datetime variable name in netcdf + self.top.dtime = [] # decimal year + self.top.latvar = [] # name of latitude variable + self.top.lonvar = [] # name of longitude variable + self.top.latdim = [] # name of latitude dimension + self.top.londim = [] # name of longitude dimension + self.top.maxdim = 0 # maximum num of dims of all variables + self.top.cols = [] # variable list # open new netcdf file self.top.fi = nc.Dataset(ncfile, 'r') analyse_netcdf(self.top) @@ -735,7 +736,7 @@ def redraw_y(self): 'markeredgecolor': mec, 'markeredgewidth': mew} gy, vy = vardim2var(y, self.fi.groups.keys()) - if vy == self.tname: + if vy == self.tname[gy]: ylab = 'Date' pargs['color'] = c else: @@ -839,7 +840,7 @@ def redraw_y2(self): 'markeredgecolor': mec, 'markeredgewidth': mew} gy, vy = vardim2var(y2, self.fi.groups.keys()) - if vy == self.tname: + if vy == self.tname[gy]: ylab = 'Date' pargs['color'] = c else: @@ -926,8 +927,8 @@ def redraw(self, event=None): # y axis if y != '': gy, vy = vardim2var(y, self.fi.groups.keys()) - if vy == self.tname: - yy = self.time + if vy == self.tname[gy]: + yy = self.time[gy] ylab = 'Date' else: yy = self.fi[vy] @@ -936,8 +937,8 @@ def redraw(self, event=None): # y2 axis if y2 != '': gy2, vy2 = vardim2var(y2, self.fi.groups.keys()) - if vy2 == self.tname: - yy2 = self.time + if vy2 == self.tname[gy2]: + yy2 = self.time[gy2] ylab2 = 'Date' else: yy2 = self.fi[vy2] @@ -946,8 +947,8 @@ def redraw(self, event=None): if (x != ''): # x axis gx, vx = vardim2var(x, self.fi.groups.keys()) - if vx == self.tname: - xx = self.time + if vx == self.tname[gx]: + xx = self.time[gx] xlab = 'Date' else: xx = self.fi[vx] diff --git a/src/ncvue/ncvue.py b/src/ncvue/ncvue.py index 2de2cb6..29420f4 100644 --- a/src/ncvue/ncvue.py +++ b/src/ncvue/ncvue.py @@ -27,6 +27,7 @@ jupyter, May 2021, Matthias Cuntz * Different themes for different OS, May 2021, Matthias Cuntz * Font size 13 on Windows for plots, Jun 2021, Matthias Cuntz + * Allow groups in netcdf files, Jan 2024, Matthias Cuntz """ import sys @@ -160,15 +161,15 @@ def ncvue(ncfile='', miss=np.nan): top.icon = icon # app icon top.fi = ncfile # file name or file handle top.miss = miss # extra missing value - top.dunlim = '' # name of unlimited dimension - top.time = None # datetime variable - top.tname = '' # datetime variable name - top.tvar = '' # datetime variable name in netcdf file - top.dtime = None # decimal year - top.latvar = '' # name of latitude variable - top.lonvar = '' # name of longitude variable - top.latdim = '' # name of latitude dimension - top.londim = '' # name of longitude dimension + top.dunlim = [] # name of unlimited dimension + top.time = [] # datetime variable + top.tname = [] # datetime variable name + top.tvar = [] # datetime variable name in netcdf file + top.dtime = [] # decimal year + top.latvar = [] # name of latitude variable + top.lonvar = [] # name of longitude variable + top.latdim = [] # name of latitude dimension + top.londim = [] # name of longitude dimension top.maxdim = 1 # maximum number of dimensions of all variables # > 0 so that dimension spinboxes present top.cols = [] # variable list diff --git a/src/ncvue/ncvwidgets.py b/src/ncvue/ncvwidgets.py index 4e64fad..66171a2 100644 --- a/src/ncvue/ncvwidgets.py +++ b/src/ncvue/ncvwidgets.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -Widget functions for ncvue. +Widget functions. Convenience functions for adding Tkinter widgets. @@ -8,7 +8,7 @@ Recherche pour l'Agriculture, l'Alimentation et l'Environnement (INRAE), Nancy, France. -:copyright: Copyright 2020-2021 Matthias Cuntz - mc (at) macu (dot) de +:copyright: Copyright 2020-2023 Matthias Cuntz - mc (at) macu (dot) de :license: MIT License, see LICENSE for details. .. moduleauthor:: Matthias Cuntz @@ -16,6 +16,7 @@ The following functions are provided: .. autosummary:: + callurl Tooltip add_checkbutton add_combobox @@ -25,6 +26,7 @@ add_scale add_spinbox add_tooltip + Treeview History * Written Nov-Dec 2020 by Matthias Cuntz (mc (at) macu (dot) de) @@ -32,31 +34,71 @@ Jan 2021, Matthias Cuntz * Added add_tooltip widget, Jan 2021, Matthias Cuntz * add_spinbox returns also label widget, Jan 2021, Matthias Cuntz + * padlabel for add_entry to add space to previous widget, + Jul 2023, Matthias Cuntz + * labelwidth for add_entry to align columns with pack, + Jul 2023, Matthias Cuntz + * Replace tk constants with strings such as tk.LEFT with 'left', + Jul 2023, Matthias Cuntz + * Use Hovertip from local copy of tooltip.py, Jul 2023, Matthias Cuntz + * Added Treeview class with optional horizontal and vertical scroolbars, + Jul 2023, Matthias Cuntz + * Added callurl function, Dec 2023, Matthias Cuntz """ -from __future__ import absolute_import, division, print_function import tkinter as tk -try: - import tkinter.ttk as ttk -except Exception: - import sys - print('Using the themed widget set introduced in Tk 8.5.') - print('Try to use mcview.py, which uses wxpython instead.') - sys.exit() -from idlelib.tooltip import Hovertip +import tkinter.ttk as ttk +import webbrowser +from .tooltip import Hovertip -__all__ = ['Tooltip', +__all__ = ['callurl', 'Tooltip', 'add_checkbutton', 'add_combobox', 'add_entry', 'add_imagemenu', 'add_menu', 'add_scale', 'add_spinbox', - 'add_tooltip'] + 'add_tooltip', + 'Treeview'] + + +# https://stackoverflow.com/questions/23482748/how-to-create-a-hyperlink-with-a-label-in-tkinter +def callurl(url): + """ + Open url in external web browser + + Parameters + ---------- + url : str + html url + + Returns + ------- + Opens *url* in external web browser + + Examples + -------- + >>> opthead = ttk.Frame(self) + >>> opthead.pack(side='top', fill='x') + >>> optheadlabel1 = ttk.Label(opthead, text='Options for') + >>> optheadlabel1.pack(side='left') + >>> ttk.Style().configure('blue.TLabel', foreground='blue') + >>> optheadlabel2 = ttk.Label(opthead, text='pandas.read_csv', + ... style='blue.TLabel') + >>> optheadlabel2.pack(side='left') + >>> font = tkfont.Font(optheadlabel2, optheadlabel2.cget("font")) + >>> font.configure(underline=True) + >>> optheadlabel2.configure(font=font) + >>> optheadlabel2.bind("", + ... lambda e: callurl("https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html")) + + """ + webbrowser.open_new(url) class Tooltip(Hovertip): """ A tooltip that pops up when a mouse hovers over an anchor widget. - This is a copy of the class Hovertip of Python's idlelib/tooltip.py. + This is a copy/extension of the class Hovertip of Python's + idlelib/tooltip.py. In addition, it sets the foreground colour to see the tip also in macOS dark mode, and displays a textvariable rather than simple text so one can change the tip during run time. @@ -80,7 +122,7 @@ def showcontents(self): # light yellow = #ffffe0 label = tk.Label(self.tipwindow, textvariable=self.text, background="#ffffe0", foreground="#000000", - justify=tk.LEFT, relief=tk.FLAT, borderwidth=0, + justify='left', relief='flat', borderwidth=0, padx=1, pady=1) label.pack() @@ -118,7 +160,7 @@ def add_checkbutton(frame, label="", value=False, command=None, tooltip="", Examples -------- >>> self.rowzxy = ttk.Frame(self) - >>> self.rowzxy.pack(side=tk.TOP, fill=tk.X) + >>> self.rowzxy.pack(side='top', fill='x') >>> self.inv_xlbl, self.inv_x = add_checkbutton( ... self.rowzxy, label="invert x", value=False, command=self.checked) @@ -128,7 +170,7 @@ def add_checkbutton(frame, label="", value=False, command=None, tooltip="", bvar = tk.BooleanVar(value=value) cb = ttk.Checkbutton(frame, variable=bvar, textvariable=check_label, command=command, **kwargs) - cb.pack(side=tk.LEFT, padx=3) + cb.pack(side='left', padx=3) if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -170,7 +212,7 @@ def add_combobox(frame, label="", values=[], command=None, tooltip="", Examples -------- >>> self.rowzxy = ttk.Frame(self) - >>> self.rowzxy.pack(side=tk.TOP, fill=tk.X) + >>> self.rowzxy.pack(side='top', fill='x') >>> self.xlbl, self.x = add_combobox( ... self.rowzxy, label="x", values=columns, command=self.selected) @@ -179,13 +221,13 @@ def add_combobox(frame, label="", values=[], command=None, tooltip="", cb_label = tk.StringVar() cb_label.set(label) label = ttk.Label(frame, textvariable=cb_label) - label.pack(side=tk.LEFT) + label.pack(side='left') cb = ttk.Combobox(frame, values=values, width=width, **kwargs) # long = len(max(values, key=len)) # cb.configure(width=(max(20, long//2))) if command is not None: cb.bind("<>", command) - cb.pack(side=tk.LEFT) + cb.pack(side='left') if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -195,7 +237,8 @@ def add_combobox(frame, label="", values=[], command=None, tooltip="", return cb_label, cb -def add_entry(frame, label="", text="", command=None, tooltip="", **kwargs): +def add_entry(frame, label="", text="", command=None, tooltip="", + padlabel=0, labelwidth=None, **kwargs): """ Add a left-aligned ttk.Entry with a ttk.Label before. @@ -207,12 +250,18 @@ def add_entry(frame, label="", text="", command=None, tooltip="", **kwargs): Text that appears in front of the entry (default: "") text : str, optional Initial text in the entry area (default: "") - command : function, optional + command : function or list of functions, optional Handler function to be bound to the entry for the events - , '', , and '' (default: None). + '', , '', and (default: None). + If list is given than command[0] is bound to '' and + command[1] is bound to the 3 events tooltip : str, optional Tooltip appearing after one second when hovering over the entry (default: "" = no tooltip) + padlabel : int, optional + Prepend number of spaces to create distance other widgets (default: 0) + labelwidth : int, optional + If given, set width of Label **kwargs : option=value pairs, optional All other options will be passed to ttk.Entry @@ -227,25 +276,49 @@ def add_entry(frame, label="", text="", command=None, tooltip="", **kwargs): Examples -------- >>> self.rowxyopt = ttk.Frame(self) - >>> self.rowxyopt.pack(side=tk.TOP, fill=tk.X) + >>> self.rowxyopt.pack(side='top', fill='x') >>> self.lslbl, self.ls = add_entry( ... self.rowxyopt, label="ls", text='-', ... width=4, command=self.selected_y) """ entry_label = tk.StringVar() - entry_label.set(label) - label = ttk.Label(frame, textvariable=entry_label) - label.pack(side=tk.LEFT) + nlab = len(label) + padlabel + lab = f'{label:>{nlab}s}' + entry_label.set(lab) + lkwargs = {'textvariable': entry_label} + if labelwidth is not None: + lkwargs.update({'width': labelwidth}) + label = ttk.Label(frame, **lkwargs) + # print(label.configure()) + label.pack(side='left') entry_text = tk.StringVar() - entry_text.set(text) + if text is None: + tt = 'None' + elif isinstance(text, bool): + if text: + tt = 'True' + else: + tt = 'False' + else: + tt = str(text) + entry_text.set(tt) entry = ttk.Entry(frame, textvariable=entry_text, **kwargs) if command is not None: - entry.bind('', command) # return - entry.bind('', command) # return - entry.bind('', command) # return of numeric keypad - entry.bind('', command) # tab or click - entry.pack(side=tk.LEFT) + if isinstance(command, (list, tuple)): + com0 = command[0] + if len(command) > 1: + com1 = command[1] + else: + com1 = command[0] + else: + com0 = command + com1 = command + entry.bind('', com0) # tab or click + entry.bind('', com1) # return + entry.bind('', com1) # return + entry.bind('', com1) # return of numeric keypad + entry.pack(side='left') if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -290,7 +363,7 @@ def add_imagemenu(frame, label="", values=[], images=[], command=None, Examples -------- >>> self.rowcmap = ttk.Frame(self) - >>> self.rowcmap.pack(side=tk.TOP, fill=tk.X) + >>> self.rowcmap.pack(side='top', fill='x') >>> self.cmaplbl, self.cmap = add_imagemenu( ... self.rowcmap, label="cmap", values=self.cmaps, ... images=self.imaps, command=self.selected_cmap) @@ -306,15 +379,15 @@ def add_imagemenu(frame, label="", values=[], images=[], command=None, mb_label = tk.StringVar() mb_label.set(label) label = ttk.Label(frame, textvariable=mb_label) - label.pack(side=tk.LEFT) + label.pack(side='left') mb = ttk.Menubutton(frame, image=images[0], text=values[0], - compound=tk.LEFT) + compound='left') sb = tk.Menu(mb, tearoff=False) mb.config(menu=sb) for i, v in enumerate(values): - sb.add_command(label=v, image=images[i], compound=tk.LEFT, + sb.add_command(label=v, image=images[i], compound='left', command=partial(command, v)) - mb.pack(side=tk.LEFT) + mb.pack(side='left') if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -354,7 +427,7 @@ def add_menu(frame, label="", values=[], command=None, tooltip="", **kwargs): Examples -------- >>> self.rowzxy = ttk.Frame(self) - >>> self.rowzxy.pack(side=tk.TOP, fill=tk.X) + >>> self.rowzxy.pack(side='top', fill='x') >>> self.xlbl, self.x = add_combobox( ... self.rowzxy, label="x", values=columns, command=self.selected) @@ -363,14 +436,14 @@ def add_menu(frame, label="", values=[], command=None, tooltip="", **kwargs): mb_label = tk.StringVar() mb_label.set(label) label = ttk.Label(frame, textvariable=mb_label) - label.pack(side=tk.LEFT) - mb = ttk.Menubutton(frame, text=values[0], compound=tk.LEFT) + label.pack(side='left') + mb = ttk.Menubutton(frame, text=values[0], compound='left') sb = tk.Menu(mb, tearoff=False) mb.config(menu=sb) for i, v in enumerate(values): - sb.add_command(label=v, compound=tk.LEFT, + sb.add_command(label=v, compound='left', command=partial(command, v)) - mb.pack(side=tk.LEFT) + mb.pack(side='left') if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -410,7 +483,7 @@ def add_scale(frame, label="", ini=0, tooltip="", **kwargs): Examples -------- >>> self.rowzxy = ttk.Frame(self) - >>> self.rowzxy.pack(side=tk.TOP, fill=tk.X) + >>> self.rowzxy.pack(side='top', fill='x') >>> self.xlbl, self.x = add_scale( ... self.rowzxy, label="x", values=columns, command=self.selected) @@ -418,11 +491,11 @@ def add_scale(frame, label="", ini=0, tooltip="", **kwargs): s_label = tk.StringVar() s_label.set(label) label = ttk.Label(frame, textvariable=s_label) - label.pack(side=tk.LEFT) + label.pack(side='left') s_val = tk.DoubleVar() s_val.set(ini) s = ttk.Scale(frame, variable=s_val, **kwargs) - s.pack(side=tk.LEFT) + s.pack(side='left') if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -467,7 +540,7 @@ def add_spinbox(frame, label="", values=[], command=None, tooltip="", Examples -------- >>> self.rowlev = ttk.Frame(self) - >>> self.rowlev.pack(side=tk.TOP, fill=tk.X) + >>> self.rowlev.pack(side='top', fill='x') >>> self.dlval, self.dl, self.dval, self.d = add_spinbox( ... self.rowlev, label="dim", values=range(0,10), ... command=self.spinned) @@ -477,7 +550,7 @@ def add_spinbox(frame, label="", values=[], command=None, tooltip="", sbl_val = tk.StringVar() sbl_val.set(label) sbl = ttk.Label(frame, textvariable=sbl_val) - sbl.pack(side=tk.LEFT) + sbl.pack(side='left') sb_val = tk.StringVar() if len(values) > 0: sb_val.set(str(values[0])) @@ -488,7 +561,7 @@ def add_spinbox(frame, label="", values=[], command=None, tooltip="", sb.bind('', command) # return sb.bind('', command) # return of numeric keypad sb.bind('', command) # tab or click - sb.pack(side=tk.LEFT) + sb.pack(side='left') if tooltip: ttip = tk.StringVar() ttip.set(tooltip) @@ -520,7 +593,7 @@ def add_tooltip(frame, tooltip="", **kwargs): Examples -------- >>> self.rowlev = ttk.Frame(self) - >>> self.rowlev.pack(side=tk.TOP, fill=tk.X) + >>> self.rowlev.pack(side='top', fill='x') >>> self.dlbl, self.dval, self.d = add_spinbox( ... self.rowlev, label="dim", values=range(0,10), ... command=self.spinned) @@ -531,3 +604,56 @@ def add_tooltip(frame, tooltip="", **kwargs): ttip.set(tooltip) htip = Tooltip(frame, ttip) return ttip + + +# https://pythonassets.com/posts/scrollbar-in-tk-tkinter/ +class Treeview(ttk.Frame): + """ + Treeview class with optional horizontal and vertical scrollbars + + Examples + -------- + Simple ttk.Treeview widget + + .. code-block:: python + + tree = Treeview(frame_widget) + + ttk.Treeview widget with vertical scrollbar + + .. code-block:: python + + tree = Treeview(frame_widget, yscroll=True) + + ttk.Treeview widget with vertical and horizontal scrollbars + + .. code-block:: python + + tree = Treeview(frame_widget, xscroll=True, yscroll=True) + + """ + def __init__(self, *args, xscroll=False, yscroll=False, **kwargs): + super().__init__(*args, **kwargs) + # scrollbars + if xscroll: + self.hscrollbar = ttk.Scrollbar(self, orient='horizontal') + if yscroll: + self.vscrollbar = ttk.Scrollbar(self, orient='vertical') + # treeview + self.tv = ttk.Treeview(self) + # pack scrollbars and treeview together + if xscroll: + self.tv.config(xscrollcommand=self.hscrollbar.set) + self.hscrollbar.config(command=self.tv.xview) + self.hscrollbar.pack(side='bottom', fill='x') + if yscroll: + self.tv.config(yscrollcommand=self.vscrollbar.set) + self.vscrollbar.config(command=self.tv.yview) + self.vscrollbar.pack(side='right', fill='y') + self.tv.pack() + # convenience functions + self.tag_configure = self.tv.tag_configure + self.config = self.tv.config + self.column = self.tv.column + self.heading = self.tv.heading + self.insert = self.tv.insert diff --git a/src/ncvue/tooltip.py b/src/ncvue/tooltip.py new file mode 100644 index 0000000..ec9b00b --- /dev/null +++ b/src/ncvue/tooltip.py @@ -0,0 +1,192 @@ +""" +Tools for displaying tool-tips. + +This includes: + * an abstract base-class for different kinds of tooltips + * a simple text-only Tooltip class + +This is a copy (2023-07-17) of python3.11/idlelib/tooltip.py +because IDLE is not installed on all systems by default. +Commented tests using idlelib.idle_test.htest. + +""" +from tkinter import * + + +class TooltipBase: + """abstract base class for tooltips""" + + def __init__(self, anchor_widget): + """Create a tooltip. + + anchor_widget: the widget next to which the tooltip will be shown + + Note that a widget will only be shown when showtip() is called. + """ + self.anchor_widget = anchor_widget + self.tipwindow = None + + def __del__(self): + self.hidetip() + + def showtip(self): + """display the tooltip""" + if self.tipwindow: + return + self.tipwindow = tw = Toplevel(self.anchor_widget) + # show no border on the top level window + tw.wm_overrideredirect(1) + try: + # This command is only needed and available on Tk >= 8.4.0 for OSX. + # Without it, call tips intrude on the typing process by grabbing + # the focus. + tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, + "help", "noActivates") + except TclError: + pass + + self.position_window() + self.showcontents() + self.tipwindow.update_idletasks() # Needed on MacOS -- see #34275. + self.tipwindow.lift() # work around bug in Tk 8.5.18+ (issue #24570) + + def position_window(self): + """(re)-set the tooltip's screen position""" + x, y = self.get_position() + root_x = self.anchor_widget.winfo_rootx() + x + root_y = self.anchor_widget.winfo_rooty() + y + self.tipwindow.wm_geometry("+%d+%d" % (root_x, root_y)) + + def get_position(self): + """choose a screen position for the tooltip""" + # The tip window must be completely outside the anchor widget; + # otherwise when the mouse enters the tip window we get + # a leave event and it disappears, and then we get an enter + # event and it reappears, and so on forever :-( + # + # Note: This is a simplistic implementation; sub-classes will likely + # want to override this. + return 20, self.anchor_widget.winfo_height() + 1 + + def showcontents(self): + """content display hook for sub-classes""" + # See ToolTip for an example + raise NotImplementedError + + def hidetip(self): + """hide the tooltip""" + # Note: This is called by __del__, so careful when overriding/extending + tw = self.tipwindow + self.tipwindow = None + if tw: + try: + tw.destroy() + except TclError: # pragma: no cover + pass + + +class OnHoverTooltipBase(TooltipBase): + """abstract base class for tooltips, with delayed on-hover display""" + + def __init__(self, anchor_widget, hover_delay=1000): + """Create a tooltip with a mouse hover delay. + + anchor_widget: the widget next to which the tooltip will be shown + hover_delay: time to delay before showing the tooltip, in milliseconds + + Note that a widget will only be shown when showtip() is called, + e.g. after hovering over the anchor widget with the mouse for enough + time. + """ + super().__init__(anchor_widget) + self.hover_delay = hover_delay + + self._after_id = None + self._id1 = self.anchor_widget.bind("", self._show_event) + self._id2 = self.anchor_widget.bind("", self._hide_event) + self._id3 = self.anchor_widget.bind("