diff --git a/CHANGES.txt b/CHANGES.txt
index 7c1936e51fac49f88a2699d1e40689c94824a29e..66ef74f4c5bcbb6a60bf828bb3cb051a729e7608 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -9,8 +9,6 @@
         - Width of plot lines now scale-independent (issue #64, QPen.setCosmetic(True))
         - adding fields to spectral library (issue #61)
 
-
-
 2018-06-04
     increased version to 0.6
     SpectralLibrary Module
diff --git a/test/test_mapcanvas.py b/test/test_mapcanvas.py
index cebeb5143ec0ac54681705d4cef605e2e7165dd4..e7c7ee4d738d60d452ed117b740633c0bcf34f6d 100644
--- a/test/test_mapcanvas.py
+++ b/test/test_mapcanvas.py
@@ -25,7 +25,8 @@ import unittest, tempfile
 
 from timeseriesviewer.mapcanvas import *
 
-QGIS_APP = initQgisApplication()
+resourceDir = os.path.join(DIR_REPO,'qgisresources')
+QGIS_APP = initQgisApplication(qgisResourceDir=resourceDir)
 
 
 class testclassDialogTest(unittest.TestCase):
@@ -47,7 +48,6 @@ class testclassDialogTest(unittest.TestCase):
 
         self.assertIsInstance(m.mLayerModel, MapCanvasLayerModel)
 
-        m.saveAsImage()
 
 
 if __name__ == "__main__":
diff --git a/test/test_stackedbandinput.py b/test/test_stackedbandinput.py
new file mode 100644
index 0000000000000000000000000000000000000000..b2c68f8db01d4ea5305a8ed02bfbb7878ffa7cef
--- /dev/null
+++ b/test/test_stackedbandinput.py
@@ -0,0 +1,219 @@
+# -*- coding: utf-8 -*-
+
+"""
+***************************************************************************
+    
+    ---------------------
+    Date                 :
+    Copyright            : (C) 2017 by Benjamin Jakimow
+    Email                : benjamin jakimow at geo dot hu-berlin dot de
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+# noinspection PyPep8Naming
+
+from timeseriesviewer.utils import initQgisApplication, nextColor
+from PyQt5.QtGui import *
+from PyQt5.QtCore import *
+import unittest, tempfile
+
+from timeseriesviewer.stackedbandinput import *
+from example.Images import Img_2014_06_16_LE72270652014167CUB00_BOA, Img_2014_05_07_LC82270652014127LGN00_BOA
+resourceDir = os.path.join(DIR_REPO,'qgisresources')
+QGIS_APP = initQgisApplication(qgisResourceDir=resourceDir)
+
+
+class testclassDialogTest(unittest.TestCase):
+    """Test rerources work."""
+
+    @classmethod
+    def setUpClass(cls):
+        pass
+        #cls.srcDir = r'F:\Temp\EOTSV_Dev\DF'
+        #cls.stackFiles = file_search(cls.srcDir, '*.tif')
+
+    def setUp(self):
+        """Runs before each test."""
+        pass
+
+    def tearDown(self):
+        """Runs after each test."""
+        pass
+
+    def createTestDatasets(self):
+
+        vsiDir = '/vsimem/tmp'
+        from timeseriesviewer.temporalprofiles2d import date2num
+        ns = 50
+        nl = 100
+
+        r1 = np.arange('2000-01-01', '2005-06-14', step=np.timedelta64(16, 'D'), dtype=np.datetime64)
+        r2 = np.arange('2000-01-01', '2005-06-14', step=np.timedelta64(8, 'D'), dtype=np.datetime64)
+        drv = gdal.GetDriverByName('ENVI')
+
+        crs = osr.SpatialReference()
+        crs.ImportFromEPSG(32633)
+
+        assert isinstance(drv, gdal.Driver)
+        datasets = []
+        for i, r in enumerate([r1, r2]):
+            p = '{}tmpstack{}.bsq'.format(vsiDir, i+1)
+            nb = len(r)
+            ds = drv.Create(p, ns, nl, nb, eType=gdal.GDT_Float32)
+            assert isinstance(ds, gdal.Dataset)
+
+            ds.SetProjection(crs.ExportToWkt())
+
+            dateString = ','.join([str(d) for d in r])
+            dateString = '{{{}}}'.format(dateString)
+            ds.SetMetadataItem('wavelength', dateString, 'ENVI')
+
+            for b, date in enumerate(r):
+                decimalYear = date2num(date)
+
+                band = ds.GetRasterBand(b+1)
+                assert isinstance(band, gdal.Band)
+                band.Fill(decimalYear)
+            ds.FlushCache()
+            datasets.append(p)
+
+
+            if i == 0:
+                #create a classification image stack
+
+                nc = nb
+                data = np.ones((nb, ns, nl), dtype=np.uint8)
+                classNames = ['unclassified']
+                colorTable = gdal.ColorTable()
+                colorTable.SetColorEntry(0, (0,0,0))
+                assert isinstance(colorTable, gdal.ColorTable)
+                color = QColor('green')
+
+                for j, date in enumerate(r):
+                    c = j+1
+                    data[j, j:-1, 0:j] = c
+                    classNames.append('Class {}'.format(date))
+                    colorTable.SetColorEntry(c, color.getRgb())
+                    color = nextColor(color)
+
+                p = '{}tmpClassificationStack.bsq'.format(vsiDir)
+                ds = gdal_array.SaveArray(data,p, format='ENVI', prototype=datasets[0])
+                ds.GetRasterBand(1).SetColorTable(colorTable)
+                ds.GetRasterBand(1).SetCategoryNames(classNames)
+
+                assert isinstance(ds, gdal.Dataset)
+                ds.SetMetadataItem('wavelength', dateString, 'ENVI')
+                ds.FlushCache()
+                datasets.append(ds)
+        return datasets
+
+
+    def test_inputmodel(self):
+        testData = self.createTestDatasets()
+        m = InputStackTableModel()
+        m.insertSources(testData)
+        self.assertTrue(len(m) == len(testData))
+
+        dTotal, dIntersecton = m.dateInfo()
+
+        self.assertTrue(len(dTotal) > 0)
+        self.assertTrue(len(dIntersecton) > 0)
+        self.assertTrue(len(dTotal) > len(dIntersecton))
+
+
+    def test_outputmodel(self):
+
+        m = OutputImageModel()
+        m.setOutputDir('/vsimem/dub')
+        m.setOutputPrefix('myPrefix')
+
+        testData = self.createTestDatasets()
+
+        stackInfo = InputStackInfo(testData[0])
+
+        self.assertTrue(len(stackInfo) > 0)
+
+        stackInfos = [InputStackInfo(f) for f in testData]
+
+        dates = set()
+
+        for s in stackInfos:
+            dates.update(s.dates())
+
+        m.setMultiStackSources(stackInfos, list(dates))
+
+        self.assertTrue(len(m) > 0)
+
+        outInfo = m.mOutputImages[0]
+        self.assertIsInstance(outInfo, OutputVRTDescription)
+
+        xml = m.vrtXML(outInfo)
+        self.assertIsInstance(xml, str)
+
+        eTree = m.vrtXML(outInfo, asElementTree=True)
+        self.assertIsInstance(eTree, ElementTree.Element)
+
+
+    def test_dateparsing(self):
+
+        dsDates = gdal.OpenEx(self.stackFiles[1], allowed_drivers=['ENVI'])
+
+        #dsDates = gdal.Open(self.stackFiles[1])
+        dates = datesFromDataset(dsDates)
+        self.assertEqual(len(dates), dsDates.RasterCount)
+
+        dsNoDates = gdal.OpenEx(self.stackFiles[0], allowed_drivers=['ENVI'])
+        dates = datesFromDataset(dsNoDates)
+        self.assertEqual(len(dates), 0)
+
+        s = ""
+
+    def test_dialog(self):
+        d = StackedBandInputDialog()
+        d.addSources(self.createTestDatasets())
+
+        r = d.exec_()
+
+        self.assertTrue(r in [QDialog.Rejected, QDialog.Accepted])
+        if r == QDialog.Accepted:
+            images = d.writtenFiles()
+            self.assertTrue(len(images) > 0)
+
+            for p in images:
+                ds = gdal.Open(p)
+                self.assertIsInstance(ds, gdal.Dataset)
+                lyr = QgsRasterLayer(p)
+                self.assertIsInstance(lyr, QgsRasterLayer)
+                self.assertTrue(lyr.isValid())
+        else:
+            self.assertTrue(len(d.writtenFiles()) == 0)
+
+        #QGIS_APP.exec_()
+        pass
+
+    def test_withTSV(self):
+
+
+
+        testImages = self.createTestDatasets()
+        from timeseriesviewer.main import TimeSeriesViewer
+        TSV = TimeSeriesViewer(None)
+        TSV.show()
+
+        d = StackedBandInputDialog()
+        d.addSources(self.createTestDatasets())
+        writtenFiles = d.saveImages()
+        self.assertTrue(len(writtenFiles) > 0)
+        TSV.loadImageFiles(writtenFiles)
+
+        self.assertTrue(len(TSV.TS) == len(writtenFiles))
+
+        QGIS_APP.exec_()
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/test_timeseries.py b/test/test_timeseries.py
index e470194e2a6300e5dc7478954a82328c8909bf8b..e6160d408e92698eb294158a6e7c0758b7d62f65 100644
--- a/test/test_timeseries.py
+++ b/test/test_timeseries.py
@@ -5,12 +5,57 @@ import os
 import unittest
 import example
 import example.Images
-from osgeo import gdal
+from osgeo import gdal, ogr, osr
 from timeseriesviewer.utils import file_search
-from timeseriesviewer.timeseries import TimeSeries, SensorInstrument, TimeSeriesDatum
+from timeseriesviewer.timeseries import *
 
 class TestInit(unittest.TestCase):
 
+
+    def createTestDatasets(self):
+
+        vsiDir = '/vsimem/tmp'
+        from timeseriesviewer.temporalprofiles2d import date2num
+        ns = 50
+        nl = 100
+
+        r1 = np.arange('2000-01-01', '2005-06-14', step=np.timedelta64(16, 'D'), dtype=np.datetime64)
+        r2 = np.arange('2000-01-01', '2005-06-14', step=np.timedelta64(8, 'D'), dtype=np.datetime64)
+        drv = gdal.GetDriverByName('ENVI')
+
+        crs = osr.SpatialReference()
+        crs.ImportFromEPSG(32633)
+
+        assert isinstance(drv, gdal.Driver)
+        datasets = []
+
+        for i, r in enumerate([r1, r2]):
+            p = '{}tmpstack{}.bsq'.format(vsiDir, i+1)
+
+            ds = drv.Create(p, ns, nl, len(r), eType=gdal.GDT_Float32)
+            assert isinstance(ds, gdal.Dataset)
+
+            ds.SetProjection(crs.ExportToWkt())
+
+            dateString = ','.join([str(d) for d in r])
+            dateString = '{{{}}}'.format(dateString)
+            ds.SetMetadataItem('wavelength', dateString, 'ENVI')
+
+            for b, date in enumerate(r):
+                decimalYear = date2num(date)
+
+                band = ds.GetRasterBand(b+1)
+                assert isinstance(band, gdal.Band)
+                band.Fill(decimalYear)
+            ds.FlushCache()
+            datasets.append(p)
+
+
+
+
+
+        return datasets
+
     def test_timeseriesdatum(self):
 
         file = example.Images.Img_2014_03_20_LC82270652014079LGN00_BOA
@@ -19,6 +64,8 @@ class TestInit(unittest.TestCase):
         self.assertIsInstance(tsd, TimeSeriesDatum)
         self.assertEqual(tsd.nb, 6)
 
+
+
     def test_timeseries(self):
 
         files = file_search(os.path.dirname(example.__file__), '*.tif', recursive=True)
@@ -48,7 +95,7 @@ class TestInit(unittest.TestCase):
         dsRE = gdal.Open(pathRE)
         assert isinstance(dsRE, gdal.Dataset)
 
-        tsdRE = TS.tsdFromPath(pathRE)
+        tsdRE = TS.getTSD(pathRE)
         self.assertIsInstance(tsdRE, TimeSeriesDatum)
         sRE = tsdRE.sensor
         self.assertIsInstance(sRE, SensorInstrument)
diff --git a/timeseriesviewer/tests.py b/test/tests.py
similarity index 77%
rename from timeseriesviewer/tests.py
rename to test/tests.py
index 74d02ed8f8e994c1d696200ebcc8c37ffadd6a40..ffeca468f2ba773233363f351a2e2763d178b110 100644
--- a/timeseriesviewer/tests.py
+++ b/test/tests.py
@@ -28,16 +28,62 @@ from timeseriesviewer import *
 from timeseriesviewer.timeseries import *
 from timeseriesviewer import DIR_EXAMPLES
 from timeseriesviewer.utils import file_search
+from osgeo import ogr, osr, gdal, gdal_array
+
+
+
 class TestObjects():
+    """
+    Creates objects to be used for testing. It is prefered to generate objects in-memory.
+    """
 
     @staticmethod
-    def timeSeries():
+    def createTimeSeries():
 
         TS = TimeSeries()
         files = file_search(DIR_EXAMPLES, '*.bsq', recursive=True)
         TS.addFiles(files)
         return TS
 
+    @staticmethod
+    def createTimeSeriesStacks():
+        vsiDir = '/vsimem/tmp'
+        from timeseriesviewer.temporalprofiles2d import date2num
+        ns = 50
+        nl = 100
+
+        r1 = np.arange('2000-01-01', '2005-06-14', step=np.timedelta64(16, 'D'), dtype=np.datetime64)
+        r2 = np.arange('2000-01-01', '2005-06-14', step=np.timedelta64(8, 'D'), dtype=np.datetime64)
+        drv = gdal.GetDriverByName('ENVI')
+
+        crs = osr.SpatialReference()
+        crs.ImportFromEPSG(32633)
+
+        assert isinstance(drv, gdal.Driver)
+        datasets = []
+        for i, r in enumerate([r1, r2]):
+            p = '{}tmpstack{}.bsq'.format(vsiDir, i + 1)
+
+            ds = drv.Create(p, ns, nl, len(r), eType=gdal.GDT_Float32)
+            assert isinstance(ds, gdal.Dataset)
+
+            ds.SetProjection(crs.ExportToWkt())
+
+            dateString = ','.join([str(d) for d in r])
+            dateString = '{{{}}}'.format(dateString)
+            ds.SetMetadataItem('wavelength', dateString, 'ENVI')
+
+            for b, date in enumerate(r):
+                decimalYear = date2num(date)
+
+                band = ds.GetRasterBand(b + 1)
+                assert isinstance(band, gdal.Band)
+                band.Fill(decimalYear)
+            ds.FlushCache()
+            datasets.append(p)
+
+        return datasets
+
     @staticmethod
     def spectralProfiles(n):
         """
diff --git a/timeseriesviewer/dateparser.py b/timeseriesviewer/dateparser.py
index 110de79f58a5090da1a0c5ea8c370aa3b068371c..b55da28783e1d7048d8da4d82f193035ff736db5 100644
--- a/timeseriesviewer/dateparser.py
+++ b/timeseriesviewer/dateparser.py
@@ -1,9 +1,10 @@
 
-import os, re, logging
+import os, re, logging, datetime
 from osgeo import gdal
 import numpy as np
 from qgis import *
-logger = logging.getLogger(__file__)
+from qgis.PyQt.QtCore import QDate
+
 
 #regular expression. compile them only once
 
@@ -19,6 +20,7 @@ regYYYYMMDD = re.compile(r'(?P<year>(19|20)\d\d)(?P<hyphen>-?)(?P<month>1[0-2]|0
 regMissingHypen = re.compile(r'^\d{8}')
 regYYYYMM = re.compile(r'([0-9]{4})-(1[0-2]|0[1-9])')
 regYYYYDOY = re.compile(r'(?P<year>(19|20)\d\d)-?(?P<day>36[0-6]|3[0-5][0-9]|[12][0-9]{2}|0[1-9][0-9]|00[1-9])')
+regDecimalYear = re.compile(r'(?P<year>(19|20)\d\d)\.(?P<datefraction>\d\d\d)')
 
 def matchOrNone(regex, text):
     match = regex.search(text)
@@ -27,7 +29,52 @@ def matchOrNone(regex, text):
     else:
         return None
 
-def extractDateTimeGroup(text):
+def dateDOY(date):
+    if isinstance(date, np.datetime64):
+        date = date.astype(datetime.date)
+    return date.timetuple().tm_yday
+
+def daysPerYear(year):
+
+    if isinstance(year, np.datetime64):
+        year = year.astype(datetime.date)
+    if isinstance(year, datetime.date):
+        year = year.timetuple().tm_year
+
+    return dateDOY(datetime.date(year=year, month=12, day=31))
+
+def num2date(n, dt64=True, qDate=False):
+    n = float(n)
+    if n < 1:
+        n += 1
+
+    year = int(n)
+    fraction = n - year
+    yearDuration = daysPerYear(year)
+    yearElapsed = fraction * yearDuration
+
+    import math
+    doy = round(yearElapsed)
+    if doy < 1:
+        doy = 1
+    try:
+        date = datetime.date(year, 1, 1) + datetime.timedelta(days=doy-1)
+    except:
+        s = ""
+    if qDate:
+        return QDate(date.year, date.month, date.day)
+    if dt64:
+        return np.datetime64(date)
+    else:
+        return date
+
+
+def extractDateTimeGroup(text:str)->np.datetime64:
+    """
+    Extracts a date-time-group from a text string
+    :param text: a string
+    :return: numpy.datetime64 in case of success, or None
+    """
     match = regISODate1.search(text)
     if match:
         matchedText = match.group()
@@ -47,6 +94,14 @@ def extractDateTimeGroup(text):
     if match:
         return np.datetime64(match.group())
 
+    match = regDecimalYear.search(text)
+    if match:
+        year = float(match.group('year'))
+        df = float(match.group('datefraction'))
+        num = match.group()
+        return num2date(num)
+
+
     return None
 
 def datetime64FromYYYYMMDD(yyyymmdd):
diff --git a/timeseriesviewer/main.py b/timeseriesviewer/main.py
index 7bf76adcd4685ad835417455a33fd4d29fadf49c..e090f3e7276c6fdb42f85cace7c48d474665e50b 100644
--- a/timeseriesviewer/main.py
+++ b/timeseriesviewer/main.py
@@ -463,7 +463,7 @@ class TimeSeriesViewer(QgisInterface, QObject):
         self.ui.actionClearTS.triggered.connect(self.clearTimeSeries)
         self.ui.actionSaveTS.triggered.connect(self.saveTimeSeriesDefinition)
         self.ui.actionAddTSExample.triggered.connect(self.loadExampleTimeSeries)
-
+        self.ui.actionLoadTimeSeriesStack.triggered.connect(self.loadTimeSeriesStack)
         self.ui.actionShowCrosshair.toggled.connect(self.spatialTemporalVis.setShowCrosshair)
 
         #connect buttons with actions
@@ -674,6 +674,15 @@ class TimeSeriesViewer(QgisInterface, QObject):
         if path is not None:
             s.setValue('FILE_TS_DEFINITION', path)
 
+    def loadTimeSeriesStack(self):
+
+        from timeseriesviewer.stackedbandinput import StackedBandInputDialog
+
+        d = StackedBandInputDialog(parent=self.ui)
+        if d.exec_() == QDialog.Accepted:
+            C = d.saveImages()
+            self.addTimeSeriesImages(writtenFiles)
+
 
 
     def loadExampleTimeSeries(self):
diff --git a/timeseriesviewer/mapvisualization.py b/timeseriesviewer/mapvisualization.py
index e6e32efff49d2d2830ecdd3a4872791047df6023..53324cda18d0d221ab21a645485d53239cac66ba 100644
--- a/timeseriesviewer/mapvisualization.py
+++ b/timeseriesviewer/mapvisualization.py
@@ -42,7 +42,6 @@ from timeseriesviewer.crosshair import CrosshairStyle
 
 
 
-#dummyPath = os.path.abspath(os.path.join(os.path.dirname(__file__), 'dummy_gdal.vrt'))
 #assert os.path.isfile(dummyPath)
 #lyr = QgsRasterLayer(dummyPath)
 #assert lyr.isValid()
@@ -1532,21 +1531,65 @@ RENDER_CLASSES['renderer-v2'] = {
 }
 
 
+
+
 def rendererToXml(renderer):
+    """
+    Returns a renderer XML representation
+    :param renderer: QgsRasterRender | QgsFeatureRenderer
+    :return: QDomDocument
+    """
     doc = QDomDocument()
+    err = ''
     if isinstance(renderer, QgsRasterRenderer):
-        path = os.path.join(os.path.dirname(__file__), 'dummy_gdal.vrt')
+        #create a dummy raster layer
+        import uuid
+        from timeseriesviewer.virtualrasters import write_vsimem, read_vsimem
+        xml = """<VRTDataset rasterXSize="1" rasterYSize="1">
+                  <GeoTransform>  0.0000000000000000e+00,  1.0000000000000000e+00,  0.0000000000000000e+00,  0.0000000000000000e+00,  0.0000000000000000e+00, -1.0000000000000000e+00</GeoTransform>
+                  <VRTRasterBand dataType="Float32" band="1">
+                    <Metadata>
+                      <MDI key="STATISTICS_MAXIMUM">0</MDI>
+                      <MDI key="STATISTICS_MEAN">0</MDI>
+                      <MDI key="STATISTICS_MINIMUM">0</MDI>
+                      <MDI key="STATISTICS_STDDEV">0</MDI>
+                    </Metadata>
+                    <Description>Band 1</Description>
+                    <Histograms>
+                      <HistItem>
+                        <HistMin>0</HistMin>
+                        <HistMax>0</HistMax>
+                        <BucketCount>1</BucketCount>
+                        <IncludeOutOfRange>0</IncludeOutOfRange>
+                        <Approximate>0</Approximate>
+                        <HistCounts>0</HistCounts>
+                      </HistItem>
+                    </Histograms>
+                  </VRTRasterBand>
+                </VRTDataset>
+                """
+        path = '/vsimem/{}.vrt'.format(uuid.uuid4())
+        drv = gdal.GetDriverByName('VRT')
+        assert isinstance(drv, gdal.Driver)
+        write_vsimem(path, xml)
         lyr = QgsRasterLayer(path)
+        assert lyr.isValid()
         lyr.setRenderer(renderer.clone())
+        lyr.exportNamedStyle(doc, err)
+        #remove dummy raster layer
+        lyr = None
+        drv.Delete(path)
 
     elif isinstance(renderer, QgsFeatureRenderer):
         #todo: distinguish vector type from requested renderer
         lyr = QgsVectorLayer('Point?crs=epsg:4326&field=id:integer', 'dummy', 'memory')
         lyr.setRenderer(renderer.clone())
+        lyr.exportNamedStyle(doc, err)
+        lyr = None
     else:
         raise NotImplementedError()
-    err = ''
-    lyr.exportNamedStyle(doc, err)
+
+
     return doc
 
 
diff --git a/timeseriesviewer/pixelloader.py b/timeseriesviewer/pixelloader.py
index ccf65d3d4deac6edbd03728ab109bb3ffc04b234..dd828b011dba2f8ee5351423e9072bb076b84b98 100644
--- a/timeseriesviewer/pixelloader.py
+++ b/timeseriesviewer/pixelloader.py
@@ -67,7 +67,7 @@ class PixelLoaderTask(object):
     def fromDump(byte_object):
         return pickle.loads(byte_object)
 
-    def __init__(self, source, geometries, bandIndices=None, **kwargs):
+    def __init__(self, source:str, geometries, bandIndices=None, **kwargs):
         """
 
         :param jobId: jobId number as given by the calling PixelLoader
@@ -83,8 +83,6 @@ class PixelLoaderTask(object):
         for geometry in geometries:
             assert type(geometry) in [SpatialExtent, SpatialPoint]
 
-        assert os.path.isfile(source)
-
 
         #assert isinstance(source, str) or isinstance(source, unicode)
         self.sourcePath = source
diff --git a/timeseriesviewer/profilevisualization.py b/timeseriesviewer/profilevisualization.py
index 88bb882618c0d69f2fb498935a9d702099b90943..08d885f629dae0df4b8634e419d16924d74e51ca 100644
--- a/timeseriesviewer/profilevisualization.py
+++ b/timeseriesviewer/profilevisualization.py
@@ -30,7 +30,7 @@ from qgis.PyQt.QtGui import *
 
 from timeseriesviewer import jp, SETTINGS
 from timeseriesviewer.timeseries import *
-from timeseriesviewer.utils import SpatialExtent, SpatialPoint, px2geo, loadUI
+from timeseriesviewer.utils import SpatialExtent, SpatialPoint, px2geo, loadUI, nextColor
 from timeseriesviewer.plotstyling import PlotStyle, PlotStyleButton
 from timeseriesviewer.pixelloader import PixelLoader, PixelLoaderTask
 from timeseriesviewer.sensorvisualization import SensorListModel
@@ -1284,35 +1284,6 @@ class ProfileViewDockUI(QgsDockWidget, loadUI('profileviewdock.ui')):
         w.update()
         self.setWindowTitle(title)
 
-NEXT_COLOR_HUE_DELTA_CON = 10
-NEXT_COLOR_HUE_DELTA_CAT = 100
-def nextColor(color, mode='cat'):
-    """
-    Returns another color
-    :param color: the previous color
-    :param mode: 'cat' - for categorical color jump (next color looks pretty different to previous)
-                 'con' - for continuous color jump (next color looks similar to previous)
-    :return:
-    """
-    assert mode in ['cat','con']
-    assert isinstance(color, QColor)
-    hue, sat, value, alpha = color.getHsl()
-    if mode == 'cat':
-        hue += NEXT_COLOR_HUE_DELTA_CAT
-    elif mode == 'con':
-        hue += NEXT_COLOR_HUE_DELTA_CON
-    if sat == 0:
-        sat = 255
-        value = 128
-        alpha = 255
-        s = ""
-    while hue > 360:
-        hue -= 360
-
-    return QColor.fromHsl(hue, sat, value, alpha)
-
-
-
 
 class SpectralTemporalVisualization(QObject):
 
diff --git a/timeseriesviewer/spectrallibraries.py b/timeseriesviewer/spectrallibraries.py
index b421f716909bd26e052c31e7b088739fb718ee45..7e99bcb5a848b662fd0dcb5d222be975c8d1ca41 100644
--- a/timeseriesviewer/spectrallibraries.py
+++ b/timeseriesviewer/spectrallibraries.py
@@ -35,10 +35,10 @@ from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem
 from pyqtgraph.graphicsItems.PlotItem import PlotItem
 import pyqtgraph.functions as fn
 import numpy as np
-from osgeo import gdal, gdal_array
+from osgeo import gdal, gdal_array, ogr
 
 from timeseriesviewer.utils import *
-#from timeseriesviewer.virtualrasters import *
+from timeseriesviewer.virtualrasters import describeRawFile
 from timeseriesviewer.models import *
 from timeseriesviewer.plotstyling import PlotStyle, PlotStyleDialog, MARKERSYMBOLS2QGIS_SYMBOLS
 import timeseriesviewer.mimedata as mimedata
@@ -1863,7 +1863,7 @@ class EnviSpectralLibraryIO(AbstractSpectralLibraryIO):
         xSize = int(hdr['samples'])
         ySize = int(hdr['lines'])
         bands = int(hdr['bands'])
-        byteOrder = 'MSB' if hdr['byte order'] == 0 else 'LSB'
+        byteOrder = 'LSB' if int(hdr['byte order']) == 0 else 'MSB'
 
         if pathVrt is None:
             id = uuid.UUID()
@@ -2556,36 +2556,6 @@ class SpectralLibraryPlotWidget(PlotWidget):
         #if self.mModel.rowCount() > 0:
         #    self.onRowsInserted(self.mModel.index(0,0), 0, self.mModel.rowCount())
 
-    def onMouseMoved2D(self, evt):
-        pos = evt[0]  ## using signal proxy turns original arguments into a tuple
-
-        plotItem = self.getPlotItem()
-        if plotItem.sceneBoundingRect().contains(pos):
-            vb = plotItem.vb
-            assert isinstance(vb, DateTimeViewBox)
-            mousePoint = vb.mapSceneToView(pos)
-            x = mousePoint.x()
-            if x >= 0:
-                y = mousePoint.y()
-                date = num2date(x)
-                doy = dateDOY(date)
-                plotItem.vb.updateCurrentDate(num2date(x, dt64=True))
-                self.mInfoLabelCursor.setText('DN {:0.2f}\nDate {}\nDOY {}'.format(
-                                              mousePoint.y(), date, doy),
-                                              color=self.mInfoColor)
-
-                s = self.size()
-                pos = QPointF(s.width(), 0)
-                self.mInfoLabelCursor.setVisible(vb.mActionShowCursorValues.isChecked())
-                self.mInfoLabelCursor.setPos(pos)
-
-                b = vb.mActionShowCrosshair.isChecked()
-                self.mCrosshairLineH.setVisible(b)
-                self.mCrosshairLineV.setVisible(b)
-                self.mCrosshairLineH.pen.setColor(self.mInfoColor)
-                self.mCrosshairLineV.pen.setColor(self.mInfoColor)
-                self.mCrosshairLineV.setPos(mousePoint.x())
-                self.mCrosshairLineH.setPos(mousePoint.y())
 
 
     def speclib(self):
diff --git a/timeseriesviewer/stackedbandinput.py b/timeseriesviewer/stackedbandinput.py
new file mode 100644
index 0000000000000000000000000000000000000000..8cc4e547afba109af751da9566b4d9cb9560ba70
--- /dev/null
+++ b/timeseriesviewer/stackedbandinput.py
@@ -0,0 +1,832 @@
+# -*- coding: utf-8 -*-
+# noinspection PyPep8Naming
+"""
+***************************************************************************
+    stackedbandinput.py
+
+    Sometimes time-series-data is written out as stacked band images, having one observation per band.
+    This module helps to use such data as EOTS input.
+    ---------------------
+    Date                 : June 2018
+    Copyright            : (C) 2018 by Benjamin Jakimow
+    Email                : benjamin.jakimow@geo.hu-berlin.de
+***************************************************************************
+*                                                                         *
+*   This program is free software; you can redistribute it and/or modify  *
+*   it under the terms of the GNU General Public License as published by  *
+*   the Free Software Foundation; either version 2 of the License, or     *
+*   (at your option) any later version.                                   *
+*                                                                         *
+***************************************************************************
+"""
+
+import os, re, tempfile, pickle, copy, shutil, locale, uuid, csv, io
+from xml.etree import ElementTree
+from collections import OrderedDict
+from qgis.core import *
+from qgis.gui import *
+from qgis.utils import qgsfunction
+from qgis.PyQt.QtCore import *
+from qgis.PyQt.QtGui import *
+from qgis.PyQt.QtWidgets import *
+from qgis.core import QgsField, QgsFields, QgsFeature, QgsMapLayer, QgsVectorLayer, QgsConditionalStyle
+from qgis.gui import QgsMapCanvas, QgsDockWidget
+from pyqtgraph.widgets.PlotWidget import PlotWidget
+from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem
+from pyqtgraph.graphicsItems.PlotItem import PlotItem
+import pyqtgraph.functions as fn
+import numpy as np
+from osgeo import gdal, gdal_array
+import numpy as np
+from timeseriesviewer.utils import *
+from timeseriesviewer.virtualrasters import *
+from timeseriesviewer.models import *
+from timeseriesviewer.dateparser import *
+from timeseriesviewer.plotstyling import PlotStyle, PlotStyleDialog, MARKERSYMBOLS2QGIS_SYMBOLS
+import timeseriesviewer.mimedata as mimedata
+
+
+def datesFromDataset(dataset:gdal.Dataset)->list:
+
+    nb = dataset.RasterCount
+
+    def checkDates(dateList):
+        if not len(dateList) == nb:
+            return False
+        for d in dateList:
+            if not isinstance(d, np.datetime64):
+                return False
+        return True
+
+    searchedKeys = []
+    searchedKeys.append(re.compile('aquisition[ ]*dates$', re.I))
+    searchedKeys.append(re.compile('observation[ ]*dates$', re.I))
+    searchedKeys.append(re.compile('wavelength$', re.I))
+
+    #1. Check Metadata
+    for domain in dataset.GetMetadataDomainList():
+        domainData = dataset.GetMetadata_Dict(domain)
+        assert isinstance(domainData, dict)
+
+        for key, values in domainData.items():
+            for regex in searchedKeys:
+                if regex.search(key.strip()):
+                    values = re.sub('[{}]', '', values)
+                    values = values.split(',')
+                    dateValues = [extractDateTimeGroup(t) for t in values]
+                    if checkDates(dateValues):
+                        return dateValues
+
+
+    #2. Check Band Names
+    bandDates = [extractDateTimeGroup(dataset.GetRasterBand(b+1).GetDescription()) for b in range(nb)]
+    bandDates = [b for b in bandDates if isinstance(b, np.datetime64)]
+    if checkDates(bandDates):
+        return bandDates
+
+    return []
+
+class InputStackInfo(object):
+
+    def __init__(self, dataset):
+        if isinstance(dataset, str):
+            #test ENVI header first
+            basename = os.path.splitext(dataset)[0]
+            ds = None
+            if os.path.isfile(basename+'.hdr'):
+                ds = gdal.OpenEx(dataset, allowed_drivers=['ENVI'])
+            if not isinstance(ds, gdal.Dataset):
+                ds = gdal.Open(dataset)
+            if not isinstance(ds, gdal.Dataset):
+                raise Exception('Unable to open {}'.format(dataset))
+
+            dataset = ds
+            del ds
+
+        assert isinstance(dataset, gdal.Dataset)
+
+        self.mMetadataDomains = dataset.GetMetadataDomainList()
+        self.mMetaData = OrderedDict()
+
+        for domain in self.mMetadataDomains:
+            self.mMetaData[domain] = dataset.GetMetadata_Dict(domain)
+
+        self.ns = dataset.RasterXSize
+        self.nl = dataset.RasterYSize
+        self.nb = dataset.RasterCount
+
+        self.wkt = dataset.GetProjection()
+        self.gt = dataset.GetGeoTransform()
+
+        self.colorTable = dataset.GetRasterBand(1).GetColorTable()
+        self.classNames = dataset.GetRasterBand(1).GetCategoryNames()
+
+        self.path = dataset.GetFileList()[0]
+
+        self.outputBandName = os.path.basename(self.path)
+        if len(self.outputBandName) == 0:
+            self.outputBandName = ''
+
+        self.bandnames = []
+        self.nodatavalues = []
+        for b in range(self.nb):
+            band = dataset.GetRasterBand(b+1)
+            assert isinstance(band, gdal.Band)
+            self.bandnames.append(band.GetDescription())
+            self.nodatavalues.append(band.GetNoDataValue())
+
+
+        self.mDates = datesFromDataset(dataset)
+
+    def __len__(self):
+        return len(self.mDates)
+
+    def dates(self)->list:
+        """Returns a list of dates"""
+        return self.mDates
+
+
+    def structure(self):
+        return (self.ns, self.nl, self.nb, self.gt, self.wkt)
+
+    def wavelength(self):
+        return self.mMetaData[''].get('wavelength')
+
+    def setWavelength(self, wl):
+        self.mMetaData['']['wavelength'] = wl
+
+
+class OutputVRTDescription(object):
+    """
+    Descrbies an output VRT
+    """
+
+    def __init__(self, path:str, date:np.datetime64):
+        super(OutputVRTDescription, self).__init__()
+        self.mPath = path
+        self.mDate = date
+
+
+    def setPath(self, path:str):
+        self.mPath = path
+
+
+
+class InputStackTableModel(QAbstractTableModel):
+
+
+
+    def __init__(self, parent=None):
+
+        super(InputStackTableModel, self).__init__(parent)
+        self.mStackImages = []
+
+        self.cn_source = 'Source'
+        self.cn_dates = 'Dates'
+        self.cn_crs = 'GT + CRS'
+        self.cn_ns = 'ns'
+        self.cn_nl = 'nl'
+        self.cn_nb = 'nb'
+        self.cn_name = 'Band Name'
+        self.cn_wl = 'Wavelength'
+        self.mColumnNames = [self.cn_source, self.cn_dates, self.cn_name, self.cn_wl, self.cn_ns, self.cn_nl, self.cn_nb, self.cn_crs]
+
+        self.mColumnTooltips = {}
+        self.mColumnTooltips[self.cn_source] = 'Stack source uri / file path'
+        self.mColumnTooltips[self.cn_crs] = 'Geo-Transformation + Coordinate Reference System'
+        self.mColumnTooltips[self.cn_ns] = 'Number of samples / pixel in horizontal direction'
+        self.mColumnTooltips[self.cn_nl] = 'Number of lines / pixel in vertical direction'
+        self.mColumnTooltips[self.cn_nb] = 'Number of bands'
+        self.mColumnTooltips[self.cn_name] = 'Prefix of band name in output image'
+        self.mColumnTooltips[self.cn_wl] = 'Wavelength in output image'
+        self.mColumnTooltips[self.cn_dates] = 'Identified dates'
+
+    def __len__(self):
+        return len(self.mStackImages)
+
+    def __iter__(self):
+        return iter(self.mStackImages)
+
+    def columnName(self, i) -> str:
+        if isinstance(i, QModelIndex):
+            i = i.column()
+        return self.mColumnNames[i]
+
+
+
+    def dateInfo(self):
+        """
+        Returns a list with all extracted dates and a list of date in common between all datasets
+        :return: [all dates], [dates in common]
+        """
+        if len(self) == 0:
+            return [],[]
+        datesTotal = set()
+        datesInCommon = None
+        for i, f in enumerate(self.mStackImages):
+            assert isinstance(f, InputStackInfo)
+
+            dates = f.dates()
+            if datesInCommon is None:
+                datesInCommon = set(dates)
+            else:
+                datesInCommon = datesInCommon.intersection(dates)
+
+            datesTotal = datesTotal.union(f.dates())
+
+        return sorted(list(datesTotal)), sorted(list(datesInCommon))
+
+    def flags(self, index):
+        if index.isValid():
+            columnName = self.columnName(index)
+            flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
+            if columnName in [self.cn_name, self.cn_wl]: #allow check state
+                flags = flags | Qt.ItemIsEditable
+
+            return flags
+            #return item.qt_flags(index.column())
+        return None
+
+    def headerData(self, col, orientation, role):
+        if Qt is None:
+            return None
+        if orientation == Qt.Horizontal:
+            cname = self.mColumnNames[col]
+            if role == Qt.DisplayRole:
+                return cname
+            elif role == Qt.ToolTipRole:
+                return self.mColumnTooltips.get(cname)
+        elif orientation == Qt.Vertical and role == Qt.DisplayRole:
+            return col
+        return None
+
+
+    def rowCount(self, parent=None):
+        return len(self.mStackImages)
+
+    def columnCount(self, parent: QModelIndex):
+        return len(self.mColumnNames)
+
+    def insertSources(self, paths, i=None):
+        """
+        Inserts new datasources
+        :param paths: [list-of-datasources]
+        :param i: index where to add the first datasource.
+        """
+
+        if i == None:
+            i = self.rowCount()
+
+        if not isinstance(paths, list):
+            paths = [paths]
+
+        infos = [InputStackInfo(p) for p in paths]
+        if len(infos) > 0:
+
+            self.beginInsertRows(QModelIndex(), i, i+len(infos)-1)
+            for j, info in enumerate(infos):
+                assert isinstance(info, InputStackInfo)
+                if len(info.outputBandName) == 0:
+                    info.outputBandName = 'Band {}'.format(i+j+1)
+                self.mStackImages.insert(i+j, info)
+            self.endInsertRows()
+
+    def removeSources(self, stackInfos:list):
+
+        for stackInfo in stackInfos:
+            assert stackInfo in self.mStackImages
+
+        for stackInfo in stackInfos:
+            assert isinstance(stackInfo, InputStackInfo)
+
+            idx = self.info2index(stackInfo)
+
+            self.beginRemoveRows(QModelIndex(), idx.row(), idx.row())
+            self.mStackImages.remove(stackInfo)
+            self.endRemoveRows()
+
+    def isValid(self):
+        l = len(self.mStackImages)
+        if l == 0:
+            return False
+        ref = self.mStackImages[0]
+        assert isinstance(ref, InputStackInfo)
+
+        #all input stacks need to have the same characteristic
+        for stackInfo in self.mStackImages[1:]:
+            assert isinstance(stackInfo, InputStackInfo)
+            if not ref.dates() == stackInfo.dates():
+                return False
+            if not ref.structure() == stackInfo.structure():
+                return False
+        return True
+
+
+    def index2info(self, index:QModelIndex) -> InputStackInfo:
+        return self.mStackImages[index.row()]
+
+    def info2index(self, info:InputStackInfo) -> QModelIndex:
+        r = self.mStackImages.index(info)
+        return self.createIndex(r,0, info)
+
+    def data(self, index: QModelIndex, role: int):
+        if not index.isValid():
+            return None
+
+        info = self.mStackImages[index.row()]
+        assert isinstance(info, InputStackInfo)
+        cname = self.columnName(index)
+
+        if role in [Qt.DisplayRole, Qt.ToolTipRole]:
+            if cname == self.cn_source:
+                return info.path
+            if cname == self.cn_dates:
+                dates = info.dates()
+                if role == Qt.DisplayRole:
+                    return len(dates)
+                if role == Qt.ToolTipRole:
+                    if len(dates) == 0:
+                        return 'No dates identified. Can not use this image as input'
+                    else:
+                        if len(dates) > 11:
+                            dates = dates[0:10] + ['...']
+                        return '\n'.join([str(d) for d in dates])
+
+
+            if cname == self.cn_ns:
+                return info.ns
+            if cname == self.cn_nl:
+                return info.nl
+            if cname == self.cn_nb:
+                return info.nb
+            if cname == self.cn_crs:
+                return '{} {}'.format(info.gt, info.wkt)
+            elif cname == self.cn_wl:
+                return info.mMetaData[''].get('wavelength')
+            elif cname == self.cn_name:
+                return info.outputBandName
+
+        if role == Qt.EditRole:
+            if cname == self.cn_wl:
+                return info.mMetaData[''].get('wavelength')
+            elif cname == self.cn_name:
+                return info.outputBandName
+
+        if role == Qt.BackgroundColorRole:
+            if cname in [self.cn_name, self.cn_wl]:
+                return QColor('yellow')
+
+        return None
+
+    def setData(self, index: QModelIndex, value, role: int):
+
+        if not index.isValid():
+            return None
+
+        info = self.index2info(index)
+        cname = self.columnName(index)
+
+        changed = False
+        if role == Qt.EditRole:
+            if cname == self.cn_name:
+                if isinstance(value, str) and len(value) > 0:
+                    info.outputBandName = value
+                    changed = True
+            elif cname == self.cn_wl:
+                info.setWavelength(value)
+                changed = True
+        if changed:
+            self.dataChanged.emit(index, index)
+        return changed
+
+class OutputImageModel(QAbstractTableModel):
+
+    def __init__(self, parent=None):
+        super(OutputImageModel, self).__init__(parent)
+
+
+        self.cn_uri = 'Path'
+        self.cn_date = 'Date'
+        self.mOutputImages = []
+
+        self.mColumnNames = [self.cn_date, self.cn_uri]
+        self.mColumnTooltips = {}
+        self.mColumnTooltips[self.cn_uri] = 'Output location'
+        self.masterVRT_DateLookup = {}
+        self.masterVRT_SourceBandTemplates = {}
+        self.masterVRT_InputStacks = None
+        self.masterVRT_XML = None
+        self.mOutputDir = '/vsimem/'
+        self.mOutputPrefix = 'date'
+
+
+
+    def headerData(self, col, orientation, role):
+        if Qt is None:
+            return None
+        if orientation == Qt.Horizontal:
+            cname = self.mColumnNames[col]
+            if role == Qt.DisplayRole:
+                return cname
+            elif role == Qt.ToolTipRole:
+                return self.mColumnTooltips.get(cname)
+        elif orientation == Qt.Vertical and role == Qt.DisplayRole:
+            return col
+        return None
+
+    def createVRTUri(self, date:np.datetime64):
+
+        path = os.path.join(self.mOutputDir, self.mOutputPrefix)
+        path = '{}{}.vrt'.format(path, date)
+
+        return path
+
+    def clearOutputs(self):
+        self.beginRemoveRows(QModelIndex(), 0, self.rowCount() - 1)
+        self.mOutputImages = []
+        self.endRemoveRows()
+
+    def setMultiStackSources(self, listOfInputStacks:list, dates:list):
+
+        self.clearOutputs()
+
+        if listOfInputStacks is None or len(listOfInputStacks) == 0:
+            return
+        if dates is None or len(dates) == 0:
+            return
+        for s in listOfInputStacks:
+            assert isinstance(s, InputStackInfo)
+        dates = sorted(dates)
+
+        listOfInputStacks = [s for s in listOfInputStacks if len(s) > 0]
+        numberOfOutputVRTBands = len(listOfInputStacks)
+        self.masterVRT_DateLookup.clear()
+        self.masterVRT_InputStacks = listOfInputStacks
+        self.masterVRT_SourceBandTemplates.clear()
+        #dates = set()
+        #for s in listOfInputStacks:
+        #    for d in s.dates():
+        #        dates.add(d)
+        #dates = sorted(list(dates))
+
+        #create a LUT to get the stack indices for a related date (not each stack might contain a band for each date)
+
+        for stackIndex, s in enumerate(listOfInputStacks):
+            for bandIndex, bandDate in enumerate(s.dates()):
+                if bandDate not in self.masterVRT_DateLookup.keys():
+                    self.masterVRT_DateLookup[bandDate] = []
+                self.masterVRT_DateLookup[bandDate].append((stackIndex, bandIndex))
+
+        #create VRT Template XML
+        VRT = VRTRaster()
+        wavelength = []
+        for stackIndex, stack in enumerate(listOfInputStacks):
+            assert isinstance(stack, InputStackInfo)
+            vrtBand = VRTRasterBand()
+            vrtBand.setName(stack.outputBandName)
+            vrtSrc = VRTRasterInputSourceBand(stack.path, 0)
+            vrtBand.addSource(vrtSrc)
+            wavelength.append(stack.wavelength())
+            VRT.addVirtualBand(vrtBand)
+
+
+
+        pathVSITmp = '/vsimem/temp.vrt'
+        dsVRT = VRT.saveVRT(pathVSITmp)
+        dsVRT.SetMetadataItem('acquisition date', 'XML_REPLACE_DATE')
+
+        if None not in wavelength:
+            dsVRT.SetMetadataItem('wavelength', ','.join(str(wl) for wl in wavelength))
+            dsVRT.SetMetadataItem('wavelength units', 'Nanometers')
+
+        for stackIndex, stack in enumerate(listOfInputStacks):
+            band = dsVRT.GetRasterBand(stackIndex+1)
+            assert isinstance(band, gdal.Band)
+            assert isinstance(stack, InputStackInfo)
+            if stack.colorTable:
+                band.SetColorTable(stack.colorTable)
+            if stack.classNames:
+                band.SetCategoryNames(stack.classNames)
+
+        dsVRT.FlushCache()
+        drv = dsVRT.GetDriver()
+        masterVRT_XML = read_vsimem(pathVSITmp).decode('utf-8')
+        drv.Delete(pathVSITmp)
+        outputVRTs = []
+
+
+        eTree = ElementTree.fromstring(masterVRT_XML)
+        for iBand, elemBand in enumerate(eTree.findall('VRTRasterBand')):
+            sourceElements  = elemBand.findall('ComplexSource') + elemBand.findall('SimpleSource')
+            assert len(sourceElements) == 1
+            self.masterVRT_SourceBandTemplates[iBand] = copy.deepcopy(sourceElements[0])
+            elemBand.remove(sourceElements[0])
+
+        for date in dates:
+            assert isinstance(date, np.datetime64)
+            path = self.createVRTUri(date)
+            outputDescription = OutputVRTDescription(path, date)
+            outputVRTs.append(outputDescription)
+
+        self.masterVRT_XML = eTree
+
+
+        self.beginInsertRows(QModelIndex(), 0, len(outputVRTs)-1)
+        self.mOutputImages = outputVRTs[:]
+        self.endInsertRows()
+
+    def setOutputDir(self, path:str):
+        self.mOutputDir = path
+        self.updateOutputURIs()
+
+    def setOutputPrefix(self, basename:str):
+        self.mOutputPrefix = basename
+        self.updateOutputURIs()
+
+    def updateOutputURIs(self):
+        c = self.mColumnNames.index(self.cn_uri)
+        ul = self.createIndex(0, c)
+        lr = self.createIndex(self.rowCount()-1, c)
+
+        for outputVRT in self:
+            assert isinstance(outputVRT, OutputVRTDescription)
+            outputVRT.setPath(self.createVRTUri(outputVRT.mDate))
+        self.dataChanged.emit(ul, lr)
+
+
+    def __len__(self):
+        return len(self.mOutputImages)
+
+    def __iter__(self):
+        return iter(self.mOutputImages)
+
+    def rowCount(self, parent=None) -> int:
+        return len(self.mOutputImages)
+
+    def columnCount(self, parent=None) -> int:
+        return len(self.mColumnNames)
+
+    def columnName(self, i) -> str:
+        if isinstance(i, QModelIndex):
+            i = i.column()
+        return self.mColumnNames[i]
+
+    def columnIndex(self, columnName:str)-> QModelIndex:
+        c = self.mColumnNames.index(columnName)
+        return self.createIndex(0, c)
+
+    def index2vrt(self, index:QModelIndex) -> OutputVRTDescription:
+        return self.mOutputImages[index.row()]
+
+    def vrt2index(self, vrt:OutputVRTDescription) -> QModelIndex:
+        i = self.mOutputImages[vrt]
+        return self.createIndex(i, 0, vrt)
+
+    def data(self, index: QModelIndex, role: int):
+
+        if not index.isValid():
+            return None
+
+        cname = self.columnName(index)
+        vrt = self.index2vrt(index)
+        if role in [Qt.DisplayRole, Qt.ToolTipRole]:
+            if cname == self.cn_uri:
+                return vrt.mPath
+            if cname == self.cn_date:
+                return str(vrt.mDate)
+
+    def vrtXML(self, outputDefinition:OutputVRTDescription, asElementTree=False) -> str:
+        """
+        Create the VRT XML related to an outputDefinition
+        :param outputDefinition:
+        :return: str
+        """
+
+        # re.search(tmpXml, '<MDI key='>')
+
+        # xml = copy.deepcopy(eTree)
+        if self.masterVRT_XML is None:
+            return None
+        #xmlTree = ElementTree.fromstring(self.masterVRT_XML)
+        xmlTree = copy.deepcopy(self.masterVRT_XML)
+
+        # set metadata
+        for elem in xmlTree.findall('Metadata/MDI'):
+            if elem.attrib['key'] == 'acquisition date':
+                elem.text = str(outputDefinition.mDate)
+
+        # insert required rasterbands
+        requiredBands = self.masterVRT_DateLookup[outputDefinition.mDate]
+
+        xmlVRTBands = xmlTree.findall('VRTRasterBand')
+
+        for t in requiredBands:
+            stackIndex, stackBandIndex = t
+
+            stackSourceXMLTemplate = copy.deepcopy(self.masterVRT_SourceBandTemplates[stackIndex])
+            stackSourceXMLTemplate.find('SourceBand').text = str(stackBandIndex+1)
+            xmlVRTBands[stackIndex].append(stackSourceXMLTemplate)
+
+        if asElementTree:
+            return xmlTree
+        else:
+            return ElementTree.tostring(xmlTree).decode('utf-8')
+
+
+
+
+
+class StackedBandInputDialog(QDialog, loadUI('stackedinputdatadialog.ui')):
+
+    def __init__(self, parent=None):
+
+        super(StackedBandInputDialog, self).__init__(parent=parent)
+        self.setupUi(self)
+        self.setWindowTitle('Stacked Time Series Data Input')
+        self.mWrittenFiles = []
+
+        self.tableModelInputStacks = InputStackTableModel()
+        self.tableModelInputStacks.rowsInserted.connect(self.updateOutputs)
+        self.tableModelInputStacks.dataChanged.connect(self.updateOutputs)
+        self.tableModelInputStacks.rowsRemoved.connect(self.updateOutputs)
+        self.tableModelInputStacks.rowsInserted.connect(self.updateInputInfo)
+        self.tableModelInputStacks.rowsRemoved.connect(self.updateInputInfo)
+        self.tableViewSourceStacks.setModel(self.tableModelInputStacks)
+
+        self.tableModelOutputImages = OutputImageModel()
+        self.tableModelOutputImages.rowsInserted.connect(self.updateOutputInfo)
+        self.tableModelOutputImages.rowsRemoved.connect(self.updateOutputInfo)
+        self.tableModelOutputImages.dataChanged.connect(self.updateOutputInfo)
+        self.tableViewOutputImages.setModel(self.tableModelOutputImages)
+        self.tableViewOutputImages.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
+
+        self.buttonGroupDateMode.buttonClicked.connect(self.updateOutputs)
+        self.buttonGroupOutputLocation.buttonClicked.connect(self.updateOutputs)
+
+        self.cbOpenInQGIS.setEnabled(isinstance(qgis.utils.iface, QgisInterface))
+        self.tbFilePrefix.textChanged.connect(self.tableModelOutputImages.setOutputPrefix)
+        self.tbFilePrefix.setText('img')
+
+        self.fileWidgetOutputDir.setStorageMode(QgsFileWidget.GetDirectory)
+        self.fileWidgetOutputDir.fileChanged.connect(self.tableModelOutputImages.setOutputDir)
+
+        sm = self.tableViewSourceStacks.selectionModel()
+        assert isinstance(sm, QItemSelectionModel)
+        sm.selectionChanged.connect(self.onSourceStackSelectionChanged)
+        self.onSourceStackSelectionChanged([],[])
+
+        sm = self.tableViewOutputImages.selectionModel()
+        assert isinstance(sm, QItemSelectionModel)
+        sm.selectionChanged.connect(self.onOutputImageSelectionChanged)
+
+        self.initActions()
+
+    def writtenFiles(self):
+        """
+        Returns the files written after pressing the "Save" button.
+        :return: [list-of-written-file-paths]
+        """
+        return self.mWrittenFiles[:]
+
+    def updateOutputs(self, *args):
+        """
+        Updates the output file information
+        """
+        self.tableModelOutputImages.clearOutputs()
+        inputStacks = self.tableModelInputStacks.mStackImages
+        datesTotal, datesIntersection = self.tableModelInputStacks.dateInfo()
+        if self.rbDatesAll.isChecked():
+            self.tableModelOutputImages.setMultiStackSources(inputStacks, datesTotal)
+        elif self.rbDatesIntersection.isChecked():
+            self.tableModelOutputImages.setMultiStackSources(inputStacks, datesIntersection)
+
+        if self.rbSaveInMemory.isChecked():
+            self.tableModelOutputImages.setOutputDir('/vsimem/')
+        elif self.rbSaveInDirectory.isChecked():
+            self.tableModelOutputImages.setOutputDir(self.fileWidgetOutputDir.filePath())
+
+    def updateInputInfo(self):
+        """
+        Updates the input file information
+        """
+
+        n = len(self.tableModelInputStacks)
+        datesTotal, datesInCommon = self.tableModelInputStacks.dateInfo()
+        info = None
+        if n > 0:
+            nAll = len(datesTotal)
+            nInt = len(datesInCommon)
+            info = '{} Input Images with {} dates in total, {} in intersection'.format(n, nAll, nInt)
+
+        self.tbInfoInputImages.setText(info)
+
+    def updateOutputInfo(self):
+
+        n = len(self.tableModelOutputImages)
+        info = None
+        if n > 0:
+            nb = len(self.tableModelOutputImages.masterVRT_InputStacks)
+            info = '{} output images with {} bands to {}'.format(n, nb, self.tableModelOutputImages.mOutputDir)
+        self.buttonBox.button(QDialogButtonBox.Save).setEnabled(n > 0)
+        self.tbInfoOutputImages.setText(info)
+
+    def initActions(self):
+        """
+        Initializes QActions and what they trigger.
+        """
+
+        self.actionAddSourceStack.triggered.connect(self.onAddSource)
+        self.actionRemoveSourceStack.triggered.connect(self.onRemoveSources)
+
+        self.btnAddSourceStack.setDefaultAction(self.actionAddSourceStack)
+        self.btnRemoveSourceStack.setDefaultAction(self.actionRemoveSourceStack)
+
+        self.buttonBox.button(QDialogButtonBox.Save).clicked.connect(self.accept)
+        self.buttonBox.button(QDialogButtonBox.Cancel).clicked.connect(self.close)
+
+    def onAddSource(self, *args):
+        """
+        Reacts on new added datasets
+        """
+        from timeseriesviewer import SETTINGS
+        defDir = SETTINGS.value('DIR_FILESEARCH')
+        filters = QgsProviderRegistry.instance().fileVectorFilters()
+        files, filter = QFileDialog.getOpenFileNames(directory=defDir, filter=filters)
+
+        if len(files) > 0:
+            self.tableModelInputStacks.insertSources(files)
+        s = ""
+
+
+    def addSources(self, paths):
+        """
+        Adds new datasources
+        :param paths: [list-of-new-datasources]
+        :return:
+        """
+        self.tableModelInputStacks.insertSources(paths)
+
+    def onRemoveSources(self, *args):
+
+        model = self.tableViewSourceStacks.selectionModel()
+        assert isinstance(model, QItemSelectionModel)
+
+        infos = [self.tableModelInputStacks.index2info(idx) for idx in model.selectedRows()]
+        self.tableModelInputStacks.removeSources(infos)
+
+    def onSourceStackSelectionChanged(self, selected, deselected):
+
+        self.actionRemoveSourceStack.setEnabled(len(selected) > 0)
+
+
+    def onOutputImageSelectionChanged(self, selected, deselected):
+
+        if len(selected) > 0:
+            idx = selected.indexes()[0]
+
+            vrtOutput = self.tableModelOutputImages.index2vrt(idx)
+            assert isinstance(vrtOutput, OutputVRTDescription)
+            xml = self.tableModelOutputImages.vrtXML(vrtOutput)
+            self.tbXMLPreview.setPlainText(xml)
+        else:
+            self.tbXMLPreview.setPlainText(None)
+            s = ""
+
+
+    def saveImages(self):
+        """
+        Write the VRT images
+        :return: [list-of-written-file-paths]
+        """
+
+
+        nTotal = len(self.tableModelOutputImages)
+        if nTotal == 0:
+            return
+
+        writtenFiles = []
+        self.progressBar.setValue(0)
+        from timeseriesviewer.virtualrasters import write_vsimem, read_vsimem
+        for i, outVRT in enumerate(self.tableModelOutputImages):
+            assert isinstance(outVRT, OutputVRTDescription)
+            xml = self.tableModelOutputImages.vrtXML(outVRT)
+
+            if outVRT.mPath.startswith('/vsimem/'):
+                write_vsimem(outVRT.mPath, xml)
+            else:
+                f = open(outVRT.mPath, 'w', encoding='utf-8')
+                f.write(xml)
+                f.flush()
+                f.close()
+
+            writtenFiles.append(outVRT.mPath)
+
+            self.progressBar.setValue(int(100. * i / nTotal))
+
+        QTimer.singleShot(500, lambda: self.progressBar.setValue(0))
+
+        if self.cbOpenInQGIS.isEnabled() and self.cbOpenInQGIS.isChecked():
+            mapLayers = [QgsRasterLayer(p) for p in writtenFiles]
+            QgsProject.instance().addMapLayers(mapLayers, addToLegend=True)
+        self.mWrittenFiles.extend(writtenFiles)
+        return writtenFiles
\ No newline at end of file
diff --git a/timeseriesviewer/temporalprofiles3dGL.py b/timeseriesviewer/temporalprofiles3dGL.py
index bb6dcd1562627a103cbbe507439794b64105c1e9..a9068258794d28d370078a3052003ea6c3fe7ce2 100644
--- a/timeseriesviewer/temporalprofiles3dGL.py
+++ b/timeseriesviewer/temporalprofiles3dGL.py
@@ -750,7 +750,6 @@ if __name__ == '__main__':
 
     app = initQgisApplication()
     from timeseriesviewer.utils import *
-    from timeseriesviewer.tests import TestObjects
 
     w = ViewWidget3D()
     w.show()
diff --git a/timeseriesviewer/timeseries.py b/timeseriesviewer/timeseries.py
index 577a6c41e407486831e77333d618d7db06a11e66..c02c95dd798c57a44824a00a5c08a6009c63c21e 100644
--- a/timeseriesviewer/timeseries.py
+++ b/timeseriesviewer/timeseries.py
@@ -87,6 +87,9 @@ def getDS(pathOrDataset):
 
 
 class SensorInstrument(QObject):
+    """
+    Describes a Sensor Configuration
+    """
     SensorNameSettingsPrefix = 'SensorName.'
     sigNameChanged = pyqtSignal(str)
 
@@ -98,9 +101,7 @@ class SensorInstrument(QObject):
                             'swIR1':1650,
                             'swIR2':2150
                             })
-    """
-    Describes a Sensor Configuration
-    """
+
     def __init__(self, pathImg, sensor_name=None):
         super(SensorInstrument, self).__init__()
 
@@ -126,9 +127,9 @@ class SensorInstrument(QObject):
             self.wavelengths = np.asarray(wl)
 
 
-        self._id = '{}b{}m'.format(self.nb, self.px_size_x)
+        self.mId = '{}b{}m'.format(self.nb, self.px_size_x)
         if wl is not None:
-            self._id += ';'.join([str(w) for w in self.wavelengths])+str(self.wavelengthUnits)
+            self.mId += ';'.join([str(w) for w in self.wavelengths]) + str(self.wavelengthUnits)
 
 
         if sensor_name is None:
@@ -141,10 +142,10 @@ class SensorInstrument(QObject):
 
 
     def id(self):
-        return self._id
+        return self.mId
 
     def _sensorSettingsKey(self):
-        return SensorInstrument.SensorNameSettingsPrefix+self._id
+        return SensorInstrument.SensorNameSettingsPrefix+self.mId
     def setName(self, name):
         self._name = name
 
@@ -182,6 +183,10 @@ class SensorInstrument(QObject):
 
 
     def wavelengthsDefined(self):
+        """
+        Returns if wavelenght and wavelength units are defined
+        :return: bool
+        """
         return self.wavelengths is not None and \
                 self.wavelengthUnits is not None
 
@@ -199,6 +204,10 @@ class SensorInstrument(QObject):
         return str(self.__class__) +' ' + self.name()
 
     def getDescription(self):
+        """
+        Returns a human-readable description
+        :return: str
+        """
         info = []
         info.append(self.name())
         info.append('{} Bands'.format(self.nb))
@@ -213,42 +222,45 @@ class SensorInstrument(QObject):
         return '\n'.join(info)
 
 
-def verifyInputImage(path, vrtInspection=''):
-    if path is None or not isinstance(path, str):
-        return None
-    ds = gdal.Open(path)
+def verifyInputImage(datasource):
+    """
+    Checks if an image source can be uses as TimeSeriesDatum, i.e. if it can be read by gdal.Open() and
+    if we can extract an observation date as numpy.datetime64.
+    :param datasource: str with data source uri or gdal.Dataset
+    :return: bool
+    """
 
-    if not ds:
-        #logger.error('{}GDAL unable to open: '.format(vrtInspection, path))
+    if datasource is None:
+        return None
+    if isinstance(datasource, str):
+        datasource = gdal.Open(datasource)
+    if not isinstance(datasource, gdal.Dataset):
         return False
 
-    if ds.RasterCount == 0 and len(ds.GetSubDatasets()) > 0:
+    if datasource.RasterCount == 0 and len(datasource.GetSubDatasets()) > 0:
         #logger.error('Can not open container {}.\nPlease specify a subdataset'.format(path))
         return False
 
-    if ds.GetDriver().ShortName == 'VRT':
-        vrtInspection = 'VRT Inspection {}\n'.format(path)
-        nextFiles = set(ds.GetFileList()) - set([path])
-        validSrc = [verifyInputImage(p, vrtInspection=vrtInspection) for p in nextFiles]
-        if not all(validSrc):
-            return False
+    if datasource.GetDriver().ShortName == 'VRT':
+        files = datasource.GetFileList()
+        if len(files) > 0:
+            for f in files:
+                subDS = gdal.Open(f)
+                if not isinstance(subDS, gdal.Dataset):
+                    return False
 
     from timeseriesviewer.dateparser import parseDateFromDataSet
-    date = parseDateFromDataSet(ds)
+    date = parseDateFromDataSet(datasource)
     if date is None:
         return False
 
     return True
 
-def pixel2coord(gt, x, y):
-    """Returns global coordinates from pixel x, y coords"""
-    """https://scriptndebug.wordpress.com/2014/11/24/latitudelongitude-of-each-pixel-using-python-and-gdal/"""
-    xoff, a, b, yoff, d, e = gt
-    xp = a * x + b * y + xoff
-    yp = d * x + e * y + yoff
-    return (xp, yp)
-
 class TimeSeriesDatum(QObject):
+    """
+    The core entity of a TimeSeries. Contains a data source, an observation date (numpy.datetime64) and
+    the SensorInstrument the data source is linked to.
+    """
     @staticmethod
     def createFromPath(path):
         """
@@ -267,11 +279,6 @@ class TimeSeriesDatum(QObject):
 
         return tsd
 
-
-
-    """
-    Collects all data sets related to one sensor
-    """
     sigVisibilityChanged = pyqtSignal(bool)
     sigRemoveMe = pyqtSignal()
 
@@ -296,9 +303,9 @@ class TimeSeriesDatum(QObject):
 
 
         gt = ds.GetGeoTransform()
-
-        UL = QgsPointXY(*pixel2coord(gt, 0, 0))
-        LR = QgsPointXY(*pixel2coord(gt, self.ns, self.nl))
+        from timeseriesviewer.utils import px2geo
+        UL = QgsPointXY(*px2geo(QPoint(0,0), gt, pxCenter=False))
+        LR = QgsPointXY(*px2geo(QPoint(self.ns+1, self.nl+1), gt, pxCenter=False))
         self._spatialExtent = SpatialExtent(self.crs, UL, LR)
 
         self.srs_wkt = str(self.crs.toWkt())
@@ -307,27 +314,44 @@ class TimeSeriesDatum(QObject):
         self.mVisibility = True
 
 
-    def rank(self):
-        return self.timeSeries.index(self)
-
     def setVisibility(self, b):
+        """
+        Sets the visibility of the TimeSeriesDatum, i.e. whether linked MapCanvases will be shown to the user
+        :param b: bool
+        """
         old = self.mVisibility
         self.mVisibility = b
         if old != self.mVisibility:
             self.sigVisibilityChanged.emit(b)
 
     def isVisible(self):
+        """
+        Returns whether the TimeSeriesDatum is visible as MapCanvas
+        :return: bool
+        """
         return self.mVisibility
 
 
     def getDate(self):
+        """
+        Returns the observation date
+        :return: numpy.datetime64
+        """
         return np.datetime64(self.date)
 
 
     def getSpatialReference(self):
+        """
+        Returns the QgsCoordinateReferenceSystem of the data source.
+        :return: QgsCoordinateReferenceSystem
+        """
         return self.crs
 
     def spatialExtent(self):
+        """
+        Returns the SpatialExtent of the data source
+        :return: SpatialExtent
+        """
         return self._spatialExtent
 
     def __repr__(self):
@@ -354,7 +378,10 @@ class TimeSeriesTableView(QTableView):
         super(TimeSeriesTableView, self).__init__(parent)
 
     def contextMenuEvent(self, event):
-
+        """
+        Creates and shows an QMenu
+        :param event:
+        """
         menu = QMenu(self)
         a = menu.addAction('Copy value(s)')
         a.triggered.connect(self.onCopyValues)
@@ -365,6 +392,10 @@ class TimeSeriesTableView(QTableView):
         menu.popup(QCursor.pos())
 
     def onSetCheckState(self, checkState):
+        """
+        Sets a ChecState to all selected rows
+        :param checkState: Qt.CheckState
+        """
         indices = self.selectionModel().selectedIndexes()
         rows = sorted(list(set([i.row() for i in indices])))
         model = self.model()
@@ -374,6 +405,9 @@ class TimeSeriesTableView(QTableView):
                 model.setData(idx, checkState, Qt.CheckStateRole)
 
     def onCopyValues(self):
+        """
+        Copies selected cell values to the clipboard
+        """
         indices = self.selectionModel().selectedIndexes()
         model = self.model()
         if isinstance(model, TimeSeriesTableModel):
@@ -390,12 +424,13 @@ class TimeSeriesTableView(QTableView):
             QApplication.clipboard().setText(info)
         s = ""
 
-    def selectSelectedObservations(b):
-        assert isinstance(b, bool)
 
 
 
 class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')):
+    """
+    QgsDockWidget that shows the TimeSeries
+    """
     def __init__(self, parent=None):
         super(TimeSeriesDockUI, self).__init__(parent)
         self.setupUi(self)
@@ -414,6 +449,9 @@ class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')):
         self.setTimeSeries(None)
 
     def setStatus(self):
+        """
+        Updates the status of the TimeSeries
+        """
         from timeseriesviewer.timeseries import TimeSeries
         if isinstance(self.TS, TimeSeries):
             ndates = len(self.TS)
@@ -423,7 +461,13 @@ class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')):
                 msg += ', {} to {}'.format(str(self.TS[0].date), str(self.TS[-1].date))
             self.progressInfo.setText(msg)
 
-    def setProgressInfo(self, nDone, nMax, message=None):
+    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)
@@ -433,14 +477,25 @@ class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')):
             QTimer.singleShot(3000, lambda: self.setStatus())
 
     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)
 
     def selectedTimeSeriesDates(self):
+        """
+        Returns the TimeSeriesDatum selected by a user.
+        :return: [list-of-TimeSeriesDatum]
+        """
         if self.SM is not None:
             return [self.TSM.data(idx, Qt.UserRole) for idx in self.SM.selectedRows()]
         return []
 
     def setTimeSeries(self, TS):
+        """
+        Sets the TimeSeries to be shown in the TimeSeriesDockUI
+        :param TS: TimeSeries
+        """
         from timeseriesviewer.timeseries import TimeSeries
         self.TS = TS
         self.TSM = None
@@ -461,6 +516,9 @@ class TimeSeriesDockUI(QgsDockWidget, loadUI('timeseriesdock.ui')):
 
 
 class TimeSeries(QObject):
+    """
+    The sorted list of data sources that specify the time series
+    """
 
     sigTimeSeriesDatesAdded = pyqtSignal(list)
     sigTimeSeriesDatesRemoved = pyqtSignal(list)
@@ -495,9 +553,18 @@ class TimeSeries(QObject):
     _sep = ';'
 
     def sensors(self):
+        """
+        Returns the list of sensors derived from the TimeSeries data soures
+        :return: [list-of-SensorInstruments]
+        """
         return list(self.Sensors.keys())
 
     def loadFromFile(self, path, n_max=None):
+        """
+        Loads a CSV file with source images of a TimeSeries
+        :param path: str, Path of CSV file
+        :param n_max: optional, maximum number of files to load
+        """
 
         images = []
         masks = []
@@ -522,6 +589,11 @@ class TimeSeries(QObject):
 
 
     def saveToFile(self, path):
+        """
+        Saves the TimeSeries sources into a CSV file
+        :param path: str, path of CSV file
+        :return: path of CSV file
+        """
         if path is None or len(path) == 0:
             return None
 
@@ -541,7 +613,12 @@ class TimeSeries(QObject):
             messageLog('Time series source images written to {}'.format(path))
 
         return path
+
     def getPixelSizes(self):
+        """
+        Returns the pixel sizes of all SensorInstruments
+        :return: [list-of-QgsRectangles]
+        """
 
         r = []
         for sensor in self.Sensors.keys():
@@ -549,7 +626,13 @@ class TimeSeries(QObject):
         return r
 
         return None
+
     def getMaxSpatialExtent(self, crs=None):
+        """
+        Returns the maximum SpatialExtent of all images of the TimeSeries
+        :param crs: QgsCoordinateSystem to express the SpatialExtent coordinates.
+        :return:
+        """
         if len(self.data) == 0:
             return None
 
@@ -565,23 +648,31 @@ class TimeSeries(QObject):
             extent = extent.toCrs(crs)
         return extent
 
-
-    def tsdFromPath(self, path):
-        for tsd in self.data:
-            if tsd.pathImg == path:
-                return tsd
-        return False
-
     def getObservationDates(self):
+        """
+        Returns the observations dates of ecach TimeSeries data source
+        :return: [list-of-numpy.datetime64]
+        """
         return [tsd.getDate() for tsd in self.data]
 
     def getTSD(self, pathOfInterest):
+        """
+        Returns the TimeSeriesDatum related to an image source
+        :param pathOfInterest: str, image source uri
+        :return: TimeSeriesDatum
+        """
         for tsd in self.data:
             if tsd.pathImg == pathOfInterest:
                 return tsd
         return None
 
     def getTSDs(self, dateOfInterest=None, sensorOfInterest=None):
+        """
+        Returns a list of  TimeSeriesDatum of the TimeSeries. By default all TimeSeriesDatum will be returned.
+        :param dateOfInterest: numpy.datetime64 to return the TimeSeriesDatum for
+        :param sensorOfInterest: SensorInstrument of interest to return the [list-of-TimeSeriesDatum] for.
+        :return: [list-of-TimeSeriesDatum]
+        """
         tsds = self.data[:]
         if dateOfInterest:
             tsds = [tsd for tsd in tsds if tsd.getDate() == dateOfInterest]
@@ -590,16 +681,23 @@ class TimeSeries(QObject):
         return tsds
 
     def clear(self):
+        """
+        Removes all data sources from the TimeSeries (which will be empty after calling this routine).
+        """
         self.removeDates(self[:])
 
 
 
     def removeDates(self, TSDs):
+        """
+        Removes a list of TimeSeriesDatum
+        :param TSDs: [list-of-TimeSeriesDatum]
+        """
         removed = list()
         for TSD in TSDs:
             assert type(TSD) is TimeSeriesDatum
             self.data.remove(TSD)
-            TSD.timeSeries = None
+            TSD.createTimeSeries = None
             removed.append(TSD)
 
             S = TSD.sensor
@@ -612,6 +710,10 @@ class TimeSeries(QObject):
 
 
     def addTimeSeriesDates(self, timeSeriesDates):
+        """
+        Adds a list of TimeSeriesDatum
+        :param timeSeriesDates: [list-of-TimeSeriesDatum]
+        """
         assert isinstance(timeSeriesDates, list)
         added = list()
         for TSD in timeSeriesDates:
@@ -649,6 +751,10 @@ class TimeSeries(QObject):
 
 
     def addFiles(self, files):
+        """
+        Adds new data sources to the TimeSeries
+        :param files: [list-of-str]
+        """
         if isinstance(files, str):
             files = [files]
         assert isinstance(files, list)
@@ -656,7 +762,7 @@ class TimeSeries(QObject):
 
         nMax = len(files)
         nDone = 0
-        self.sigLoadingProgress.emit(0,nMax, 'Start loading {} files...'.format(nMax))
+        self.sigLoadingProgress.emit(0, nMax, 'Start loading {} files...'.format(nMax))
 
         for i, file in enumerate(files):
             t0 = np.datetime64('now')
diff --git a/timeseriesviewer/ui/resources.qrc b/timeseriesviewer/ui/resources.qrc
index 71a4f7f6395a9ccaa547565d128c4c4c44c5ea9b..fdf14936064b520d963ec187646c8c65358911a2 100644
--- a/timeseriesviewer/ui/resources.qrc
+++ b/timeseriesviewer/ui/resources.qrc
@@ -1,6 +1,5 @@
 <RCC>
   <qresource prefix="timeseriesviewer">
-    <file>../../ABOUT.txt</file>
     <file>icons/mIconTemporalProfileRefresh.svg</file>
     <file>icons/profile2speclib_auto.svg</file>
     <file>icons/mActionAddOgrLayer.svg</file>
diff --git a/timeseriesviewer/ui/stackedinputdatadialog.ui b/timeseriesviewer/ui/stackedinputdatadialog.ui
new file mode 100644
index 0000000000000000000000000000000000000000..58a7f330e303d36f53ff0b9b0b714be518347107
--- /dev/null
+++ b/timeseriesviewer/ui/stackedinputdatadialog.ui
@@ -0,0 +1,459 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Dialog</class>
+ <widget class="QDialog" name="Dialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>569</width>
+    <height>573</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Dialog</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QgsCollapsibleGroupBox" name="gbInputStacks">
+     <property name="sizePolicy">
+      <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+       <horstretch>0</horstretch>
+       <verstretch>1</verstretch>
+      </sizepolicy>
+     </property>
+     <property name="minimumSize">
+      <size>
+       <width>0</width>
+       <height>0</height>
+      </size>
+     </property>
+     <property name="title">
+      <string>Input Images (each band = one temporal observation)</string>
+     </property>
+     <property name="collapsed">
+      <bool>false</bool>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_4">
+      <item>
+       <layout class="QHBoxLayout" name="horizontalLayout">
+        <property name="topMargin">
+         <number>0</number>
+        </property>
+        <item>
+         <widget class="QToolButton" name="btnAddSourceStack">
+          <property name="text">
+           <string>...</string>
+          </property>
+          <property name="autoRaise">
+           <bool>true</bool>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <widget class="QToolButton" name="btnRemoveSourceStack">
+          <property name="text">
+           <string>...</string>
+          </property>
+          <property name="autoRaise">
+           <bool>true</bool>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <spacer name="horizontalSpacer_2">
+          <property name="orientation">
+           <enum>Qt::Horizontal</enum>
+          </property>
+          <property name="sizeHint" stdset="0">
+           <size>
+            <width>40</width>
+            <height>20</height>
+           </size>
+          </property>
+         </spacer>
+        </item>
+       </layout>
+      </item>
+      <item>
+       <widget class="QTableView" name="tableViewSourceStacks"/>
+      </item>
+      <item>
+       <widget class="QLineEdit" name="tbInfoInputImages">
+        <property name="autoFillBackground">
+         <bool>false</bool>
+        </property>
+        <property name="styleSheet">
+         <string notr="true">background-color: transparent;</string>
+        </property>
+        <property name="text">
+         <string/>
+        </property>
+        <property name="frame">
+         <bool>false</bool>
+        </property>
+        <property name="readOnly">
+         <bool>true</bool>
+        </property>
+        <property name="placeholderText">
+         <string>Add images with stacked observations to the list of input images</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QgsCollapsibleGroupBox" name="mGroupBox">
+     <property name="title">
+      <string>Output options</string>
+     </property>
+     <property name="collapsed">
+      <bool>false</bool>
+     </property>
+     <layout class="QGridLayout" name="gridLayout_2">
+      <item row="0" column="3">
+       <widget class="QLineEdit" name="tbFilePrefix">
+        <property name="text">
+         <string>timestack</string>
+        </property>
+       </widget>
+      </item>
+      <item row="0" column="0">
+       <widget class="QLabel" name="label">
+        <property name="text">
+         <string>Dates from</string>
+        </property>
+       </widget>
+      </item>
+      <item row="1" column="0">
+       <widget class="QLabel" name="label_5">
+        <property name="text">
+         <string>Save images to</string>
+        </property>
+       </widget>
+      </item>
+      <item row="1" column="1" colspan="3">
+       <layout class="QHBoxLayout" name="horizontalLayout_4">
+        <property name="topMargin">
+         <number>0</number>
+        </property>
+        <item>
+         <widget class="QRadioButton" name="rbSaveInMemory">
+          <property name="text">
+           <string>memory</string>
+          </property>
+          <property name="checked">
+           <bool>true</bool>
+          </property>
+          <attribute name="buttonGroup">
+           <string notr="true">buttonGroupOutputLocation</string>
+          </attribute>
+         </widget>
+        </item>
+        <item>
+         <widget class="QRadioButton" name="rbSaveInDirectory">
+          <property name="text">
+           <string>directory</string>
+          </property>
+          <attribute name="buttonGroup">
+           <string notr="true">buttonGroupOutputLocation</string>
+          </attribute>
+         </widget>
+        </item>
+        <item>
+         <widget class="QgsFileWidget" name="fileWidgetOutputDir">
+          <property name="enabled">
+           <bool>false</bool>
+          </property>
+          <property name="dialogTitle">
+           <string>Select output directory</string>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </item>
+      <item row="0" column="1">
+       <layout class="QHBoxLayout" name="horizontalLayout_2">
+        <item>
+         <widget class="QRadioButton" name="rbDatesIntersection">
+          <property name="text">
+           <string>Intersection</string>
+          </property>
+          <property name="checked">
+           <bool>true</bool>
+          </property>
+          <attribute name="buttonGroup">
+           <string notr="true">buttonGroupDateMode</string>
+          </attribute>
+         </widget>
+        </item>
+        <item>
+         <widget class="QRadioButton" name="rbDatesAll">
+          <property name="text">
+           <string>All Images</string>
+          </property>
+          <attribute name="buttonGroup">
+           <string notr="true">buttonGroupDateMode</string>
+          </attribute>
+         </widget>
+        </item>
+        <item>
+         <spacer name="horizontalSpacer_4">
+          <property name="orientation">
+           <enum>Qt::Horizontal</enum>
+          </property>
+          <property name="sizeHint" stdset="0">
+           <size>
+            <width>40</width>
+            <height>20</height>
+           </size>
+          </property>
+         </spacer>
+        </item>
+       </layout>
+      </item>
+      <item row="0" column="2">
+       <widget class="QLabel" name="label_6">
+        <property name="text">
+         <string>Prefix</string>
+        </property>
+       </widget>
+      </item>
+      <item row="2" column="1">
+       <widget class="QCheckBox" name="cbOpenInQGIS">
+        <property name="text">
+         <string>Open in QGIS</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QgsCollapsibleGroupBox" name="gbOutputImages">
+     <property name="sizePolicy">
+      <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+       <horstretch>0</horstretch>
+       <verstretch>2</verstretch>
+      </sizepolicy>
+     </property>
+     <property name="title">
+      <string>Output Images (each image = one temporal observation)</string>
+     </property>
+     <property name="collapsed">
+      <bool>false</bool>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_3">
+      <item>
+       <widget class="QSplitter" name="splitter">
+        <property name="sizePolicy">
+         <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+          <horstretch>0</horstretch>
+          <verstretch>0</verstretch>
+         </sizepolicy>
+        </property>
+        <property name="orientation">
+         <enum>Qt::Horizontal</enum>
+        </property>
+        <property name="childrenCollapsible">
+         <bool>true</bool>
+        </property>
+        <widget class="QTableView" name="tableViewOutputImages">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+        </widget>
+        <widget class="QPlainTextEdit" name="tbXMLPreview">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+           <horstretch>2</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="verticalScrollBarPolicy">
+          <enum>Qt::ScrollBarAlwaysOn</enum>
+         </property>
+         <property name="horizontalScrollBarPolicy">
+          <enum>Qt::ScrollBarAlwaysOn</enum>
+         </property>
+         <property name="sizeAdjustPolicy">
+          <enum>QAbstractScrollArea::AdjustToContents</enum>
+         </property>
+         <property name="lineWrapMode">
+          <enum>QPlainTextEdit::NoWrap</enum>
+         </property>
+         <property name="readOnly">
+          <bool>true</bool>
+         </property>
+         <property name="placeholderText">
+          <string>Select output image to show its definition here</string>
+         </property>
+        </widget>
+       </widget>
+      </item>
+      <item>
+       <widget class="QLineEdit" name="tbInfoOutputImages">
+        <property name="autoFillBackground">
+         <bool>false</bool>
+        </property>
+        <property name="styleSheet">
+         <string notr="true">background-color:transparent;</string>
+        </property>
+        <property name="text">
+         <string/>
+        </property>
+        <property name="frame">
+         <bool>false</bool>
+        </property>
+        <property name="readOnly">
+         <bool>true</bool>
+        </property>
+        <property name="placeholderText">
+         <string>No output images defined</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <spacer name="verticalSpacer">
+     <property name="orientation">
+      <enum>Qt::Vertical</enum>
+     </property>
+     <property name="sizeHint" stdset="0">
+      <size>
+       <width>0</width>
+       <height>0</height>
+      </size>
+     </property>
+    </spacer>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout_3">
+     <property name="topMargin">
+      <number>0</number>
+     </property>
+     <item>
+      <widget class="QProgressBar" name="progressBar">
+       <property name="value">
+        <number>0</number>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QDialogButtonBox" name="buttonBox">
+       <property name="sizePolicy">
+        <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+         <horstretch>0</horstretch>
+         <verstretch>0</verstretch>
+        </sizepolicy>
+       </property>
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="standardButtons">
+        <set>QDialogButtonBox::Cancel|QDialogButtonBox::Save</set>
+       </property>
+       <property name="centerButtons">
+        <bool>false</bool>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+  <action name="actionAddSourceStack">
+   <property name="icon">
+    <iconset resource="resources.qrc">
+     <normaloff>:/timeseriesviewer/icons/mActionAddRasterLayer.svg</normaloff>:/timeseriesviewer/icons/mActionAddRasterLayer.svg</iconset>
+   </property>
+   <property name="text">
+    <string>AddSourceStack</string>
+   </property>
+  </action>
+  <action name="actionRemoveSourceStack">
+   <property name="icon">
+    <iconset resource="resources.qrc">
+     <normaloff>:/timeseriesviewer/icons/mActionRemoveTSD.svg</normaloff>:/timeseriesviewer/icons/mActionRemoveTSD.svg</iconset>
+   </property>
+   <property name="text">
+    <string>removeSourceStack</string>
+   </property>
+  </action>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>QgsCollapsibleGroupBox</class>
+   <extends>QGroupBox</extends>
+   <header>qgscollapsiblegroupbox.h</header>
+   <container>1</container>
+  </customwidget>
+  <customwidget>
+   <class>QgsFileWidget</class>
+   <extends>QWidget</extends>
+   <header>qgsfilewidget.h</header>
+  </customwidget>
+ </customwidgets>
+ <resources>
+  <include location="resources.qrc"/>
+ </resources>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>Dialog</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>236</x>
+     <y>587</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>Dialog</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>304</x>
+     <y>587</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>rbSaveInDirectory</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>fileWidgetOutputDir</receiver>
+   <slot>setEnabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>217</x>
+     <y>238</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>375</x>
+     <y>240</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+ <buttongroups>
+  <buttongroup name="buttonGroupOutputLocation"/>
+  <buttongroup name="buttonGroupDateMode"/>
+ </buttongroups>
+</ui>
diff --git a/timeseriesviewer/ui/timeseriesviewer.ui b/timeseriesviewer/ui/timeseriesviewer.ui
index 337848a8954bfceba9df68d9aa3e9ec568abe95e..7e5d7708e10b62de42faa0574407e8caf96fbf24 100644
--- a/timeseriesviewer/ui/timeseriesviewer.ui
+++ b/timeseriesviewer/ui/timeseriesviewer.ui
@@ -155,6 +155,7 @@
      <string>Files</string>
     </property>
     <addaction name="actionAddTSD"/>
+    <addaction name="actionLoadTimeSeriesStack"/>
     <addaction name="actionAddVectorData"/>
     <addaction name="actionLoadTS"/>
     <addaction name="actionSaveTS"/>
@@ -688,6 +689,14 @@
     <string>Opens the online documentation</string>
    </property>
   </action>
+  <action name="actionLoadTimeSeriesStack">
+   <property name="text">
+    <string>Add images from time stack</string>
+   </property>
+   <property name="toolTip">
+    <string>Load images from a stack or list of stacks witch each image band being an temporal observation</string>
+   </property>
+  </action>
  </widget>
  <customwidgets>
   <customwidget>
diff --git a/timeseriesviewer/utils.py b/timeseriesviewer/utils.py
index f30813db35ad40caf6f30ac357125371c23a384d..b9684e6d3de260afcc62b2f0344c3efdaa0d1603 100644
--- a/timeseriesviewer/utils.py
+++ b/timeseriesviewer/utils.py
@@ -101,6 +101,35 @@ def file_search(rootdir, pattern, recursive=False, ignoreCase=False, directories
     return results
 
 
+NEXT_COLOR_HUE_DELTA_CON = 10
+NEXT_COLOR_HUE_DELTA_CAT = 100
+def nextColor(color, mode='cat'):
+    """
+    Returns another color
+    :param color: the previous color
+    :param mode: 'cat' - for categorical color jump (next color looks pretty different to previous)
+                 'con' - for continuous color jump (next color looks similar to previous)
+    :return:
+    """
+    assert mode in ['cat','con']
+    assert isinstance(color, QColor)
+    hue, sat, value, alpha = color.getHsl()
+    if mode == 'cat':
+        hue += NEXT_COLOR_HUE_DELTA_CAT
+    elif mode == 'con':
+        hue += NEXT_COLOR_HUE_DELTA_CON
+    if sat == 0:
+        sat = 255
+        value = 128
+        alpha = 255
+        s = ""
+    while hue > 360:
+        hue -= 360
+
+    return QColor.fromHsl(hue, sat, value, alpha)
+
+
+
 
 
 def createQgsField(name : str, exampleValue, comment:str=None):
@@ -118,12 +147,12 @@ def createQgsField(name : str, exampleValue, comment:str=None):
         return QgsField(name, QVariant.Bool, 'int', len=1, comment=comment)
     elif t in [int, np.int32, np.int64]:
         return QgsField(name, QVariant.Int, 'int', comment=comment)
-    elif t in [float, np.double, np.float, np.float64]:
+    elif t in [float, np.double, np.float16, np.float32, np.float64]:
         return QgsField(name, QVariant.Double, 'double', comment=comment)
     elif isinstance(exampleValue, np.ndarray):
         return QgsField(name, QVariant.String, 'varchar', comment=comment)
     elif isinstance(exampleValue, list):
-        assert len(exampleValue)> 0, 'need at least one value in provided list'
+        assert len(exampleValue) > 0, 'need at least one value in provided list'
         v = exampleValue[0]
         prototype = createQgsField(name, v)
         subType = prototype.type()
@@ -133,6 +162,7 @@ def createQgsField(name : str, exampleValue, comment:str=None):
         raise NotImplemented()
 
 
+
 def setQgsFieldValue(feature:QgsFeature, field, value):
     """
     Wrties the Python value v into a QgsFeature field, taking care of required conversions
@@ -437,6 +467,9 @@ def px2geo(px, gt, pxCenter=True):
     return QgsPointXY(gx, gy)
 
 
+
+
+
 class SpatialExtent(QgsRectangle):
     """
     Object to keep QgsRectangle and QgsCoordinateReferenceSystem together
diff --git a/timeseriesviewer/virtualrasters.py b/timeseriesviewer/virtualrasters.py
index 5de55b0a7102dcab90a000a9638f799748dd1e9f..8006d801753c566a257a08e68f24b59b7f3bfc4c 100644
--- a/timeseriesviewer/virtualrasters.py
+++ b/timeseriesviewer/virtualrasters.py
@@ -2,8 +2,8 @@
 # noinspection PyPep8Naming
 """
 /***************************************************************************
-                              EO Time Series Viewer
-                              -------------------
+                              Virtual Raster Builder
+                              ----------------------
         begin                : 2015-08-20
         git sha              : $Format:%H$
         copyright            : (C) 2017 by HU-Berlin
@@ -19,7 +19,8 @@
  *                                                                         *
  ***************************************************************************/
 """
-import os, sys, re, pickle, tempfile, unicodedata
+import os, sys, re, pickle, tempfile, uuid
+from xml.etree import ElementTree
 from collections import OrderedDict
 import tempfile
 from osgeo import gdal, osr, ogr, gdalconst as gc
@@ -56,26 +57,56 @@ LUT_GDT_NAME = {gdal.GDT_Byte:'Byte',
                 gdal.GDT_CFloat64:'Float64'}
 
 
+GRA_tooltips = {'NearestNeighbour':'nearest neighbour resampling (default, fastest algorithm, worst interpolation quality).',
+              'Bilinear':'bilinear resampling.',
+              'Lanczos':'lanczos windowed sinc resampling.',
+              'Average':'average resampling, computes the average of all non-NODATA contributing pixels.',
+              'Cubic':'cubic resampling.',
+              'CubicSpline':'cubic spline resampling.',
+              'Mode':'mode resampling, selects the value which appears most often of all the sampled points',
+              'Max':'maximum resampling, selects the maximum value from all non-NODATA contributing pixels',
+              'Min':'minimum resampling, selects the minimum value from all non-NODATA contributing pixels.',
+              'Med':'median resampling, selects the median value of all non-NODATA contributing pixels.',
+              'Q1':'first quartile resampling, selects the first quartile value of all non-NODATA contributing pixels. ',
+              'Q3':'third quartile resampling, selects the third quartile value of all non-NODATA contributing pixels'
+              }
 
 RESAMPLE_ALGS = OptionListModel()
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_NearestNeighbour, 'nearest',
-                               tooltip='nearest neighbour resampling (default, fastest algorithm, worst interpolation quality).'))
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_Bilinear, 'bilinear', tooltip='bilinear resampling.'))
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_Lanczos, 'lanczos', tooltip='lanczos windowed sinc resampling.'))
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_Average, 'average', tooltip='average resampling, computes the average of all non-NODATA contributing pixels.'))
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_Cubic, 'cubic', tooltip='cubic resampling.'))
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_CubicSpline, 'cubic_spline', tooltip='cubic spline resampling.'))
-RESAMPLE_ALGS.addOption(Option(gdal.GRA_Mode, 'mode', tooltip='mode resampling, selects the value which appears most often of all the sampled points'))
-
-if int(gdal.VersionInfo()) >= 2020300:
-    #respect that older GDAL version do not have python binding to GRA_Max, GRA_Min, GRA_Med, GRA_Q1 and GRA_Q3
-    #see https://trac.osgeo.org/gdal/wiki/Release/2.2.3-News
-    #https://trac.osgeo.org/gdal/ticket/7153
-    RESAMPLE_ALGS.addOption(Option(gdal.GRA_Max, 'max', tooltip='maximum resampling, selects the maximum value from all non-NODATA contributing pixels'))
-    RESAMPLE_ALGS.addOption(Option(gdal.GRA_Min, 'min', tooltip='minimum resampling, selects the minimum value from all non-NODATA contributing pixels.'))
-    RESAMPLE_ALGS.addOption(Option(gdal.GRA_Med, 'med', tooltip='median resampling, selects the median value of all non-NODATA contributing pixels.'))
-    RESAMPLE_ALGS.addOption(Option(gdal.GRA_Q1, 'Q1', tooltip='first quartile resampling, selects the first quartile value of all non-NODATA contributing pixels. '))
-    RESAMPLE_ALGS.addOption(Option(gdal.GRA_Q3, 'Q2', tooltip='third quartile resampling, selects the third quartile value of all non-NODATA contributing pixels'))
+for GRAkey in [k for k in list(gdal.__dict__.keys()) if k.startswith('GRA_')]:
+
+    GRA = gdal.__dict__[GRAkey]
+    GRA_Name = GRAkey[4:]
+
+    option = Option(GRA, GRA_Name, tooltip=GRA_tooltips.get(GRA_Name))
+    RESAMPLE_ALGS.addOption(option)
+
+
+# thanks to https://gis.stackexchange.com/questions/75533/how-to-apply-band-settings-using-gdal-python-bindings
+def read_vsimem(fn):
+    """
+    Reads VSIMEM path as string
+    :param fn: vsimem path (str)
+    :return: result of gdal.VSIFReadL(1, vsileng, vsifile)
+    """
+    vsifile = gdal.VSIFOpenL(fn,'r')
+    gdal.VSIFSeekL(vsifile, 0, 2)
+    vsileng = gdal.VSIFTellL(vsifile)
+    gdal.VSIFSeekL(vsifile, 0, 0)
+    return gdal.VSIFReadL(1, vsileng, vsifile)
+
+def write_vsimem(fn:str,data:str):
+    """
+    Writes data to vsimem path
+    :param fn: vsimem path (str)
+    :param data: string to write
+    :return: result of gdal.VSIFCloseL(vsifile)
+    """
+    '''Write GDAL vsimem files'''
+    vsifile = gdal.VSIFOpenL(fn,'w')
+    size = len(data)
+    gdal.VSIFWriteL(data, 1, size, vsifile)
+    return gdal.VSIFCloseL(vsifile)
+
 
 def px2geo(px, gt):
     #see http://www.gdal.org/gdal_datamodel.html
@@ -113,7 +144,13 @@ def describeRawFile(pathRaw, pathVrt, xsize, ysize,
 
     assert interleave in ['bsq','bil','bip']
     assert byteOrder in ['LSB', 'MSB']
-    vrt = ['<VRTDataset rasterXSize="{xsize}" rasterYSize="{ysize}">'.format(xsize=xsize,ysize=ysize)]
+
+    drvVRT = gdal.GetDriverByName('VRT')
+    assert isinstance(drvVRT, gdal.Driver)
+    dsVRT = drvVRT.Create(pathVrt, xsize, ysize, bands=0, eType=eType)
+    assert isinstance(dsVRT, gdal.Dataset)
+
+    #vrt = ['<VRTDataset rasterXSize="{xsize}" rasterYSize="{ysize}">'.format(xsize=xsize,ysize=ysize)]
 
     vrtDir = os.path.dirname(pathVrt)
     if pathRaw.startswith(vrtDir):
@@ -124,8 +161,6 @@ def describeRawFile(pathRaw, pathVrt, xsize, ysize,
         srcFilename = pathRaw
 
     for b in range(bands):
-        vrt.append('  <VRTRasterBand dataType="{dataType}" band="{band}" subClass="VRTRawRasterBand">'.format(
-            dataType=LUT_GDT_NAME[eType], band=b+1))
         if interleave == 'bsq':
             imageOffset = headerOffset
             pixelOffset = LUT_GDT_SIZE[eType]
@@ -136,67 +171,47 @@ def describeRawFile(pathRaw, pathVrt, xsize, ysize,
             lineOffset = xsize * bands
         else:
             raise Exception('Interleave {} is not supported'.format(interleave))
-        vrt.append("""    <SourceFilename relativetoVRT="{relativeToVRT}">{srcFilename}</SourceFilename>
-    <ImageOffset>{imageOffset}</ImageOffset>
-    <PixelOffset>{pixelOffset}</PixelOffset>
-    <LineOffset>{lineOffset}</LineOffset>
-    <ByteOrder>{byteOrder}</ByteOrder>""".format(relativeToVRT=relativeToVRT,
-                   srcFilename=srcFilename,
-                   imageOffset=imageOffset,
-                   pixelOffset=pixelOffset,
-                   lineOffset=lineOffset,
-                   byteOrder=byteOrder))
-
-        vrt.append('  </VRTRasterBand>')
-    vrt.append('</VRTDataset>')
-    vrt = '\n'.join(vrt)
-
-    drv = gdal.GetDriverByName('VRT')
-    assert isinstance(drv, gdal.Driver)
-
-    dsVRT = drv.Create(pathVrt, xsize, ysize, bands , eType)
-    assert isinstance(dsVRT, gdal.Dataset)
-    for b in range(bands):
-        band = dsVRT.GetRasterBand(b+1)
-        assert isinstance(band, gdal.Band)
 
-        if interleave == 'bsq':
-            imageOffset = headerOffset
-            pixelOffset = LUT_GDT_SIZE[eType]
-            lineOffset = pixelOffset * xsize
-        elif interleave == 'bip':
-            imageOffset = headerOffset + b * LUT_GDT_SIZE[eType]
-            pixelOffset = bands * LUT_GDT_SIZE[eType]
-            lineOffset = xsize * bands
-        else:
-            raise Exception('Interleave {} is not supported'.format(interleave))
+        options = ['subClass=VRTRawRasterBand']
+        options.append('SourceFilename={}'.format(srcFilename))
+        options.append('dataType={}'.format(LUT_GDT_NAME[eType]))
+        options.append('ImageOffset={}'.format(imageOffset))
+        options.append('PixelOffset={}'.format(pixelOffset))
+        options.append('LineOffset={}'.format(lineOffset))
+        options.append('ByteOrder={}'.format(byteOrder))
 
         xml = """<SourceFilename relativetoVRT="{relativeToVRT}">{srcFilename}</SourceFilename>
             <ImageOffset>{imageOffset}</ImageOffset>
             <PixelOffset>{pixelOffset}</PixelOffset>
             <LineOffset>{lineOffset}</LineOffset>
             <ByteOrder>{byteOrder}</ByteOrder>""".format(relativeToVRT=relativeToVRT,
-                           srcFilename=srcFilename,
-                           imageOffset=imageOffset,
-                           pixelOffset=pixelOffset,
-                           lineOffset=lineOffset,
-                           byteOrder=byteOrder)
-
-        band.SetMetadataItem('source_0', xml, "new_vrt_sources")
+                                                         srcFilename=srcFilename,
+                                                         imageOffset=imageOffset,
+                                                         pixelOffset=pixelOffset,
+                                                         lineOffset=lineOffset,
+                                                         byteOrder=byteOrder)
+
+        #md = {}
+        #md['source_0'] = xml
+        #vrtBand = dsVRT.GetRasterBand(b + 1)
+        assert dsVRT.AddBand(eType, options=options) == 0
+
+        vrtBand = dsVRT.GetRasterBand(b+1)
+        assert isinstance(vrtBand, gdal.Band)
+        #vrtBand.SetMetadata(md, 'vrt_sources')
+        #vrt.append('  <VRTRasterBand dataType="{dataType}" band="{band}" subClass="VRTRawRasterBand">'.format(dataType=LUT_GDT_NAME[eType], band=b+1))
     dsVRT.FlushCache()
-
-    #file = open(pathVrt, 'w')
-    #file.write(vrt)
-    #file.flush()
-    #file.close()
-    #ds = gdal.Open(pathVrt)
-    #return ds
     return dsVRT
 
 
 class VRTRasterInputSourceBand(object):
     @staticmethod
     def fromGDALDataSet(pathOrDataSet):
+        """
+        Returns the VRTRasterInputSourceBands from a raster data source
+        :param pathOrDataSet: str | gdal.Dataset
+        :return: [list-of-VRTRasterInputSourceBand]
+        """
 
         srcBands = []
 
@@ -211,15 +226,14 @@ class VRTRasterInputSourceBand(object):
 
 
 
-    def __init__(self, path, bandIndex, bandName=''):
-        self.mPath = os.path.normpath(path)
+    def __init__(self, path:str, bandIndex:int, bandName:str=''):
+        self.mPath = path
         self.mBandIndex = bandIndex
         self.mBandName = bandName
         self.mNoData = None
         self.mVirtualBand = None
 
 
-
     def isEqual(self, other):
         if isinstance(other, VRTRasterInputSourceBand):
             return self.mPath == other.mPath and self.mBandIndex == other.mBandIndex
@@ -248,16 +262,16 @@ class VRTRasterBand(QObject):
     sigSourceRemoved = pyqtSignal(int, VRTRasterInputSourceBand)
     def __init__(self, name='', parent=None):
         super(VRTRasterBand, self).__init__(parent)
-        self.sources = []
+        self.mSources = []
         self.mName = ''
         self.setName(name)
         self.mVRT = None
 
+    def __len__(self):
+        return len(self.mSources)
 
     def setName(self, name):
-
-        if isinstance(name, str):
-            name = unicode(name)
+        assert isinstance(name, str)
         oldName = self.mName
         self.mName = name
         if oldName != self.mName:
@@ -270,16 +284,17 @@ class VRTRasterBand(QObject):
 
     def addSource(self, virtualBandInputSource):
         assert isinstance(virtualBandInputSource, VRTRasterInputSourceBand)
-        self.insertSource(len(self.sources), virtualBandInputSource)
+        self.insertSource(len(self.mSources), virtualBandInputSource)
 
     def insertSource(self, index, virtualBandInputSource):
         assert isinstance(virtualBandInputSource, VRTRasterInputSourceBand)
         virtualBandInputSource.mVirtualBand = self
-        if index <= len(self.sources):
-            self.sources.insert(index, virtualBandInputSource)
+        if index <= len(self.mSources):
+            self.mSources.insert(index, virtualBandInputSource)
             self.sigSourceInserted.emit(index, virtualBandInputSource)
         else:
-            print('DEBUG: index <= len(self.sources)')
+            pass
+            #print('DEBUG: index <= len(self.sources)')
     def bandIndex(self):
         if isinstance(self.mVRT, VRTRaster):
             return self.mVRT.mBands.index(self)
@@ -294,10 +309,10 @@ class VRTRasterBand(QObject):
         :return: The VRTRasterInputSourceBand that was removed
         """
         if not isinstance(vrtRasterInputSourceBand, VRTRasterInputSourceBand):
-            vrtRasterInputSourceBand = self.sources[vrtRasterInputSourceBand]
-        if vrtRasterInputSourceBand in self.sources:
-            i = self.sources.index(vrtRasterInputSourceBand)
-            self.sources.remove(vrtRasterInputSourceBand)
+            vrtRasterInputSourceBand = self.mSources[vrtRasterInputSourceBand]
+        if vrtRasterInputSourceBand in self.mSources:
+            i = self.mSources.index(vrtRasterInputSourceBand)
+            self.mSources.remove(vrtRasterInputSourceBand)
             self.sigSourceRemoved.emit(i, vrtRasterInputSourceBand)
 
 
@@ -305,12 +320,12 @@ class VRTRasterBand(QObject):
         """
         :return: list of file-paths to all source files
         """
-        files = set([inputSource.mPath for inputSource in self.sources])
+        files = set([inputSource.mPath for inputSource in self.mSources])
         return sorted(list(files))
 
     def __repr__(self):
         infos = ['VirtualBand name="{}"'.format(self.mName)]
-        for i, info in enumerate(self.sources):
+        for i, info in enumerate(self.mSources):
             assert isinstance(info, VRTRasterInputSourceBand)
             infos.append('\t{} SourceFileName {} SourceBand {}'.format(i + 1, info.mPath, info.mBandIndex))
         return '\n'.join(infos)
@@ -449,7 +464,8 @@ class VRTRaster(QObject):
             if crs != self.mCrs:
                 extent = self.extent()
                 if isinstance(extent, QgsRectangle):
-                    trans = QgsCoordinateTransform(self.mCrs, crs)
+                    trans = QgsCoordinateTransform()
+                    trans.setDestinationCrs(self.mCrs, crs)
                     extent = trans.transform(extent)
                     self.setExtent(extent)
                 self.mCrs = crs
@@ -527,7 +543,7 @@ class VRTRaster(QObject):
         assert path in self.sourceRaster()
         for vBand in self.mBands:
             assert isinstance(vBand, VRTRasterBand)
-            if path in vBand.sources():
+            if path in vBand.mSources():
                 vBand.removeSource(path)
 
     def removeVirtualBand(self, bandOrIndex):
@@ -621,14 +637,14 @@ class VRTRaster(QObject):
         ds = gdal.Open(pathVRT)
         assert isinstance(ds, gdal.Dataset)
         assert ds.GetDriver().GetDescription() == 'VRT'
-        from xml.etree import ElementTree
+
         for b in range(ds.RasterCount):
             srcBand = ds.GetRasterBand(b+1)
             vrtBand = VRTRasterBand(name=srcBand.GetDescription().decode('utf-8'))
             for key, xml in srcBand.GetMetadata(str('vrt_sources')).items():
 
                 tree = ElementTree.fromstring(xml)
-                srcPath = os.path.normpath(tree.find('SourceFilename').text)
+                srcPath = tree.find('SourceFilename').text
                 srcBandIndex = int(tree.find('SourceBand').text)
                 vrtBand.addSource(VRTRasterInputSourceBand(srcPath, srcBandIndex))
 
@@ -638,22 +654,32 @@ class VRTRaster(QObject):
 
 
 
-    def saveVRT(self, pathVRT):
-
-        if len(self.mBands) == 0:
-            print('No VRT Inputs defined.')
-            return None
+    def saveVRT(self, pathVRT, warpedImageFolder = '.warpedimage'):
+        """
+        Save the VRT to path.
+        If source images need to be warped to the final CRS warped VRT image will be created in a folder <directory>/<basename>+<warpedImageFolder>/
 
+        :param pathVRT: str, path of final VRT.
+        :param warpedImageFolder: basename of folder that is created
+        :return:
+        """
+        """
+        :param pathVRT: 
+        :return:
+        """
+        assert len(self) >= 1, 'VRT needs to define at least 1 band'
         assert os.path.splitext(pathVRT)[-1].lower() == '.vrt'
 
-        dirVrt = os.path.dirname(pathVRT)
-        dirWarpedVRT = os.path.join(dirVrt, 'WarpedVRTs')
-        if not os.path.isdir(dirVrt):
-            os.mkdir(dirVrt)
-
         srcLookup = dict()
         srcNodata = None
+        inMemory = pathVRT.startswith('/vsimem/')
+
+        if inMemory:
+            dirWarped = '/vsimem/'
+        else:
+            dirWarped = os.path.join(os.path.splitext(pathVRT)[0] + '.WarpedImages')
 
+        drvVRT = gdal.GetDriverByName('VRT')
         for i, pathSrc in enumerate(self.sourceRaster()):
             dsSrc = gdal.Open(pathSrc)
             assert isinstance(dsSrc, gdal.Dataset)
@@ -667,68 +693,114 @@ class VRTRaster(QObject):
             if crs == self.mCrs:
                 srcLookup[pathSrc] = pathSrc
             else:
+                #do a CRS transformation using VRTs
+
+                warpedFileName = 'warped.{}.vrt'.format(os.path.basename(pathSrc))
+                if inMemory:
+                    warpedFileName = dirWarped + warpedFileName
+                else:
+                    os.makedirs(dirWarped, exist_ok=True)
+                    warpedFileName = os.path.join(dirWarped, warpedFileName)
 
-                if not os.path.isdir(dirWarpedVRT):
-                    os.mkdir(dirWarpedVRT)
-                pathVRT2 = os.path.join(dirWarpedVRT, 'warped.{}.vrt'.format(os.path.basename(pathSrc)))
                 wops = gdal.WarpOptions(format='VRT',
                                         dstSRS=self.mCrs.toWkt())
-                tmp = gdal.Warp(pathVRT2, dsSrc, options=wops)
+                tmp = gdal.Warp(warpedFileName, dsSrc, options=wops)
                 assert isinstance(tmp, gdal.Dataset)
-                tmp = None
-                srcLookup[pathSrc] = pathVRT2
+                vrtXML = read_vsimem(warpedFileName)
+                xml = ElementTree.fromstring(vrtXML)
+                #print(vrtXML.decode('utf-8'))
 
-        srcFiles = [srcLookup[src] for src in self.sourceRaster()]
+                if False:
+                    dsTmp = gdal.Open(warpedFileName)
+                    assert isinstance(dsTmp, gdal.Dataset)
+                    drvVRT.Delete(warpedFileName)
+                    dsTmp = gdal.Open(warpedFileName)
+                    assert not isinstance(dsTmp, gdal.Dataset)
 
-        #1. build a temporary VRT that describes the spatial shifts of all input sources
+                srcLookup[pathSrc] = warpedFileName
 
+        srcFiles = [srcLookup[src] for src in self.sourceRaster()]
 
-        kwds = {}
+        #these need to be set
+        ns = nl = gt = crs = eType = None
         res = self.resolution()
+        extent = self.extent()
 
-        if res is None:
-            res = 'average'
-        if isinstance(res, QSizeF):
-            kwds['resolution'] = 'user'
-            kwds['xRes'] = res.width()
-            kwds['yRes'] = res.height()
-        else:
-            assert res in ['highest','lowest','average']
-            kwds['resolution'] = res
+        srs = None
+        if isinstance(self.crs(), QgsCoordinateReferenceSystem):
+            srs = self.crs().toWkt()
+
+        if len(srcFiles) > 0:
+            # 1. build a temporary VRT that describes the spatial shifts of all input sources
+            kwds = {}
+            if res is None:
+                res = 'average'
+            if isinstance(res, QSizeF):
+                kwds['resolution'] = 'user'
+                kwds['xRes'] = res.width()
+                kwds['yRes'] = res.height()
+            else:
+                assert res in ['highest','lowest','average']
+                kwds['resolution'] = res
 
-        extent = self.extent()
-        if isinstance(extent, QgsRectangle):
-            kwds['outputBounds'] = (extent.xMinimum(), extent.yMinimum(), extent.xMaximum(), extent.yMaximum())
+            if isinstance(extent, QgsRectangle):
+                kwds['outputBounds'] = (extent.xMinimum(), extent.yMinimum(), extent.xMaximum(), extent.yMaximum())
 
-        vro = gdal.BuildVRTOptions(separate=True, **kwds)
+            if srs is not None:
+                kwds['outputSRS'] = srs
 
-        gdal.BuildVRT(pathVRT, srcFiles, options=vro)
-        dsVRTDst = gdal.Open(pathVRT)
-        assert isinstance(dsVRTDst, gdal.Dataset)
-        assert len(srcLookup) == dsVRTDst.RasterCount
-        ns, nl = dsVRTDst.RasterXSize, dsVRTDst.RasterYSize
-        gt = dsVRTDst.GetGeoTransform()
-        crs = dsVRTDst.GetProjectionRef()
-        eType = dsVRTDst.GetRasterBand(1).DataType
-        SOURCE_TEMPLATES = dict()
-        for i, srcFile in enumerate(srcFiles):
-            vrt_sources = dsVRTDst.GetRasterBand(i+1).GetMetadata(str('vrt_sources'))
-            assert len(vrt_sources) == 1
-            srcXML = vrt_sources['source_0']
-            assert os.path.basename(srcFile)+'</SourceFilename>' in srcXML
-            assert '<SourceBand>1</SourceBand>' in srcXML
-            SOURCE_TEMPLATES[srcFile] = srcXML
-        dsVRTDst = None
-        #remove the temporary VRT, we don't need it any more
-        os.remove(pathVRT)
+
+
+            pathInMEMVRT = '/vsimem/{}.vrt'.format(uuid.uuid4())
+            vro = gdal.BuildVRTOptions(separate=True, **kwds)
+            dsVRTDst = gdal.BuildVRT(pathInMEMVRT, srcFiles, options=vro)
+
+            assert isinstance(dsVRTDst, gdal.Dataset)
+
+            ns, nl = dsVRTDst.RasterXSize, dsVRTDst.RasterYSize
+            gt = dsVRTDst.GetGeoTransform()
+            crs = dsVRTDst.GetProjectionRef()
+            eType = dsVRTDst.GetRasterBand(1).DataType
+            SOURCE_TEMPLATES = dict()
+            for i, srcFile in enumerate(srcFiles):
+                vrt_sources = dsVRTDst.GetRasterBand(i+1).GetMetadata(str('vrt_sources'))
+                assert len(vrt_sources) == 1
+                srcXML = vrt_sources['source_0']
+                assert os.path.basename(srcFile)+'</SourceFilename>' in srcXML
+                assert '<SourceBand>1</SourceBand>' in srcXML
+                SOURCE_TEMPLATES[srcFile] = srcXML
+
+            drvVRT.Delete(pathInMEMVRT)
+
+        else:
+            # special case: no source files defined
+            ns = nl = 1 #this is the minimum size
+            if isinstance(extent, QgsRectangle):
+                x0 = extent.xMinimum()
+                y1 = extent.yMaximum()
+            else:
+                x0 = 0
+                y1 = 0
+
+            if isinstance(res, QSizeF):
+                resx = res.width()
+                resy = res.height()
+            else:
+                resx = 1
+                resy = 1
+
+            gt = (x0, resx, 0, y1, 0, -resy)
+            eType = gdal.GDT_Float32
 
         #2. build final VRT from scratch
-        drvVRT = gdal.GetDriverByName(str('VRT'))
+        drvVRT = gdal.GetDriverByName('VRT')
         assert isinstance(drvVRT, gdal.Driver)
         dsVRTDst = drvVRT.Create(pathVRT, ns, nl,0, eType=eType)
         #2.1. set general properties
         assert isinstance(dsVRTDst, gdal.Dataset)
-        dsVRTDst.SetProjection(crs)
+
+        if srs is not None:
+            dsVRTDst.SetProjection(srs)
         dsVRTDst.SetGeoTransform(gt)
 
         #2.2. add virtual bands
@@ -740,23 +812,21 @@ class VRTRaster(QObject):
             vrtBandDst.SetDescription(vBand.name())
             md = {}
             #add all input sources for this virtual band
-            for iSrc, sourceInfo in enumerate(vBand.sources):
+            for iSrc, sourceInfo in enumerate(vBand.mSources):
                 assert isinstance(sourceInfo, VRTRasterInputSourceBand)
                 bandIndex = sourceInfo.mBandIndex
                 xml = SOURCE_TEMPLATES[srcLookup[sourceInfo.mPath]]
-                xml = re.sub('<SourceBand>1</SourceBand>','<SourceBand>{}</SourceBand>'.format(bandIndex+1), xml)
+                xml = re.sub('<SourceBand>1</SourceBand>', '<SourceBand>{}</SourceBand>'.format(bandIndex+1), xml)
                 md['source_{}'.format(iSrc)] = xml
-            vrtBandDst.SetMetadata(md,str('vrt_sources'))
-            if False:
-                vrtBandDst.ComputeBandStats(1)
+            vrtBandDst.SetMetadata(md,'vrt_sources')
 
 
         dsVRTDst = None
 
         #check if we get what we like to get
         dsCheck = gdal.Open(pathVRT)
+        assert isinstance(dsCheck, gdal.Dataset)
 
-        s = ""
         return dsCheck
 
     def __repr__(self):