From c6bab145f9f92ec5d2b2ae7cc4441cfbd9b3b42e Mon Sep 17 00:00:00 2001 From: "benjamin.jakimow" <benjamin.jakimow@geo.hu-berlin.de> Date: Fri, 24 May 2019 12:07:27 +0200 Subject: [PATCH] TimeSeriesSource: added .timeSeriesDatum(), .setTimeSeriesDatum() TimeSeriesDatum: implements QAbstractTableModel, added .removeSource(...) TimeSeriesDock shows datasources in a TreeView (better grouping) TimeSeriesTreeView highlight TimeSeriesDates of visible MapCanvases Signed-off-by: benjamin.jakimow <benjamin.jakimow@geo.hu-berlin.de> --- eotimeseriesviewer/main.py | 18 +- eotimeseriesviewer/mapviewscrollarea.py | 1 + eotimeseriesviewer/mapvisualization.py | 23 +- eotimeseriesviewer/timeseries.py | 766 +++++++++++++++++----- eotimeseriesviewer/ui/timeseriesdock.ui | 64 +- eotimeseriesviewer/ui/timeseriesviewer.ui | 2 +- tests/test_timeseries.py | 56 +- 7 files changed, 717 insertions(+), 213 deletions(-) diff --git a/eotimeseriesviewer/main.py b/eotimeseriesviewer/main.py index 0b95af00..08328594 100644 --- a/eotimeseriesviewer/main.py +++ b/eotimeseriesviewer/main.py @@ -120,6 +120,8 @@ class TimeSeriesViewerUI(QMainWindow, # self.dockMapViews = addDockWidget(MapViewDockUI(self)) self.dockTimeSeries = addDockWidget(TimeSeriesDockUI(self)) + self.dockTimeSeries.initActions(self) + from eotimeseriesviewer.profilevisualization import ProfileViewDockUI self.dockProfiles = addDockWidget(ProfileViewDockUI(self)) from eotimeseriesviewer.labeling import LabelingDock @@ -292,13 +294,14 @@ class TimeSeriesViewer(QgisInterface, QObject): self.ui.dockMapViews.sigMapCanvasColorChanged.connect(self.spatialTemporalVis.setMapBackgroundColor) self.spatialTemporalVis.sigCRSChanged.connect(self.ui.dockMapViews.setCrs) self.spatialTemporalVis.sigMapSizeChanged.connect(self.ui.dockMapViews.setMapSize) + self.spatialTemporalVis.sigVisibleDatesChanged.connect(self.timeSeries().setCurrentDates) self.spectralTemporalVis.sigMoveToTSD.connect(self.showTimeSeriesDatum) self.spectralTemporalVis.ui.actionLoadProfileRequest.triggered.connect(self.ui.actionIdentifyTemporalProfile.trigger) - tstv = self.ui.dockTimeSeries.tableView_TimeSeries - assert isinstance(tstv, TimeSeriesTableView) + tstv = self.ui.dockTimeSeries.timeSeriesTreeView + assert isinstance(tstv, TimeSeriesTreeView) tstv.sigMoveToDateRequest.connect(self.showTimeSeriesDatum) # init map tools @@ -1020,17 +1023,10 @@ class TimeSeriesViewer(QgisInterface, QObject): self.mTimeSeries.addSources(files) def clearTimeSeries(self): - # remove views - M = self.ui.dockTimeSeries.tableView_TimeSeries.model() - M.beginResetModel() + self.mTimeSeries.beginResetModel() self.mTimeSeries.clear() - M.endResetModel() - - def getSelectedTSDs(self): - TV = self.ui.tableView_TimeSeries - TVM = TV.model() - return [TVM.getTimeSeriesDatumFromIndex(idx) for idx in TV.selectionModel().selectedRows()] + self.mTimeSeries.endResetModel() def disconnect_signal(signal): diff --git a/eotimeseriesviewer/mapviewscrollarea.py b/eotimeseriesviewer/mapviewscrollarea.py index 2685d36d..6044643f 100644 --- a/eotimeseriesviewer/mapviewscrollarea.py +++ b/eotimeseriesviewer/mapviewscrollarea.py @@ -26,6 +26,7 @@ from qgis.PyQt.QtWidgets import * class MapViewScrollArea(QScrollArea): sigResized = pyqtSignal() + def __init__(self, *args, **kwds): super(MapViewScrollArea, self).__init__(*args, **kwds) diff --git a/eotimeseriesviewer/mapvisualization.py b/eotimeseriesviewer/mapvisualization.py index 94c59272..a299c88c 100644 --- a/eotimeseriesviewer/mapvisualization.py +++ b/eotimeseriesviewer/mapvisualization.py @@ -1495,6 +1495,8 @@ class SpatialTemporalVisualization(QObject): sigMapViewAdded = pyqtSignal(MapView) sigMapViewRemoved = pyqtSignal(MapView) + sigVisibleDatesChanged = pyqtSignal(list) + def __init__(self, timeSeriesViewer): super(SpatialTemporalVisualization, self).__init__() # assert isinstance(timeSeriesViewer, TimeSeriesViewer), timeSeriesViewer @@ -1507,12 +1509,15 @@ class SpatialTemporalVisualization(QObject): self.mMapCanvases = [] self.ui = timeSeriesViewer.ui + self.mVisibleDates = set() # map-tool handling self.mMapTools = [] self.scrollArea = self.ui.scrollAreaSubsets assert isinstance(self.scrollArea, MapViewScrollArea) - + self.scrollArea.horizontalScrollBar().valueChanged.connect(self.onVisibleMapsChanged) + self.scrollArea.horizontalScrollBar().rangeChanged.connect(self.onVisibleMapsChanged) + # self.scrollArea.sigResized.connect(self.onVisibleMapsChanged) # self.scrollArea.sigResized.connect(self.refresh()) # self.scrollArea.horizontalScrollBar().valueChanged.connect(self.mRefreshTimer.start) @@ -1585,6 +1590,20 @@ class SpatialTemporalVisualization(QObject): else: pass + def visibleMaps(self)->list: + """ + Returns a list of mapcanvas visible to the user + :return: [list-of-MapCanvases + """ + return [m for m in self.mapCanvases() if m.isVisibleToViewport()] + + def onVisibleMapsChanged(self, *args): + + visibleDates = set([m.tsd() for m in self.visibleMaps()]) + if visibleDates != self.mVisibleDates: + self.mVisibleDates.clear() + self.mVisibleDates.update(visibleDates) + self.sigVisibleDatesChanged.emit(list(self.mVisibleDates)) def timedCanvasRefresh(self, *args, force:bool=False): @@ -1593,7 +1612,7 @@ class SpatialTemporalVisualization(QObject): # do refresh maps assert isinstance(self.scrollArea, MapViewScrollArea) - visibleMaps = [m for m in self.mapCanvases() if m.isVisibleToViewport()] + visibleMaps = self.visibleMaps() hiddenMaps = sorted([m for m in self.mapCanvases() if not m.isVisibleToViewport()], key = lambda c : self.scrollArea.distanceToCenter(c) ) diff --git a/eotimeseriesviewer/timeseries.py b/eotimeseriesviewer/timeseries.py index 047e4d9a..20bae897 100644 --- a/eotimeseriesviewer/timeseries.py +++ b/eotimeseriesviewer/timeseries.py @@ -20,7 +20,7 @@ """ # noinspection PyPep8Naming -import sys, re, collections, traceback, time, json, urllib, types, enum +import sys, re, collections, traceback, time, json, urllib, types, enum, typing import bisect @@ -392,6 +392,7 @@ class TimeSeriesSource(object): self.mUL = QgsPointXY(*px2geo(QPoint(0, 0), self.mGeoTransform, pxCenter=False)) self.mLR = QgsPointXY(*px2geo(QPoint(self.ns + 1, self.nl + 1), self.mGeoTransform, pxCenter=False)) + self.mTimeSeriesDatum = None def name(self)->str: """ @@ -424,6 +425,20 @@ class TimeSeriesSource(object): """ return self.mSid + def timeSeriesDatum(self): + """ + Returns the parent TimeSeriesDatum (if set) + :return: TimeSeriesDatum + """ + return self.mTimeSeriesDatum + + def setTimeSeriesDatum(self, tsd): + """ + Sets the parent TimeSeriesDatum + :param tsd: TimeSeriesDatum + """ + self.mTimeSeriesDatum = tsd + def date(self)->np.datetime64: return self.mDate @@ -439,14 +454,24 @@ class TimeSeriesSource(object): return self.mUri == other.mUri -class TimeSeriesDatum(QObject): +class TimeSeriesDatum(QAbstractTableModel): """ A containe to store all image source related to a single observation date and sensor. """ sigVisibilityChanged = pyqtSignal(bool) + sigSourcesAdded = pyqtSignal(list) + sigSourcesRemoved = pyqtSignal(list) sigRemoveMe = pyqtSignal() - sigSourcesChanged = pyqtSignal() - + + + cnUri = 'Source' + cnNS = 'ns' + cnNB = 'nb' + cnNL = 'nl' + cnCRS = 'crs' + + ColumnNames = [cnNB, cnNL, cnNS, cnCRS, cnUri] + def __init__(self, timeSeries, date:np.datetime64, sensor:SensorInstrument): """ Constructor @@ -454,9 +479,11 @@ class TimeSeriesDatum(QObject): :param date: np.datetime64, :param sensor: SensorInstrument """ - super(TimeSeriesDatum,self).__init__() + super(TimeSeriesDatum, self).__init__() + assert isinstance(date, np.datetime64) assert isinstance(sensor, SensorInstrument) + self.mSensor = sensor self.mDate = date self.mDOY = DOYfromDatetime64(self.mDate) @@ -464,7 +491,16 @@ class TimeSeriesDatum(QObject): self.mMasks = [] self.mVisibility = True self.mTimeSeries = timeSeries - + + def removeSource(self, source:TimeSeriesSource): + + if source in self.mSources: + i = self.mSources.index(source) + self.beginRemoveRows(QModelIndex(), i, i) + self.mSources.remove(source) + self.endRemoveRows() + self.sigSourcesRemoved.emit([source]) + def addSource(self, source): """ Adds an time series source to this TimeSeriesDatum @@ -478,9 +514,15 @@ class TimeSeriesDatum(QObject): assert isinstance(source, TimeSeriesSource) assert self.mDate == source.date() assert self.mSensor.id() == source.sid() + + source.setTimeSeriesDatum(self) + if source not in self.mSources: + i = len(self) + self.beginInsertRows(QModelIndex(), i, i) self.mSources.append(source) - self.sigSourcesChanged.emit() + self.endInsertRows() + self.sigSourcesAdded.emit([source]) return source else: return None @@ -631,7 +673,52 @@ class TimeSeriesDatum(QObject): return False else: return self.sensor().id() < other.sensor().id() + + def rowCount(self, parent: QModelIndex = QModelIndex()): + + return len(self) + + def columnCount(self, parent: QModelIndex): + return len(TimeSeriesDatum.ColumnNames) + + def flags(self, index: QModelIndex): + return Qt.ItemIsEnabled | Qt.ItemIsSelectable + + def headerData(self, section, orientation, role): + assert isinstance(section, int) + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return TimeSeriesDatum.ColumnNames[section] + else: + return None + + def data(self, index: QModelIndex, role: int ): + + if not index.isValid(): + return None + + tss = self.mSources[index.row()] + assert isinstance(tss, TimeSeriesSource) + + cn = TimeSeriesDatum.ColumnNames[index.column()] + if role == Qt.UserRole: + return tss + + if role == Qt.DisplayRole: + if cn == TimeSeriesDatum.cnNB: + return tss.nb + if cn == TimeSeriesDatum.cnNS: + return tss.ns + if cn == TimeSeriesDatum.cnNL: + return tss.nl + if cn == TimeSeriesDatum.cnCRS: + return tss.crs().description() + if cn == TimeSeriesDatum.cnUri: + return tss.uri() + + return None + + def id(self)->tuple: """ :return: tuple @@ -654,23 +741,24 @@ class TimeSeriesDatum(QObject): return hash(self.id()) -class TimeSeriesTableView(QTableView): +class TimeSeriesTreeView(QTreeView): sigMoveToDateRequest = pyqtSignal(TimeSeriesDatum) def __init__(self, parent=None): - super(TimeSeriesTableView, self).__init__(parent) + super(TimeSeriesTreeView, self).__init__(parent) - def contextMenuEvent(self, event): + + + def contextMenuEvent(self, event: QContextMenuEvent): """ - Creates and shows an QMenu - :param event: + Creates and shows the QMenu + :param event: QContextMenuEvent """ idx = self.indexAt(event.pos()) tsd = self.model().data(idx, role=Qt.UserRole) - menu = QMenu(self) a = menu.addAction('Copy value(s)') a.triggered.connect(lambda: self.onCopyValues()) @@ -692,7 +780,7 @@ class TimeSeriesTableView(QTableView): indices = self.selectionModel().selectedIndexes() rows = sorted(list(set([i.row() for i in indices]))) model = self.model() - if isinstance(model, TimeSeriesTableModel): + if isinstance(model, QSortFilterProxyModel): for r in rows: idx = model.index(r, 0) model.setData(idx, checkState, Qt.CheckStateRole) @@ -718,113 +806,69 @@ class TimeSeriesTableView(QTableView): s = "" +class TimeSeriesTableView(QTableView): + sigMoveToDateRequest = pyqtSignal(TimeSeriesDatum) -class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')): - """ - QgsDockWidget that shows the TimeSeries - """ def __init__(self, parent=None): - super(TimeSeriesDockUI, self).__init__(parent) - self.setupUi(self) - self.btnAddTSD.setDefaultAction(parent.actionAddTSD) - self.btnRemoveTSD.setDefaultAction(parent.actionRemoveTSD) - self.btnLoadTS.setDefaultAction(parent.actionLoadTS) - self.btnSaveTS.setDefaultAction(parent.actionSaveTS) - self.btnClearTS.setDefaultAction(parent.actionClearTS) - - self.progressBar.setMinimum(0) - self.setProgressInfo(0, 100, 'Add images to fill time series') - self.progressBar.setValue(0) - self.progressInfo.setText(None) - self.frameFilters.setVisible(False) - - self.setTimeSeries(None) - - def showTSD(self, tsd:TimeSeriesDatum): - assert isinstance(self.tableView_TimeSeries, QTableView) - model = self.mTSProxyModel - tsd.setVisibility(True) + super(TimeSeriesTableView, self).__init__(parent) - tsdTableModel = model.sourceModel() - assert isinstance(tsdTableModel, TimeSeriesTableModel) - idxSrc = tsdTableModel.getIndexFromDate(tsd) + def contextMenuEvent(self, event): + """ + Creates and shows an QMenu + :param event: + """ - if isinstance(idxSrc, QModelIndex): - idx2 = model.mapFromSource(idxSrc) - if isinstance(idx2, QModelIndex): - #self.tableView_TimeSeries.setCurrentIndex(idx2) - self.tableView_TimeSeries.scrollTo(idx2, QAbstractItemView.PositionAtCenter) + idx = self.indexAt(event.pos()) + tsd = self.model().data(idx, role=Qt.UserRole) - def setStatus(self): - """ - Updates the status of the TimeSeries - """ - from eotimeseriesviewer.timeseries import TimeSeries - if isinstance(self.mTimeSeries, TimeSeries): - nDates = len(self.mTimeSeries) - nSensors = len(self.mTimeSeries.sensors()) - msg = '{} scene(s) from {} sensor(s)'.format(nDates, nSensors) - if nDates > 1: - msg += ', {} to {}'.format(str(self.mTimeSeries[0].date()), str(self.mTimeSeries[-1].date())) - self.progressInfo.setText(msg) - - def setProgressInfo(self, nDone:int, nMax:int, message=None): - """ - Sets the progress bar of the TimeSeriesDockUI - :param nDone: number of added data sources - :param nMax: total number of data source to be added - :param message: error / other kind of info message - """ - if self.progressBar.maximum() != nMax: - self.progressBar.setMaximum(nMax) - self.progressBar.setValue(nDone) - self.progressInfo.setText(message) - QgsApplication.processEvents() - if nDone == nMax: - QTimer.singleShot(3000, lambda: self.setStatus()) + menu = QMenu(self) + a = menu.addAction('Copy value(s)') + a.triggered.connect(lambda: self.onCopyValues()) + a = menu.addAction('Check') + a.triggered.connect(lambda: self.onSetCheckState(Qt.Checked)) + a = menu.addAction('Uncheck') + a.triggered.connect(lambda: self.onSetCheckState(Qt.Unchecked)) + if isinstance(tsd, TimeSeriesDatum): + a = menu.addAction('Show {}'.format(tsd.date())) + a.triggered.connect(lambda _, tsd=tsd: self.sigMoveToDateRequest.emit(tsd)) - def onSelectionChanged(self, *args): - """ - Slot to react on user-driven changes of the selected TimeSeriesDatum rows. - """ - self.btnRemoveTSD.setEnabled(self.SM is not None and len(self.SM.selectedRows()) > 0) + menu.popup(QCursor.pos()) - def selectedTimeSeriesDates(self): + def onSetCheckState(self, checkState): """ - Returns the TimeSeriesDatum selected by a user. - :return: [list-of-TimeSeriesDatum] + Sets a ChecState to all selected rows + :param checkState: Qt.CheckState """ - if self.SM is not None: - return [self.mTSModel.data(idx, Qt.UserRole) for idx in self.SM.selectedRows()] - return [] + indices = self.selectionModel().selectedIndexes() + rows = sorted(list(set([i.row() for i in indices]))) + model = self.model() + if isinstance(model, TimeSeriesTableModel): + for r in rows: + idx = model.index(r, 0) + model.setData(idx, checkState, Qt.CheckStateRole) - def setTimeSeries(self, TS): + def onCopyValues(self, delimiter='\t'): """ - Sets the TimeSeries to be shown in the TimeSeriesDockUI - :param TS: TimeSeries + Copies selected cell values to the clipboard """ - from eotimeseriesviewer.timeseries import TimeSeries - self.mTimeSeries = TS - self.mTSModel = None - self.SM = None - - - if isinstance(TS, TimeSeries): - from eotimeseriesviewer.timeseries import TimeSeriesTableModel - self.mTSModel = TimeSeriesTableModel(self.mTimeSeries) - self.mTSProxyModel = QSortFilterProxyModel(self) - self.mTSProxyModel.setSourceModel(self.mTSModel) - self.tableView_TimeSeries.setModel(self.mTSProxyModel) - self.SM = QItemSelectionModel(self.mTSProxyModel) - self.tableView_TimeSeries.setSelectionModel(self.SM) - self.SM.selectionChanged.connect(self.onSelectionChanged) - self.tableView_TimeSeries.horizontalHeader().setResizeMode(QHeaderView.ResizeToContents) - self.tableView_TimeSeries.verticalHeader().setResizeMode(QHeaderView.ResizeToContents) - TS.sigLoadingProgress.connect(self.setProgressInfo) + indices = self.selectionModel().selectedIndexes() + model = self.model() + if isinstance(model, QSortFilterProxyModel): + from collections import OrderedDict + R = OrderedDict() + for idx in indices: + if not idx.row() in R.keys(): + R[idx.row()] = [] + R[idx.row()].append(model.data(idx, Qt.DisplayRole)) + info = [] + for k, values in R.items(): + info.append(delimiter.join([str(v) for v in values])) + info = '\n'.join(info) + QApplication.clipboard().setText(info) + s = "" - self.onSelectionChanged() class DateTimePrecision(enum.Enum): @@ -843,7 +887,7 @@ class DateTimePrecision(enum.Enum): Original = 0 -class TimeSeries(QObject): +class TimeSeries(QAbstractItemModel): """ The sorted list of data sources that specify the time series """ @@ -851,25 +895,56 @@ class TimeSeries(QObject): sigTimeSeriesDatesAdded = pyqtSignal(list) sigTimeSeriesDatesRemoved = pyqtSignal(list) sigLoadingProgress = pyqtSignal(int, int, str) + + sigSensorAdded = pyqtSignal(SensorInstrument) sigSensorRemoved = pyqtSignal(SensorInstrument) - sigSourcesChanged = pyqtSignal(TimeSeriesDatum) - sigRuntimeStats = pyqtSignal(dict) + sigSourcesAdded = pyqtSignal(list) + sigSourcesRemoved = pyqtSignal(list) - def __init__(self, imageFiles=None, maskFiles=None): + + + _sep = ';' + + def __init__(self, imageFiles=None): super(TimeSeries, self).__init__() self.mTSDs = list() self.mSensors = [] self.mShape = None self.mDateTimePrecision = DateTimePrecision.Original + self.mCurrentDates = [] + + self.cnDate = 'Date' + self.cnSensor = 'Sensor' + self.cnNS = 'ns' + self.cnNL = 'nl' + self.cnNB = 'nb' + self.cnCRS = 'CRS' + self.cnImages = 'Source Image(s)' + self.mColumnNames = [self.cnDate, self.cnSensor, + self.cnNS, self.cnNL, self.cnNB, + self.cnCRS, self.cnImages] + + self.mRootIndex = QModelIndex() + + if imageFiles is not None: self.addSources(imageFiles) - if maskFiles is not None: - self.addMasks(maskFiles) - _sep = ';' + + def setCurrentDates(self, tsds:list): + + + self.mCurrentDates.clear() + self.mCurrentDates.extend(tsds) + for tsd in tsds: + assert isinstance(tsd, TimeSeriesDatum) + idx = self.tsdToIdx(tsd) + # forece reset of background color + idx2 = self.index(idx.row(), self.columnCount()-1) + self.dataChanged.emit(idx, idx2, [Qt.BackgroundColorRole]) def sensor(self, sensorID:str)->SensorInstrument: """ @@ -1013,30 +1088,58 @@ class TimeSeries(QObject): #insert sorted by time & sensor assert tsd not in self.mTSDs assert tsd.sensor() in self.mSensors - bisect.insort(self.mTSDs, tsd) + tsd.mTimeSeries = self tsd.sigRemoveMe.connect(lambda: self.removeTSDs([tsd])) - tsd.sigSourcesChanged.connect(lambda: self.sigSourcesChanged.emit(tsd)) + tsd.rowsAboutToBeRemoved.connect(self.onSourcesAboutToBeRemoved) + tsd.rowsRemoved.connect(self.onSourcesRemoved) + tsd.rowsAboutToBeInserted.connect(self.onSourcesAboutToBeInserted) + tsd.rowsInserted.connect(self.onSourcesInserted) + + row = bisect.bisect(self.mTSDs, tsd) + self.beginInsertRows(self.mRootIndex, row, row) + self.mTSDs.insert(row, tsd) + self.endInsertRows() + #self.rowsInserted() return tsd + def onSourcesAboutToBeRemoved(self, parent, first, last): + s = "" + pass + + def onSourcesRemoved(self, parent, first, last): + s = "" + + def onSourcesAboutToBeInserted(self, parent, first, last): + s = "" + + def onSourcesInserted(self, parent, first, last): + s = "" + + def removeTSDs(self, tsds): """ Removes a list of TimeSeriesDatum :param tsds: [list-of-TimeSeriesDatum] """ + removed = list() for tsd in tsds: assert isinstance(tsd, TimeSeriesDatum) - assert tsd in self.mTSDs + row = self.mTSDs.index(tsd) + self.beginRemoveRows(self.mRootIndex, row, row) self.mTSDs.remove(tsd) tsd.mTimeSeries = None removed.append(tsd) + self.endRemoveRows() + self.sigTimeSeriesDatesRemoved.emit(removed) + def tsds(self, date:np.datetime64=None, sensor:SensorInstrument=None)->list: """ @@ -1104,6 +1207,7 @@ class TimeSeries(QObject): return None + def addSources(self, sources:list, progressDialog:QProgressDialog=None): """ Adds new data sources to the TimeSeries @@ -1112,6 +1216,7 @@ class TimeSeries(QObject): assert isinstance(sources, list) nMax = len(sources) + #self.sigTimeSeriesSourcesAboutToBeChanged.emit() self.sigLoadingProgress.emit(0, nMax, 'Start loading {} sources...'.format(nMax)) @@ -1122,56 +1227,68 @@ class TimeSeries(QObject): # this could be excluded into a parallel process addedDates = [] for i, source in enumerate(sources): - - - + newTSD = None msg = None - try: - - if isinstance(source, TimeSeriesSource): - tss = source - else: - tss = TimeSeriesSource.create(source) - - assert isinstance(tss, TimeSeriesSource) - tss.mDate = self.date2date(tss.date()) - date = tss.date() - sid = tss.sid() - sensor = self.sensor(sid) - - # if necessary, add a new sensor instance - if not isinstance(sensor, SensorInstrument): - sensor = self.addSensor(SensorInstrument(sid)) - assert isinstance(sensor, SensorInstrument) - - tsd = self.tsd(date, sensor) - - # if necessary, add a new TimeSeriesDatum instance - if not isinstance(tsd, TimeSeriesDatum): - tsd = self.insertTSD(TimeSeriesDatum(self, date, sensor)) - addedDates.append(tsd) - assert isinstance(tsd, TimeSeriesDatum) - - # add the source - tsd.addSource(tss) - - except Exception as ex: - msg = 'Unable to add: {}\n{}'.format(str(source), str(ex)) - print(msg, file=sys.stderr) + if False: #debug + newTSD = self._addSource(source) + else: + try: + newTSD = self._addSource(source) + except Exception as ex: + msg = 'Unable to add: {}\n{}'.format(str(source), str(ex)) + print(msg, file=sys.stderr) if isinstance(progressDialog, QProgressDialog): if progressDialog.wasCanceled(): break progressDialog.setValue(i) progressDialog.setLabelText('{}/{}'.format(i+1, nMax)) - if (i+1) % 10 == 0: + if (i+1) % 10 == 0: self.sigLoadingProgress.emit(i+1, nMax, msg) + + if isinstance(newTSD, TimeSeriesDatum): + addedDates.append(newTSD) + #if len(addedDates) > 0: + if isinstance(progressDialog, QProgressDialog): + progressDialog.setLabelText('Create map widgets...') + if len(addedDates) > 0: - if isinstance(progressDialog, QProgressDialog): - progressDialog.setLabelText('Create map widgets...') self.sigTimeSeriesDatesAdded.emit(addedDates) + def _addSource(self, source:TimeSeriesSource)->TimeSeriesDatum: + """ + :param source: + :return: TimeSeriesDatum (if new created) + """ + if isinstance(source, TimeSeriesSource): + tss = source + else: + tss = TimeSeriesSource.create(source) + + assert isinstance(tss, TimeSeriesSource) + + newTSD = None + + tss.mDate = self.date2date(tss.date()) + date = tss.date() + sid = tss.sid() + sensor = self.sensor(sid) + # if necessary, add a new sensor instance + if not isinstance(sensor, SensorInstrument): + sensor = self.addSensor(SensorInstrument(sid)) + assert isinstance(sensor, SensorInstrument) + tsd = self.tsd(date, sensor) + # if necessary, add a new TimeSeriesDatum instance + if not isinstance(tsd, TimeSeriesDatum): + tsd = self.insertTSD(TimeSeriesDatum(self, date, sensor)) + newTSD = tsd + # addedDates.append(tsd) + assert isinstance(tsd, TimeSeriesDatum) + # add the source + tsd.addSource(tss) + return newTSD + def setDateTimePrecision(self, mode:DateTimePrecision): """ Sets the precision with which the parsed DateTime information will be handled. @@ -1194,6 +1311,17 @@ class TimeSeries(QObject): return date + def sources(self) -> list: + """ + Returns the input sources + :return: iterator over [list-of-TimeSeriesSources] + """ + + for tsd in self: + for source in tsd: + yield source + + def sourceUris(self)->list: """ @@ -1230,6 +1358,226 @@ class TimeSeries(QObject): return '\n'.join(info) + def headerData(self, section, orientation, role): + assert isinstance(section, int) + + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + + if len(self.mColumnNames) > section: + return self.mColumnNames[section] + else: + return '' + + else: + return None + + def parent(self, index: QModelIndex) -> QModelIndex: + """ + Returns the parent index of a QModelIndex `index` + :param index: QModelIndex + :return: QModelIndex + """ + if not index.isValid(): + return QModelIndex() + + node = index.internalPointer() + tsd = None + tss = None + + if isinstance(node, TimeSeriesDatum): + return self.mRootIndex + + elif isinstance(node, TimeSeriesSource): + tss = node + tsd = node.timeSeriesDatum() + return self.createIndex(self.mTSDs.index(tsd), 0, tsd) + + def rowCount(self, index: QModelIndex=None) -> int: + """ + Return the row-count, i.e. number of child node for a TreeNode as index `index`. + :param index: QModelIndex + :return: int + """ + if index is None: + index = QModelIndex() + + if not index.isValid(): + return len(self) + + node = index.internalPointer() + if isinstance(node, TimeSeriesDatum): + return len(node) + + if isinstance(node, TimeSeriesSource): + return 0 + + + def columnNames(self) -> list: + """ + Returns the column names + :return: [list-of-string] + """ + return self.mColumnNames[:] + + def columnCount(self, index:QModelIndex = None) -> int: + """ + Returns the number of columns + :param index: QModelIndex + :return: + """ + + return len(self.mColumnNames) + + + def connectTreeView(self, treeView): + self.mTreeView = treeView + + def index(self, row: int, column: int, parent: QModelIndex = None) -> QModelIndex: + """ + Returns the QModelIndex + :param row: int + :param column: int + :param parent: QModelIndex + :return: QModelIndex + """ + if parent is None: + parent = self.mRootIndex + else: + assert isinstance(parent, QModelIndex) + + if row < 0 or row >= len(self): + return QModelIndex() + if column < 0 or column >= len(self.mColumnNames): + return QModelIndex() + + + if parent == self.mRootIndex: + # TSD node + if row < 0 or row >= len(self): + return QModelIndex() + return self.createIndex(row, column, self[row]) + + elif parent.parent() == self.mRootIndex: + # TSS node + tsd = self.tsdFromIdx(parent) + if row < 0 or row >= len(tsd): + return QModelIndex() + return self.createIndex(row, column, tsd[row]) + + return QModelIndex() + + def tsdToIdx(self, tsd:TimeSeriesDatum)->QModelIndex: + """ + Returns an QModelIndex pointing on a TimeSeriesDatum of interest + :param tsd: TimeSeriesDatum + :return: QModelIndex + """ + row = self.mTSDs.index(tsd) + return self.index(row, 0) + + def tsdFromIdx(self, index: QModelIndex) -> TimeSeriesDatum: + """ + Returns the TimeSeriesDatum related to an QModelIndex `index`. + :param index: QModelIndex + :return: TreeNode + """ + + if index.row() == -1 and index.column() == -1: + return None + elif not index.isValid(): + return None + else: + node = index.internalPointer() + if isinstance(node, TimeSeriesDatum): + return node + elif isinstance(node, TimeSeriesSource): + return node.timeSeriesDatum() + + return None + + def data(self, index, role): + """ + + :param index: QModelIndex + :param role: Qt.ItemRole + :return: object + """ + assert isinstance(index, QModelIndex) + if not index.isValid(): + return None + + node = index.internalPointer() + tsd = None + tss = None + if isinstance(node, TimeSeriesSource): + tsd = node.timeSeriesDatum() + tss = node + elif isinstance(node, TimeSeriesDatum): + tsd = node + + if role == Qt.UserRole: + return node + + cName = self.mColumnNames[index.column()] + + if isinstance(tss, TimeSeriesSource): + if role in [Qt.DisplayRole]: + if cName == self.cnDate: + return str(tsd.date()) + if cName == self.cnImages: + return tss.uri() + if cName == self.cnNB: + return tss.nb + if cName == self.cnNL: + return tss.nl + if cName == self.cnNS: + return tss.ns + if cName == self.cnCRS: + return tss.crs().description() + + if isinstance(tsd, TimeSeriesDatum): + if role in [Qt.DisplayRole]: + if cName == self.cnSensor: + return tsd.sensor().name() + if cName == self.cnImages: + return len(tsd) + if cName == self.cnDate: + return str(tsd.date()) + if role == Qt.CheckStateRole and index.column() == 0: + return Qt.Checked if tsd.isVisible() else Qt.Unchecked + + if role == Qt.BackgroundColorRole and tsd in self.mCurrentDates: + return QColor('yellow') + + return None + + def setData(self, index: QModelIndex, value: typing.Any, role: int): + + if not index.isValid(): + return False + + result = False + + node = index.internalPointer() + if isinstance(node, TimeSeriesDatum): + if role == Qt.CheckStateRole and index.column() == 0: + node.setVisibility(value == Qt.Checked) + result = True + + if result == True: + self.dataChanged.emit(index, index, [role]) + + return result + + def flags(self, index): + assert isinstance(index, QModelIndex) + if not index.isValid(): + return Qt.NoItemFlags + #cName = self.mColumnNames.index(index.column()) + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if isinstance(index.internalPointer(), TimeSeriesDatum) and index.column() == 0: + flags = flags | Qt.ItemIsUserCheckable + return flags class TimeSeriesTableModel(QAbstractTableModel): @@ -1506,6 +1854,116 @@ def extractWavelengths(ds): return wl, wlu + +class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')): + """ + QgsDockWidget that shows the TimeSeries + """ + def __init__(self, parent=None): + super(TimeSeriesDockUI, self).__init__(parent) + self.setupUi(self) + + #self.progressBar.setMinimum(0) + #self.setProgressInfo(0, 100, 'Add images to fill time series') + #self.progressBar.setValue(0) + #self.progressInfo.setText(None) + self.frameFilters.setVisible(False) + + self.mTimeSeries = None + self.mSelectionModel = None + + + def initActions(self, parent): + + from eotimeseriesviewer.main import TimeSeriesViewerUI + assert isinstance(parent, TimeSeriesViewerUI) + self.btnAddTSD.setDefaultAction(parent.actionAddTSD) + self.btnRemoveTSD.setDefaultAction(parent.actionRemoveTSD) + self.btnLoadTS.setDefaultAction(parent.actionLoadTS) + self.btnSaveTS.setDefaultAction(parent.actionSaveTS) + self.btnClearTS.setDefaultAction(parent.actionClearTS) + + + def showTSD(self, tsd:TimeSeriesDatum): + assert isinstance(self.timeSeriesTreeView, TimeSeriesTreeView) + assert isinstance(self.mTSProxyModel, QSortFilterProxyModel) + + tsd.setVisibility(True) + + assert isinstance(self.mTimeSeries, TimeSeries) + idxSrc = self.mTimeSeries.tsdToIdx(tsd) + + if isinstance(idxSrc, QModelIndex): + idx2 = self.mTSProxyModel.mapFromSource(idxSrc) + if isinstance(idx2, QModelIndex): + self.timeSeriesTreeView.setCurrentIndex(idx2) + self.timeSeriesTreeView.scrollTo(idx2, QAbstractItemView.PositionAtCenter) + + def updateSummary(self): + + + if isinstance(self.mTimeSeries, TimeSeries): + if len(self.mTimeSeries) == 0: + info = 'Empty Timeseries. Please add source images.' + else: + nDates = self.mTimeSeries.rowCount() + nSensors = len(self.mTimeSeries.sensors()) + nImages = len(list(self.mTimeSeries.sources())) + + info = '{} dates, {} sensors, {} source images'.format(nDates, nSensors, nImages) + else: + info = '' + self.summary.setText(info) + + def onSelectionChanged(self, *args): + """ + Slot to react on user-driven changes of the selected TimeSeriesDatum rows. + """ + + self.btnRemoveTSD.setEnabled( + isinstance(self.mSelectionModel, QItemSelectionModel) and + len(self.mSelectionModel.selectedRows()) > 0) + + def selectedTimeSeriesDates(self)->list: + """ + Returns the TimeSeriesDatum selected by a user. + :return: [list-of-TimeSeriesDatum] + """ + if isinstance(self.mSelectionModel, QItemSelectionModel): + return [self.mTSProxyModel.data(idx, Qt.UserRole) for idx in self.mSelectionModel.selectedRows()] + return [] + + def setTimeSeries(self, TS:TimeSeries): + """ + Sets the TimeSeries to be shown in the TimeSeriesDockUI + :param TS: TimeSeries + """ + from eotimeseriesviewer.timeseries import TimeSeries + if isinstance(TS, TimeSeries): + self.mTimeSeries = TS + self.mTSProxyModel = QSortFilterProxyModel(self) + self.mTSProxyModel.setSourceModel(self.mTimeSeries) + self.mSelectionModel = QItemSelectionModel(self.mTSProxyModel) + self.mSelectionModel.selectionChanged.connect(self.onSelectionChanged) + + + self.timeSeriesTreeView.setModel(self.mTSProxyModel) + self.timeSeriesTreeView.setSelectionModel(self.mSelectionModel) + + for c in range(self.mTSProxyModel.columnCount()): + self.timeSeriesTreeView.header().setSectionResizeMode(c, QHeaderView.ResizeToContents) + self.mTimeSeries.rowsInserted.connect(self.updateSummary) + #self.mTimeSeries.dataChanged.connect(self.updateSummary) + self.mTimeSeries.rowsRemoved.connect(self.updateSummary) + #TS.sigLoadingProgress.connect(self.setProgressInfo) + + self.onSelectionChanged() + + + + + + if __name__ == '__main__': q = QApplication([]) p = QProgressBar() diff --git a/eotimeseriesviewer/ui/timeseriesdock.ui b/eotimeseriesviewer/ui/timeseriesdock.ui index d4a93c88..c1f489b3 100644 --- a/eotimeseriesviewer/ui/timeseriesdock.ui +++ b/eotimeseriesviewer/ui/timeseriesdock.ui @@ -94,6 +94,9 @@ <property name="text"> <string>...</string> </property> + <property name="autoRaise"> + <bool>true</bool> + </property> </widget> </item> <item> @@ -101,6 +104,9 @@ <property name="text"> <string>...</string> </property> + <property name="autoRaise"> + <bool>true</bool> + </property> </widget> </item> <item> @@ -108,6 +114,9 @@ <property name="text"> <string>...</string> </property> + <property name="autoRaise"> + <bool>true</bool> + </property> </widget> </item> <item> @@ -115,6 +124,9 @@ <property name="text"> <string>...</string> </property> + <property name="autoRaise"> + <bool>true</bool> + </property> </widget> </item> <item> @@ -122,6 +134,9 @@ <property name="text"> <string>...</string> </property> + <property name="autoRaise"> + <bool>true</bool> + </property> </widget> </item> <item> @@ -187,7 +202,7 @@ </widget> </item> <item> - <widget class="TimeSeriesTableView" name="tableView_TimeSeries"> + <widget class="TimeSeriesTreeView" name="timeSeriesTreeView"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> <horstretch>1</horstretch> @@ -203,6 +218,9 @@ <property name="alternatingRowColors"> <bool>true</bool> </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> <property name="sortingEnabled"> <bool>true</bool> </property> @@ -246,32 +264,7 @@ <number>0</number> </property> <item> - <widget class="QProgressBar" name="progressBar"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> - <horstretch>1</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>50</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>100</width> - <height>16777215</height> - </size> - </property> - <property name="value"> - <number>24</number> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="progressInfo"> + <widget class="QLabel" name="summary"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Preferred"> <horstretch>2</horstretch> @@ -304,19 +297,6 @@ </property> </widget> </item> - <item> - <spacer name="horizontalSpacer_2"> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - <property name="sizeHint" stdset="0"> - <size> - <width>0</width> - <height>0</height> - </size> - </property> - </spacer> - </item> </layout> </widget> </item> @@ -325,8 +305,8 @@ </widget> <customwidgets> <customwidget> - <class>TimeSeriesTableView</class> - <extends>QTableView</extends> + <class>TimeSeriesTreeView</class> + <extends>QTreeView</extends> <header>eotimeseriesviewer.timeseries</header> </customwidget> </customwidgets> diff --git a/eotimeseriesviewer/ui/timeseriesviewer.ui b/eotimeseriesviewer/ui/timeseriesviewer.ui index cdb8e7f3..5f18ef8f 100644 --- a/eotimeseriesviewer/ui/timeseriesviewer.ui +++ b/eotimeseriesviewer/ui/timeseriesviewer.ui @@ -154,7 +154,7 @@ <enum>Qt::ActionsContextMenu</enum> </property> <property name="defaultUp"> - <bool>true</bool> + <bool>false</bool> </property> <widget class="QMenu" name="menuFiles"> <property name="title"> diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index ef8b82df..f04aec13 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -13,6 +13,8 @@ from eotimeseriesviewer.tests import initQgisApplication QAPP = initQgisApplication() +SHOW_GUI = True and os.environ.get('CI') is None + class TestInit(unittest.TestCase): def createTestDatasets(self): @@ -107,6 +109,7 @@ class TestInit(unittest.TestCase): file = example.Images.Img_2014_03_20_LC82270652014079LGN00_BOA tss = TimeSeriesSource.create(file) + tss2 = TimeSeriesSource.create(example.Images.Img_2014_07_02_LE72270652014183CUB00_BOA) sensor = SensorInstrument(tss.sid()) tsd = TimeSeriesDatum(None, tss.date(), sensor) @@ -116,6 +119,7 @@ class TestInit(unittest.TestCase): self.assertEqual(tsd.sensor(), sensor) self.assertEqual(len(tsd), 0) tsd.addSource(tss) + tsd.addSource(tss) self.assertEqual(len(tsd), 1) self.assertTrue(tsd.year() == 2014) @@ -123,6 +127,19 @@ class TestInit(unittest.TestCase): self.assertIsInstance(tsd.decimalYear(), float) self.assertTrue(tsd.decimalYear() >= 2014 and tsd.decimalYear() < 2015) + + self.assertIsInstance(tsd, QAbstractTableModel) + for r in range(len(tsd)): + for i in range(len(TimeSeriesDatum.ColumnNames)): + value = tsd.data(tsd.createIndex(r, i), role=Qt.DisplayRole) + + TV = QTableView() + TV.setModel(tsd) + TV.show() + + if SHOW_GUI: + QAPP.exec_() + def test_timeseriessource(self): wcs = r'dpiMode=7&identifier=BGS_EMODNET_CentralMed-MCol&url=http://194.66.252.155/cgi-bin/BGS_EMODnet_bathymetry/ows?VERSION%3D1.1.0%26coverage%3DBGS_EMODNET_CentralMed-MCol' @@ -237,9 +254,11 @@ class TestInit(unittest.TestCase): TS = TimeSeries() self.assertIsInstance(TS, TimeSeries) + self.assertIsInstance(TS, QAbstractItemModel) + TS.sigTimeSeriesDatesAdded.connect(lambda dates: addedDates.extend(dates)) TS.sigTimeSeriesDatesRemoved.connect(lambda dates: removedDates.extend(dates)) - TS.sigSourcesChanged.connect(lambda tsd: sourcesChanged.append(tsd)) + #TS.sigSourcesChanged.connect(lambda tsd: sourcesChanged.append(tsd)) TS.sigSensorAdded.connect(lambda sensor: addedSensors.append(sensor)) TS.sigSensorRemoved.connect(lambda sensor:removedSensors.append(sensor)) TS.addSources(files) @@ -255,8 +274,9 @@ class TestInit(unittest.TestCase): self.assertEqual(len(files), len(TS)) self.assertEqual(len(addedDates), len(TS)) - - + self.assertTrue(len(TS) > 0) + self.assertEqual(TS.columnCount(), len(TS.mColumnNames)) + self.assertEqual(TS.rowCount(), len(TS)) self.assertEqual(len(removedDates), 0) @@ -297,6 +317,36 @@ class TestInit(unittest.TestCase): def test_datematching(self): pass + def test_TimeSeriesTreeModel(self): + + TS = TimeSeries() + TS.addSources(TestObjects.createMultiSourceTimeSeries()) + + self.assertTrue(len(TS) > 0) + self.assertIsInstance(TS, QAbstractItemModel) + + self.assertEqual(len(TS), TS.rowCount()) + M = QSortFilterProxyModel() + M.setSourceModel(TS) + TV = QTreeView() + TV.setSortingEnabled(True) + TV.setModel(M) + TV.show() + + if SHOW_GUI: + QAPP.exec_() + + def test_TimeSeriesDock(self): + + TS = TimeSeries() + TS.addSources(TestObjects.createMultiSourceTimeSeries()) + + dock = TimeSeriesDockUI() + dock.setTimeSeries(TS) + dock.show() + + if SHOW_GUI: + QAPP.exec_() if __name__ == '__main__': -- GitLab