# Copyright (c) 2011-2014, B.I.Stepanov Institute of Physics, National Academy
# of Sciences of Belarus.
#
# 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.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

import datetime
import os
import os.path
import re

from PyQt4.QtCore import *
from PyQt4.QtGui import *

from common.utils import gui
from common.utils import txt
from common.utils import utils

from common.GeodeticPoint import *
from common.StatusSignalingWidget import *

from AerlidToolDialog import *
from PhotometerInput import *

try:
    from AeronetDownloader import *
except ImportError:
    aeronetDownloaderPluginLoaded = False
else:
    aeronetDownloaderPluginLoaded = True

__all__ = ['PhotometerDatabaseWidget']

# *****************************************************************************
class PhotometerDatabaseWidget(StatusSignalingWidget):
    """Widget responsible for loading of Sun photometer data files for the
    aerosol profile retrieval algorithm."""

    # ---- Signals ------------------------------------------------------------
    dataListChanged = pyqtSignal()

    # ---- Private class attributes -------------------------------------------
    # Default value for 'self.databaseRootDir'.
    defaultDatabaseDir = 'data/AERONET'

    # ---- Public methods -----------------------------------------------------
    def __init__(self, parent = None):
        StatusSignalingWidget.__init__(self, parent)

        # ---- Members ----
        # Root directory for the photometer database. This directory should
        # contain subdirectories named according to AERONET site names, then
        # subdirectories names according to measurement years, and then
        # subdirectories named according to measurement data levels. Data files
        # themselves will be stored at the bottom of this hierarchy.
        self.databaseRootDir = txt.absolutePath(self.defaultDatabaseDir)

        # A list of 'AeronetSiteInfo' instances or 'None' if AERONET site list
        # file has not been loaded.
        self.siteInfoList = None

        # String holding the currently selected AERONET site name.
        self.siteName = None
        # Year for the currently selected AERLID data file.
        self.year = datetime.datetime.now().year
        # Currently selected AERONET data level (either '1.5' or '2.0').
        self.dataLevel = '1.5'

        # List of 'PhotometerInput' instances loaded from the currently
        # selected AERLID data file.
        self.dataList = []

        # ---- Layout ----
        layout = QHBoxLayout()

        siteNameLabel = QLabel('Aeronet &site:')
        layout.addWidget(siteNameLabel)

        self.siteNameComboBox = QComboBox()
        # Automatically resize the combo box if new long site names are added.
        self.siteNameComboBox.setSizeAdjustPolicy(QComboBox.AdjustToContents)
        self.siteNameComboBox.currentIndexChanged.connect(
            self.onComboBoxIndexChanged)
        siteNameLabel.setBuddy(self.siteNameComboBox)
        layout.addWidget(self.siteNameComboBox)

        yearLabel = QLabel('&Year:')
        layout.addWidget(yearLabel)

        self.yearComboBox = QComboBox()
        self.yearComboBox.currentIndexChanged.connect(
            self.onComboBoxIndexChanged)
        yearLabel.setBuddy(self.yearComboBox)
        layout.addWidget(self.yearComboBox)

        dataLevelLabel = QLabel('Data l&evel:')
        layout.addWidget(dataLevelLabel)

        self.dataLevelComboBox = QComboBox()
        self.dataLevelComboBox.currentIndexChanged.connect(
            self.onComboBoxIndexChanged)
        dataLevelLabel.setBuddy(self.dataLevelComboBox)
        layout.addWidget(self.dataLevelComboBox)

        worldIcon = gui.loadIcon('world')
        self.updateDataButton = QPushButton(worldIcon, '&Update data')
        # This button will be hidden if 'AeronetDownloader' is not available.
        self.updateDataButton.hide()
        self.updateDataButton.clicked.connect(self.onUpdateDataButtonClicked)
        layout.addWidget(self.updateDataButton)

        layout.addStretch()

        self.excelExportButton = QPushButton(
            gui.loadIcon('microsoft-office-excel'), '')
        # Excel export button has to be disabled if there's no file selected.
        self.excelExportButton.setEnabled(False)
        self.excelExportButton.setToolTip('Export the currently selected '
            'photometer data file into Microsoft Excel format')
        self.excelExportButton.clicked.connect(self.onExcelExportButtonClicked)
        layout.addWidget(self.excelExportButton)

        self.reloadButton = QPushButton(gui.loadIcon('reload'), '')
        self.reloadButton.setToolTip('Save changes and reload the currently '
            'selected photometer data file')
        self.reloadButton.clicked.connect(self.onReloadButtonClicked)
        layout.addWidget(self.reloadButton)

        self.openButton = QPushButton(gui.loadIcon('parameters'), '')
        self.openButton.setToolTip(
            'Select root folder for the photometer database')
        self.openButton.clicked.connect(self.onOpenButtonClicked)
        layout.addWidget(self.openButton)

        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

        # ---- Initialization ----
        if aeronetDownloaderPluginLoaded:
            self.updateDataButton.show()

        # Don't load the AERONET site list here, because this procedure may
        # fail, thus setting the widget's 'errorMessage' attribute, whereas
        # 'errorMessageChanged' signal is definitely not connected yet. The
        # site list data file will be loaded in 'loadSettings'.
        self.populateSiteNameComboBox(False)
        self.populateYearComboBox()
        self.populateDataLevelComboBox()
        self.syncComboBoxIndices()

    def getSiteName(self):
        return self.siteName

    def getYear(self):
        return self.year

    def getDataLevel(self):
        return self.dataLevel

    def getDataList(self):
        """Return the list of 'PhotometerInput' instances loaded from the
        currently selected photometer data file.

        If no data have been loaded, an empty list is returned.

        Note: Currently, the data list returned by this function may be
        directly edited by the caller."""

        return self.dataList

    def saveAotCorrFile(self):
        """Save changes that have been made by the user to 'aot675Corr'
        attributes of the currently loaded list of 'PhotometerInput' instances
        in the appropriate AOT correction data file.

        Note: this function is called automatically whenever current photometer
        data file is changed."""

        # Do nothing if no data file has been selected (including the case of
        # failure of loading of the site list file).
        if self.siteName is None or self.year is None:
            return

        # Theoretically, this operation may fail. If an error occurs, warn the
        # user and make it possible to manually fix the problem and then retry
        # once again.
        while True:
            try:
                PhotometerInput.updateAotCorrFileForDataList(
                    self.getAotCorrFilePath(), self.dataList)
                break

            except txt.Error as e:

                # Display a message box to the user, explaining the problem.
                answer = QMessageBox.critical(self,
                    'Failed to save AOT correction file',
                    e.text,
                    QMessageBox.Retry | QMessageBox.Ignore, QMessageBox.Retry)

                if answer == QMessageBox.Ignore:
                    # Return without saving the data.
                    break

    def saveSettings(self, settings):
        """Save attributes of the currently selected photometer data file in
        the given 'QSettings' instance, and also save any changes that the user
        might have introduced to 'aot675Corr' attributes of the currently
        loaded list of 'PhotometerInput' instances."""

        with utils.SettingsGrouper(settings, 'PhotometerDatabaseWidget'):
            settings.setValue('databaseRootDir',
                txt.portablePath(self.databaseRootDir))
            settings.setValue('siteName', self.siteName)
            settings.setValue('year', self.year)
            settings.setValue('dataLevel', self.dataLevel)

        # Update the AOT correction data file.
        self.saveAotCorrFile()

    def loadSettings(self, settings):
        """Load attributes of the photometer data file stored in the given
        'QSettings' instance, load the AERONET site list file, and load the
        photometer data file itself if it is available and its site name is
        present in the AERONET site list."""

        with utils.SettingsGrouper(settings, 'PhotometerDatabaseWidget'):

            self.databaseRootDir = settings.value('databaseRootDir').toString()

            if self.databaseRootDir == '':
                self.databaseRootDir = self.defaultDatabaseDir

            # If database root directory turns out to be non-existent, an error
            # message will be signaled in 'loadPhotometerData'.
            self.databaseRootDir = txt.absolutePath(self.databaseRootDir)

            try:
                self.siteName = str(settings.value('siteName').toString())
                # Set the value to 'None' if it is missing or invalid.
                if self.siteName == '':
                    self.siteName = None

            except UnicodeEncodeError:
                # Non-ASCII AERONET site name is an invalid one.
                self.siteName = None

            # If the value is out of range, it will be set to 'None' later on.
            (self.year, conversionResult) = settings.value('year').toInt()
            if not conversionResult:
                self.year = None

            dataLevel = settings.value('dataLevel').toString()
            if dataLevel == '2.0':
                self.dataLevel = '2.0'
            else:
                # Use the default data level if the value is missing or
                # invalid.
                self.dataLevel = '1.5'

        # Load the AERONET site list data file.
        self.populateSiteNameComboBox(True)
        self.syncComboBoxIndices()
        self.loadPhotometerData()

    def getNearestSiteName(self, latitude, longitude):
        """Return name of the AERONET site that is the nearest one with respect
        to the given geodetic latitude and longitude (in degrees), or 'None' if
        AERONET site list file has not been loaded yet or is not available."""

        if self.siteInfoList is None or len(self.siteInfoList) == 0:
            return None

        # Don't take geodetic altitudes into account (although they are in fact
        # available for both lidar and AERONET data), to simplify the overall
        # logic of this function.
        targetPoint = GeodeticPoint(latitude, longitude)

        nearestIndex = 0
        nearestDistance = targetPoint.distToPoint(GeodeticPoint(
            self.siteInfoList[0].latitude, self.siteInfoList[0].longitude))

        for i in range(1, len(self.siteInfoList)):

            dist = targetPoint.distToPoint(GeodeticPoint(
                self.siteInfoList[i].latitude, self.siteInfoList[i].longitude))

            if dist < nearestDistance:
                nearestDistance = dist
                nearestIndex = i

        return self.siteInfoList[nearestIndex].siteName

    def setSiteNameAndYear(self, siteName, year):
        """Load photometer data file for the given site name and year, and the
        currently selected data level."""

        # Update the local variables.
        self.siteName = siteName
        self.year = year

        # Update interface elements and load the data file.
        self.syncComboBoxIndices()
        self.loadPhotometerData()

        # This is intended to be called after 'getNearestSiteName'. Thus, site
        # name should always be valid. However, year may theoretically be out
        # of range. Warn the user if such a case takes place.
        if self.year is None:
            QMessageBox.critical(self, 'AERONET data not available',
                'AERONET data are not available for year %s' %
                txt.quoteNumber(year))

    # ---- Private methods ----------------------------------------------------
    def getAeronetFileDir(self):
        """Return path to the directory where files related to the currently
        selected photometer data subset are stored."""

        return os.path.join(self.databaseRootDir,
            self.siteName, str(self.year), 'Level ' + self.dataLevel)

    def getDubovikFilePath(self):
        """Return path to the original Dubovik data file that corresponds to
        the currently selected photometer (AERLID) data file."""

        fileName = '%02d0101_%02d1231_%s.dubovik' % (
            self.year % 100, self.year % 100, self.siteName)

        return os.path.join(self.getAeronetFileDir(), fileName)

    def getAerlidFilePath(self):
        """Return path to the currently selected photometer data file (a
        Dubovik AERONET data file processed with AERLID tool)."""

        fileName = '%02d0101_%02d1231_%s.lidar.txt' % (
            self.year % 100, self.year % 100, self.siteName)

        return os.path.join(self.getAeronetFileDir(), fileName)

    def getAotCorrFilePath(self):
        """Return path to the AOT correction data file that corresponds to the
        currently selected photometer (AERLID) data file."""

        fileName = '%02d0101_%02d1231_%s.aot.txt' % (
            self.year % 100, self.year % 100, self.siteName)

        return os.path.join(self.getAeronetFileDir(), fileName)

    def getSiteListFilePath(self):
        """Return path to the AERONET site list data file."""

        # This file is stored in a subdirectory of 'code', because it should
        # be immediately available upon the program's installation, whereas
        # 'data' folder should normally be absent in the installation package.
        return 'code/data/AERONET/aeronet_locations.txt'

    def populateSiteNameComboBox(self, loadSiteInfoList = False):
        """Fill in the combo box meant for selection the AERONET site name.

        The combo box will contain the site list itself, and also an item that
        will make it possible to download the most recent version of the site
        list file and reload it.

        If 'loadSiteInfoList' if 'False', only the extra utility item is added
        to the combobox. Otherwise, the AERONET site list file is loaded, and
        a corresponding error message is set on failure. The simpler
        initialization is used in the constructor, and the right one is used in
        'loadSettings', thus making sure that 'errorMessageChanged' signal
        could be received by the parent widget in case of an error."""

        # Suspend the signals while the combo box is being populated.
        with gui.SignalBlocker(self.siteNameComboBox):

            self.siteNameComboBox.clear()
            self.siteInfoList = None

            if aeronetDownloaderPluginLoaded:
                # Make sure that items with and without icons have the same
                # height.
                iconHeight = QFontMetrics(
                    self.siteNameComboBox.font()).height()
                self.siteNameComboBox.setIconSize(
                    QSize(iconHeight, iconHeight))

                # Add the extra item used to update and reload the site list.
                worldIcon = gui.loadIcon('world')
                self.siteNameComboBox.addItem(worldIcon, 'Update site list')

            # Leave this single item only if 'loadSiteInfoList' is 'False'.
            if not loadSiteInfoList:
                # Open button should be disabled if there's no valid site list.
                self.openButton.setEnabled(False)
                return

            try:
                # Load the AERONET site list data file.
                self.siteInfoList = AeronetSiteListLoader().loadFile(
                    self.getSiteListFilePath())

            except txt.Error as e:
                # Open button should be disabled if there's no valid site list.
                self.openButton.setEnabled(False)
                self.setErrorMessage(e.text)
                return

            self.openButton.setEnabled(True)

            # Sort the AERONET site list by site name.
            self.siteInfoList.sort(
                cmp = lambda x, y: cmp(x.siteName, y.siteName))

            # Insert the AERONET site names before the extra combo box item.
            for i in range(len(self.siteInfoList)):
                self.siteNameComboBox.insertItem(i,
                    self.siteInfoList[i].siteName)

        # Size of the combo box widget will be updated automatically to fit its
        # contents. However, sometimes size of the top-level window should be
        # adjusted as well. The following code will do that if the window is
        # not shown yet. Otherwise, the problem remains open.
        self.layout().activate()

    def populateYearComboBox(self):
        """Fill in the combo box meant for selection of year of the photometer
        data."""

        # Suspend the signals while the combo box is being populated.
        with gui.SignalBlocker(self.yearComboBox):

            self.yearComboBox.clear()

            # AERONET measurements had started in 1993.
            currentYear = datetime.datetime.now().year
            for year in range(1993, currentYear + 1):
                self.yearComboBox.addItem(str(year))

    def populateDataLevelComboBox(self):
        """Fill in the combo box meant for selection of photometer data
        level."""

        # Suspend the signals while the combo box is being populated.
        with gui.SignalBlocker(self.dataLevelComboBox):

            self.dataLevelComboBox.clear()

            self.dataLevelComboBox.addItem('1.5')
            self.dataLevelComboBox.addItem('2.0')

    def syncComboBoxIndices(self):
        """Synchronize currently selected items of the combo boxes with actual
        attributes of the photometer data file stored in the local variables.

        If any of the values stored in the local variables is not present in
        the respective combo box, that value is reset to 'None', and current
        item of the combo box is cleared by setting its current index to -1.

        Note: this function does not load the currently selected photometer
        data file; use 'loadPhotometerData' for that."""

        # ---- 'siteName' ----
        siteIndex = -1

        # Search for the site name in the site list, if it is available.
        if self.siteName is not None and self.siteInfoList is not None:
            for i in range(len(self.siteInfoList)):
                if self.siteInfoList[i].siteName == self.siteName:
                    siteIndex = i
                    break

        # Reset the local variable if the value is not found.
        if siteIndex == -1:
            self.siteName = None

        with gui.SignalBlocker(self.siteNameComboBox):
            self.siteNameComboBox.setCurrentIndex(siteIndex)

        # ---- 'year' ----
        yearIndex = self.yearComboBox.findText(str(self.year))

        # Reset the local variable if the required value is not present.
        if yearIndex == -1:
            self.year = None

        with gui.SignalBlocker(self.yearComboBox):
            self.yearComboBox.setCurrentIndex(yearIndex)

        # ---- 'dataLevel' ----
        dataLevelIndex = self.dataLevelComboBox.findText(self.dataLevel)

        # 'dataLevel' attribute should always be valid.
        assert dataLevelIndex != -1

        with gui.SignalBlocker(self.dataLevelComboBox):
            self.dataLevelComboBox.setCurrentIndex(dataLevelIndex)

        # ---- Post-processing ----
        # Enable "Update data" button if a valid photometer data subrange is
        # selected and disable it otherwise.
        self.updateDataButton.setEnabled(
            self.siteName is not None and self.year is not None)

    def loadPhotometerData(self):
        """Load the currently selected photometer data file as defined by the
        local variables."""

        del self.dataList[:]

        # Loading a data file and displaying its contents usually takes some
        # noticeable amount of time, thus it's reasonable to hint the user that
        # something is going on by clearing the data table widget, if any.
        #
        # Note: The actual clearing will occur when the progress message
        # changes later on, or when the function returns.
        self.excelExportButton.setEnabled(False)
        self.dataListChanged.emit()

        # Don't overwrite the error message about AERONET site list loading
        # failure.
        if self.siteInfoList is None:
            return

        # Disable "Update data" button and leave data list blank if no
        # photometer data file is selected.
        if self.siteName is None or self.year is None:
            self.updateDataButton.setEnabled(
                self.siteName is not None and self.year is not None)

            self.setErrorMessage('Photometer data subset is not selected')
            return

        # If AERLID file is available, use it unless the corresponding Dubovik
        # file also exists and is newer than the AERLID one. If no AERLID file
        # exists, or if it's older than the corresponding Dubovik file, process
        # the Dubovik file with the AERLID tool and then try to load the data
        # once again. If neither AERLID nor Dubovik file is available, display
        # an error message.

        dubovikPath = self.getDubovikFilePath()
        aerlidPath = self.getAerlidFilePath()
        if not os.path.isfile(dubovikPath) and not os.path.isfile(aerlidPath):
            self.setErrorMessage('AERONET database file (%s) is not '
                'available' % txt.quotePath(dubovikPath))
            return

        if (os.path.isfile(dubovikPath) and not os.path.isfile(aerlidPath) or
            os.path.isfile(dubovikPath) and os.path.isfile(aerlidPath) and
            os.path.getmtime(dubovikPath) > os.path.getmtime(aerlidPath)):

            # Schedule the launch of the AERLID tool. Scheduled launch is used
            # here to make sure that AERLID dialog box is always displayed
            # after the main interface window during the application startup.
            QTimer.singleShot(0, self.runAerlidTool)
            return

        # If AOT correction file is present, load it along with the main file.
        aotCorrPath = self.getAotCorrFilePath()
        if not os.path.isfile(aotCorrPath):
            aotCorrPath = None

        try:
            # This message will be cleared by 'setErrorMessage' later on.
            self.setProgressMessage('Loading photometer input file (%s)...' %
                txt.quotePath(aerlidPath), True)

            # Load the photometer data file.
            self.dataList = PhotometerInput.readDataListFromAerlid(
                aerlidPath, aotCorrPath)

        except txt.Error as e:
            self.setErrorMessage(e.text)
            return

        self.clearStatusMessage()
        self.excelExportButton.setEnabled(True)
        self.dataListChanged.emit()

    if aeronetDownloaderPluginLoaded:
        def updateSiteListFile(self):
            """Download the most recent version of the AERONET site list file,
            update contents of the site name combo box appropriately if the
            download succeeds, and then restore the currently selected site
            name in the combo box."""

            if downloadAeronetSiteListFile(self, self.getSiteListFilePath()):

                # Load the updated site list data file.
                self.populateSiteNameComboBox(True)

                # Restore the previously selected site name in the combo box.
                # This will overwrite the current 'self.siteName' with 'None'
                # if the newly loaded list doesn't contain the previously
                # selected site.
                previousSiteName = self.siteName
                self.syncComboBoxIndices()

                # If the previously selected site happened to get excluded from
                # the site list, photometer data have to be reloaded in order
                # to clear the photometer data list. If no site list file had
                # been loaded prior to this function call, 'loadPhotometerData'
                # has to be called as well in order to remove the error message
                # about site list loading failure.
                #
                # In the former case, it may be necessary to call
                # 'saveAotCorrFile' method as well, so that any unsaved data
                # are saved. On the other hand, 'saveAotCorrFile' requires
                # 'self.siteName' to be set in order to work properly. Work
                # this around by temporarily setting the previous value of the
                # variable. If no site list file had been loaded,
                # 'saveAotCorrFile' will do nothing.
                if self.siteName is None:

                    self.siteName = previousSiteName
                    self.saveAotCorrFile()
                    self.siteName = None
                    self.loadPhotometerData()

            else:
                # Restore the previously selected site name in the combo box. No
                # update is needed, as neither site list nor site name has changed.
                self.syncComboBoxIndices()

    # ---- Private slots ------------------------------------------------------
    def runAerlidTool(self):
        """Run AERLID tool for the original Dubovik data file that corresponds
        to the currently selected AERLID file, and then reload the updated
        AERLID data file."""

        # This message will be cleared by 'setErrorMessage' later on.
        self.setProgressMessage('Processing AERONET data (%s)...' %
            txt.quotePath(self.getDubovikFilePath()), True)

        # Run the AERLID tool.
        aerlidToolDialog = AerlidToolDialog(self, 'code/binary/AERLID',
            (355.0, 532.0, 1064.0),
            self.getDubovikFilePath(), self.getAerlidFilePath())

        aerlidToolDialog.exec_()

        # Report the error message if data processing has failed.
        if aerlidToolDialog.getErrorMessage() is not None:
            self.setErrorMessage(aerlidToolDialog.getErrorMessage())
            return

        # Reload the updated data file.
        self.loadPhotometerData()

    def onComboBoxIndexChanged(self, index):
        """Update local variables that define the currently selected photometer
        data file and load data from the newly selected file.

        If 'Update site list' item has been selected in the AERONET site name
        combo box, download the most recent version of the site list file and
        update the combo box' contents appropriately."""

        # Nicely close the currently selected data file. The AERLID file itself
        # will remain the same; only the auxiliary AOT correction data file
        # will be modified here.
        self.saveAotCorrFile()

        # ---- 'siteName' ----
        siteIndex = self.siteNameComboBox.currentIndex()

        if aeronetDownloaderPluginLoaded:
            # Check whether newly selected item is the extra utility one.
            if siteIndex == self.siteNameComboBox.count() - 1:

                assert (self.siteNameComboBox.itemText(siteIndex) ==
                    'Update site list')

                self.updateSiteListFile()
                return

        # Update the local site name variable.
        if siteIndex >= 0:
            self.siteName = str(self.siteNameComboBox.itemText(siteIndex))
        else:
            self.siteName = None

        # ---- 'year' ----
        yearIndex = self.yearComboBox.currentIndex()
        if yearIndex >= 0:
            self.year = int(self.yearComboBox.itemText(yearIndex))
        else:
            self.year = None

        # ---- 'dataLevel' ----
        dataLevelIndex = self.dataLevelComboBox.currentIndex()
        # 'dataLevel' attribute should always be valid.
        assert dataLevelIndex >= 0
        self.dataLevel = str(self.dataLevelComboBox.itemText(dataLevelIndex))

        # ---- Post-processing ----
        # Disable "Update data" button if no data file is selected.
        self.updateDataButton.setEnabled(
            self.siteName is not None and self.year is not None)

        # Load the newly selected data file, if any.
        self.loadPhotometerData()

    def onUpdateDataButtonClicked(self):
        """Download the most recent version of the Dubovik AERONET data file
        corresponding to the currently selected photometer data subset, process
        the downloaded file with AERLID tool and then reload the updated AERLID
        data file."""

        if aeronetDownloaderPluginLoaded:
            if downloadAeronetDubovikDataFile(self, self.siteName, self.year,
                self.dataLevel, self.getDubovikFilePath()):

                # Process the downloaded Dubovik data file.
                self.runAerlidTool()
        pass

    def onExcelExportButtonClicked(self):

        # By default, save Excel files in the directory of the photometer data
        # files, using '.xls' extension and the common base file name prefix.
        defaultExcelFilePath = PhotometerInput.getExcelExportFilePath(
            self.getDubovikFilePath())

        excelFilePath = QFileDialog.getSaveFileName(self,
            'Export photometer data fine into Microsoft Excel format',
            defaultExcelFilePath,
            'Microsoft Excel files (*.xls)')

        if excelFilePath.isNull():
            return

        excelFilePath = txt.absolutePath(excelFilePath)

        # Excel export may take a long time. Display a message in the info
        # label so that the user could realize that the action is in progress.
        self.setProgressMessage('Exporting photometer data file into '
            'Microsoft Excel format...', True)

        try:
            PhotometerInput.exportDataListToExcel(excelFilePath, self.dataList)

        except txt.Error as e:
            QMessageBox.critical(self, 'Failed to save the file', e.text)

        # Remove the progress message.
        self.clearStatusMessage()

    def onReloadButtonClicked(self):
        """Save changes made to 'aot675Corr' attributes of the currently loaded
        list of 'PhotometerInput' instances, reload the AERONET site list file
        and then reload the currently selected photometer data file."""

        self.saveAotCorrFile()
        # Load the AERONET site list data file.
        self.populateSiteNameComboBox(True)
        self.syncComboBoxIndices()
        self.loadPhotometerData()

    def onOpenButtonClicked(self):
        """Ask the user to select a new value for the database root folder."""

        dialogCaption = 'Select root folder for the photometer database'

        # Expand the dialog's directory tree to the application's root
        # directory if current photometer database root is invalid.
        if not os.path.isdir(self.databaseRootDir):
            dirPath = '.'
        else:
            dirPath = self.databaseRootDir

        dirPath = QFileDialog.getExistingDirectory(self, dialogCaption,
            dirPath)

        # On Windows, 'getExistingDirectory' may return an empty but not-null
        # string sometimes (e.g. if "My Web Sites on MSN" directory is selected
        # in the dialog box).
        if dirPath.isNull() or dirPath == '':
            return

        dirPath = txt.absolutePath(dirPath)

        # Photometer root directory should normally contain subdirectories
        # named according to AERONET site names, whereas ordinary directories
        # would usually contain subdirectories not present in the list. Warn
        # the user if the selected folder is suspicious.

        # Open button should be disabled if there's no valid site list.
        assert self.siteInfoList is not None

        siteNames = [info.siteName for info in self.siteInfoList]

        try:
            # Find subdirectories whose names are absent in the site name list.
            rootSubDirs = []
            for name in os.listdir(dirPath):
                if (os.path.isdir(os.path.join(dirPath, name)) and
                    name not in siteNames):

                    rootSubDirs.append(name)

        # Sometimes 'os.listdir' may fail on directories returned by the dialog
        # box (e.g. on unmounted floppy or CD-ROM drives).
        except OSError:
            QMessageBox.critical(self, dialogCaption,
                'Selected folder (%s) is not accessible' %
                    txt.quotePath(dirPath))
            return

        # Ask a confirmation from the user if selected folder contains
        # suspicious subdirectories.
        if len(rootSubDirs) > 0:

            # Display a list of up to 3 first suspicious subdirectories, in
            # alphabetical order.
            rootSubDirs.sort()

            if len(rootSubDirs) == 1:
                nameListStr = '%s' % txt.quote(rootSubDirs[0])
            elif len(rootSubDirs) == 2:
                nameListStr = '%s and %s' % (
                    txt.quote(rootSubDirs[0]), txt.quote(rootSubDirs[1]))
            elif len(rootSubDirs) == 3:
                nameListStr = '%s, %s, and %s' % (
                    txt.quote(rootSubDirs[0]), txt.quote(rootSubDirs[1]),
                    txt.quote(rootSubDirs[2]))
            else:
                nameListStr = '%s, %s, %s, etc.' % (
                    txt.quote(rootSubDirs[0]), txt.quote(rootSubDirs[1]),
                    txt.quote(rootSubDirs[2]))

            if len(rootSubDirs) == 1:
                subfoldersStr = 'a subfolder'
                namesStr = 'name'
                dontStr = u'doesn\u2019t'
            else:
                subfoldersStr = 'subfolders'
                namesStr = 'names'
                dontStr = u'don\u2019t'

            msgBox = QMessageBox(self)
            msgBox.setWindowTitle(dialogCaption)
            msgBox.setText(
                'Selected folder (%s) contains %s (%s), whose %s %s coincide '
                'with any of AERONET site names.' %
                (txt.quotePath(dirPath), subfoldersStr, nameListStr, namesStr,
                dontStr))
            msgBox.setInformativeText(
                'Do you really want this folder to be the root of the '
                'photometer database?')
            msgBox.setIcon(QMessageBox.Warning)
            msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No)

            if msgBox.exec_() == QMessageBox.No:
                return

        # Save any unsaved data under the previous database root.
        self.saveAotCorrFile()
        # Switch to the selected root folder and reload the data from the
        # updated location.
        self.databaseRootDir = txt.absolutePath(dirPath)
        self.loadPhotometerData()

# *****************************************************************************
class AeronetSiteInfo:
    """Description of an AERONET site stored in the AERONET site list file.

    Attributes:
      - 'siteName': string holding the AERONET site name.
      - 'longitude', 'latitude': geodetic coordinates of the site, in degrees.
      - 'altitude': site's elevation, in meters."""
    pass

# *****************************************************************************
class AeronetSiteListLoader:
    """Utility class used for parsing of the AERONET site list file."""

    # Private attributes:
    #   - 'file': instance of the opened data file that is being loaded.
    #   - 'errorPrefix': string to be used with error messages related to
    #     invalid format of the data file.
    #   - 'currentLine': zero-based index of the data file line being
    #     processed.

    # ---- Public methods -----------------------------------------------------
    def __init__(self):
        """Initialize auxiliary attributes used to describe the data format."""

        # Data are stored in a text file with 4 columns separated by commas.

        self.headers = ('Site_Name', 'Longitude(decimal_degrees)',
            'Latitude(decimal_degrees)', 'Elevation(meters)')

        self.nameIndex = self.headers.index('Site_Name')
        self.longitudeIndex = self.headers.index('Longitude(decimal_degrees)')
        self.latitudeIndex = self.headers.index('Latitude(decimal_degrees)')
        self.altitudeIndex = self.headers.index('Elevation(meters)')

    def loadFile(self, filePath):
        """Load the specified AERONET site list file.

        Return a list of 'AeronetSiteInfo' instances.

        Raise 'txt.Error' if loading fails or file format is invalid."""

        # The real loading is done in 'readFile'. Here, the file is opened,
        # and 'file' and 'errorPrefix' attributes are set.

        if not os.path.isfile(filePath):
            raise txt.Error('Aeronet site list file (%s) is not available' %
                txt.quotePath(filePath))

        try:
            self.file = open(filePath)
        except IOError:
            raise txt.Error('Aeronet site list file (%s) may not be opened '
                'for reading' % txt.quotePath(filePath))

        self.errorPrefix = ('Aeronet site list file format is invalid (%s): ' %
            txt.quotePath(filePath))

        try:
            return self.readFile()

        except IOError:
            raise txt.Error('Failed to read Aeronet site list file (%s)' %
                txt.quotePath(filePath))
        finally:
            self.file.close()

    # ---- Private methods ----------------------------------------------------
    def readFile(self):
        """Load the opened data file referenced in 'self.file'."""

        # This will be a list of 'AeronetSiteInfo' instances.
        siteInfoList = []

        # Skip the generic header line.
        self.file.readline()

        # Read the data column headers and check them against the expected
        # values. Column headers are separated by commas.
        dataHeaders = self.file.readline().strip().split(',')

        for i in range(len(self.headers)):
            self.testColumnHeader(dataHeaders, i)

        # Initialize the 'currentLine' attribute (there are 2 header lines).
        self.currentLine = 2

        for line in self.file:
            # Read the data strings stored in the current line.
            # Data columns are separated by commas.
            dataStrings = line.strip().split(',')

            siteInfo = AeronetSiteInfo()

            # Read contents of the data columns.
            siteInfo.siteName = self.readSiteName(dataStrings)
            siteInfo.longitude = self.readFloatValue(dataStrings,
                self.longitudeIndex)
            siteInfo.latitude = self.readFloatValue(dataStrings,
                self.latitudeIndex)
            siteInfo.altitude = self.readFloatValue(dataStrings,
                self.altitudeIndex)

            siteInfoList.append(siteInfo)

            # Update the 'currentLine' attribute.
            self.currentLine += 1

        return siteInfoList

    def testColumnHeader(self, headerList, i):
        """Check if the given data column header has the expected value.

        Raise 'txt.Error' on failure.

        'headerList' should be the comlete list of header strings read from the
        file. 'i' should be the index of the data column."""

        if i >= len(headerList):
            raise txt.Error(self.errorPrefix +
                'data column %s (%s) in missing' %
                (txt.quoteNumber(i + 1), txt.quote(self.headers[i])))

        if headerList[i] != self.headers[i]:
            raise txt.Error(self.errorPrefix +
                'header of column %s is %s instead of %s'%
                (txt.quoteNumber(i + 1), txt.quote(headerList[i]),
                txt.quote(self.headers[i])))

    def readFloatValue(self, dataList, i):
        """Parse a floating-point value read from the data file.

        Return the parsed value.

        Raise 'txt.Error' on failure.

        'dataList' should be the comlete list of data strings read from a
        single line of the data file. 'i' should be the index of the data
        column."""

        if i >= len(dataList):
            raise txt.Error(self.errorPrefix +
                'missing data in column %s (line %s)' %
                (txt.quoteNumber(i + 1),
                txt.quoteNumber(self.currentLine + 1)))

        try:
            return float(dataList[i])

        except ValueError:
            raise txt.Error(self.errorPrefix +
                'invalid number in column %s (line %s)' %
                (txt.quoteNumber(i + 1),
                txt.quoteNumber(self.currentLine + 1)))

    def readSiteName(self, dataList):
        """Parse and check the site name read from the AERONET site list file.

        Return a string holding the site name.

        Raise 'txt.Error' on failure.

        'dataList' should be the comlete list of data strings read from a
        single line of the data file."""

        if self.nameIndex >= len(dataList):
            raise txt.Error(self.errorPrefix + 'missing site name in line %s' %
                txt.quoteNumber(self.currentLine + 1))

        siteName = dataList[self.nameIndex]

        # Check if site name is a non-empty ASCII string consisting of
        # alphanumeric characters, underscores and dashes.
        if re.match(r'[a-zA-Z0-9_\-]+$', siteName) is None:
            raise txt.Error(self.errorPrefix +
                'invalid site name in line %s (%s)' %
                (txt.quoteNumber(self.currentLine + 1), txt.quote(siteName)))

        return siteName
