diff --git a/docs/usage.rst b/docs/usage.rst index ae9b79dd4..cc62421d8 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -131,6 +131,28 @@ you can use these parameters. If you use shared directories you may have to solv .. literalinclude:: samples/config/msui/snippets/caching.sample +Data values in Table View +------------------------- + +The Linear View shows the values of a data field along the flight path, as retrieved +from a WMS server. Enabling the *show data* checkbox of the Table View appends one +column per layer plotted in the Linear View of the same flight track, giving the exact +value at each waypoint. Waypoints for which the server provides no data, e.g. because +the aircraft is on the ground, stay empty. + +The columns are refreshed with every plot of the Linear View and are removed when the +Linear View is closed. + +As long as no Linear View provides data for the active flight track there is nothing to +show, hence the checkbox is unchecked and greyed out. Activating another flight track +therefore empties the columns first: the values the Linear View retrieved for the flight +track it does not plot any more are dropped, they would be outdated. The columns are +filled again with the plot of the newly activated flight track, which the Linear View +requests right away. The checkbox remembers your wish while it is greyed out, +so the columns come back on their own, also when you switch +back and forth between flight tracks. + + Docking Widgets Configurations ------------------------------ diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 45f327031..8ecb233f4 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -36,6 +36,7 @@ import datetime import logging +import math import os from pathlib import Path @@ -93,6 +94,90 @@ def seconds_to_string(seconds): TABLE_SHORT = [TABLE_FULL[_i] for _i in range(7)] + [TABLE_FULL[-1]] + [("", lambda _: "", False)] * 8 +# Number of the first table column that is filled with data retrieved by the +# linear view. All columns left of it are described by TABLE_FULL/TABLE_SHORT. +LINEAR_DATA_COLUMN = len(TABLE_FULL) + + +def values_at_waypoints(path_lats, path_lons, values, waypoints, tolerance=1e-6): + """ + Pick those values of a linear section that belong to the given waypoints. + + A linear section is computed for interpolated points along the flight + path, of which only a few coincide with a waypoint. The waypoints are + matched in the order in which they appear along the path. + + Arguments: + path_lats, path_lons -- coordinates of the interpolated points of the + linear section + values -- one data value per interpolated point + waypoints -- list of Waypoint objects the values are mapped onto + tolerance -- maximum deviation of a coordinate that is still considered + to be the same point + + Returns a list with one entry per waypoint. Waypoints that are not part + of the section, and waypoints where the data field is undefined (NaN), + get None assigned. + """ + result = [None] * len(waypoints) + index = 0 + for i, (lat, lon) in enumerate(zip(path_lats, path_lons)): + if index >= len(waypoints): + break + waypoint = waypoints[index] + # The path is sent to the WMS server rounded to two decimals, hence + # the returned coordinates have to be compared to rounded waypoints. + if abs(lat - float(f"{waypoint.lat:.2f}")) < tolerance and \ + abs(lon - float(f"{waypoint.lon:.2f}")) < tolerance: + if i < len(values) and not math.isnan(values[i]): + result[index] = values[i] + index += 1 + return result + + +def linear_data_columns(xmls, waypoints): + """ + Convert the linear section responses of the WMS server into data columns + at the positions of the given waypoints. + + Arguments: + xmls -- list of XML elements as returned for a "LINE:1" request, see + mslib.mswms.mpl_lsec + waypoints -- list of Waypoint objects the values are mapped onto + + Returns a list of dictionaries with the keys "name", "unit" and "values", + ready to be passed to WaypointsTableModel.set_linear_data(). + """ + columns = [] + names = set() + for element in xmls: + data = element.find("Data") + lats = element.find("Latitude") + lons = element.find("Longitude") + if any(node is None or node.text is None for node in (data, lats, lons)): + logging.debug("incomplete linear section data, skipping") + continue + title = element.find("Title") + name = title.text if title is not None and title.text else "Data" + # The name identifies the column, therefore it has to be unique. + unique_name = name + suffix = 1 + while unique_name in names: + suffix += 1 + unique_name = f"{name} ({suffix})" + names.add(unique_name) + + columns.append({ + "name": unique_name, + "unit": data.attrib.get("unit", ""), + "values": values_at_waypoints( + [float(value) for value in lats.text.split(",")], + [float(value) for value in lons.text.split(",")], + [float(value) for value in data.text.split(",")], + waypoints), + }) + return columns + def load_from_xml_data(xml_content, name="Flight track"): try: @@ -156,6 +241,10 @@ def __init__(self, lat=0., lon=0., flightlevel=0., location="", comments=""): self.wpnumber_major = None self.wpnumber_minor = None + # Data values retrieved by the linear view for the position of this + # waypoint (column name -> value). + self.linear_data = {} + def __str__(self): """ String representation of the waypoint (e.g., when used with the print @@ -179,6 +268,11 @@ class WaypointsTableModel(QtCore.QAbstractTableModel): # Signal emitted when a waypoint is moved, inserted or deleted changeMessageSignal = QtCore.pyqtSignal(str) + # Signal emitted when the data columns filled by the linear view have + # changed. This is deliberately not the dataChanged() signal, as the data + # does not belong to the flight plan itself and must not trigger a redraw + # of the flight path or an update of an MSColab operation. + linearDataChanged = QtCore.pyqtSignal() def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, data_dir=config_loader(dataset="mss_dir"), @@ -192,6 +286,11 @@ def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, # file-save events are handled in a different manner self.mscolab_mode = mscolab_mode + # Data columns filled by the linear view, a list of (name, unit) + # tuples. The values themselves are stored per waypoint. + self.linear_data_columns = [] + self.linear_data_visible = False + # self.aircraft.setErrorHandling("permissive") self.settings_tag = "performance" self.load_settings() @@ -235,7 +334,8 @@ def flags(self, index): table = TABLE_SHORT if self.performance_settings["visible"]: table = TABLE_FULL - if table[column][2]: + # The data columns of the linear view are read only. + if column < LINEAR_DATA_COLUMN and table[column][2]: return QtCore.Qt.ItemFlags( int(QtCore.QAbstractTableModel.flags(self, index) | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled | @@ -263,6 +363,8 @@ def data(self, index, role=QtCore.Qt.DisplayRole): waypoint = waypoints[index.row()] column = index.column() if role == QtCore.Qt.DisplayRole: + if column >= LINEAR_DATA_COLUMN: + return QtCore.QVariant(self.linear_data_value(waypoint, column)) if self.performance_settings["visible"]: return QtCore.QVariant(TABLE_FULL[column][1](waypoint)) else: @@ -310,6 +412,12 @@ def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): return QtCore.QVariant() # Return the names of the table columns. if orientation == QtCore.Qt.Horizontal: + if section >= LINEAR_DATA_COLUMN: + columns = self.visible_linear_data_columns() + if not 0 <= section - LINEAR_DATA_COLUMN < len(columns): + return QtCore.QVariant() + name, unit = columns[section - LINEAR_DATA_COLUMN] + return QtCore.QVariant(f"{name}\n({unit})" if unit else name) if self.performance_settings["visible"]: return QtCore.QVariant(TABLE_FULL[section][0]) else: @@ -325,7 +433,102 @@ def rowCount(self, index=QtCore.QModelIndex()): return len(self.waypoints) def columnCount(self, index=QtCore.QModelIndex()): - return len(TABLE_FULL) + return LINEAR_DATA_COLUMN + len(self.visible_linear_data_columns()) + + def visible_linear_data_columns(self): + """ + Return the (name, unit) tuples of the data columns of the linear view + that are currently appended to the table. + """ + return self.linear_data_columns if self.linear_data_visible else [] + + def linear_data_value(self, waypoint, column): + """ + Return the value of the linear view data column at the given + waypoint, formatted for display. Waypoints without data (e.g. because + the aircraft is on the ground) give an empty string. + """ + columns = self.visible_linear_data_columns() + if not 0 <= column - LINEAR_DATA_COLUMN < len(columns): + return "" + value = waypoint.linear_data.get(columns[column - LINEAR_DATA_COLUMN][0]) + return "" if value is None else f"{value:.4g}" + + def set_linear_data_visible(self, visible): + """ + Show or hide the data columns filled by the linear view. + """ + visible = bool(visible) + if visible == self.linear_data_visible: + return + count = len(self.linear_data_columns) + if count == 0: + self.linear_data_visible = visible + elif visible: + self.beginInsertColumns(QtCore.QModelIndex(), LINEAR_DATA_COLUMN, LINEAR_DATA_COLUMN + count - 1) + self.linear_data_visible = True + self.endInsertColumns() + else: + self.beginRemoveColumns(QtCore.QModelIndex(), LINEAR_DATA_COLUMN, LINEAR_DATA_COLUMN + count - 1) + self.linear_data_visible = False + self.endRemoveColumns() + self.linearDataChanged.emit() + + def set_linear_data(self, columns): + """ + Store the data retrieved by the linear view. + + Arguments: + columns -- list of dictionaries with the keys "name", "unit" and + "values", where "values" contains one value per waypoint + (None where no data is available). The names have to be + unique, they identify the columns. See + linear_data_columns(). + """ + for i, waypoint in enumerate(self.waypoints): + waypoint.linear_data = { + column["name"]: column["values"][i] for column in columns + if i < len(column["values"]) and column["values"][i] is not None} + self._set_linear_data_columns([(column["name"], column["unit"]) for column in columns]) + + def set_linear_data_from_xml(self, xmls): + """ + Store the linear section data of the given WMS responses, see + linear_data_columns(). + """ + self.set_linear_data(linear_data_columns(xmls, self.waypoints)) + + def clear_linear_data(self): + """ + Drop all data retrieved by the linear view. + """ + for waypoint in self.waypoints: + waypoint.linear_data = {} + self._set_linear_data_columns([]) + + def _set_linear_data_columns(self, columns): + """ + Adapt the table to the given list of (name, unit) data columns and + notify the connected views. + """ + previous = len(self.linear_data_columns) + count = len(columns) + if not self.linear_data_visible or previous == count: + self.linear_data_columns = columns + elif count > previous: + self.beginInsertColumns( + QtCore.QModelIndex(), LINEAR_DATA_COLUMN + previous, LINEAR_DATA_COLUMN + count - 1) + self.linear_data_columns = columns + self.endInsertColumns() + else: + self.beginRemoveColumns( + QtCore.QModelIndex(), LINEAR_DATA_COLUMN + count, LINEAR_DATA_COLUMN + previous - 1) + self.linear_data_columns = columns + self.endRemoveColumns() + if self.linear_data_visible and count > 0: + self.headerDataChanged.emit( + QtCore.Qt.Horizontal, LINEAR_DATA_COLUMN, LINEAR_DATA_COLUMN + count - 1) + self.linearDataChanged.emit() def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): """ diff --git a/mslib/msui/linearview.py b/mslib/msui/linearview.py index f5b2cf2ef..10adb5244 100644 --- a/mslib/msui/linearview.py +++ b/mslib/msui/linearview.py @@ -129,6 +129,15 @@ def __init__(self, parent=None, mainwindow=None, model=None, _id=None, config_se def __del__(self): del self.mpl.canvas.waypoints_interactor + def closeEvent(self, event): + """ + Drop the data columns of the table view, this view is the source of + their values. + """ + super().closeEvent(event) + if event.isAccepted() and self.waypoints_model is not None: + self.waypoints_model.clear_linear_data() + def update_predefined_maps(self, extra): pass @@ -220,9 +229,18 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the view displays. """ - super().setFlightTrackModel(model) + previous = self.waypoints_model if self.docks[WMS] is not None: + # The WMS control has to know the new flight track before the + # redraw below asks it for a plot, its request describes the path. self.docks[WMS].widget().setFlightTrackModel(model) + super().setFlightTrackModel(model) + if previous is not None and previous is not model: + # The data values of the flight track that is not plotted here any + # more are outdated, the table view must not show them as if they + # belonged to the current plot. The values of the new flight track + # arrive with the plot that the redraw above requests. + previous.clear_linear_data() def open_settings_dialog(self): settings = self.getView().get_settings() diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index e4c63ec64..20fe61251 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -714,6 +714,10 @@ def draw_legend(self, img): def draw_image(self, xmls, colors=None, scales=None): self.plotter.draw_image(xmls, colors, scales) self.redraw_xaxis() + # Hand the retrieved values over to the flight track model, so that + # other views (e.g. the table view) can show the data at a waypoint. + if self.waypoints_model is not None: + self.waypoints_model.set_linear_data_from_xml(xmls) def redraw_xaxis(self): """Redraw the x-axis of the linear view on path changes. diff --git a/mslib/msui/qt5/ui_tableview_window.py b/mslib/msui/qt5/ui_tableview_window.py index 6b22797e7..115ea918f 100644 --- a/mslib/msui/qt5/ui_tableview_window.py +++ b/mslib/msui/qt5/ui_tableview_window.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'mslib/msui/ui/ui_tableview_window.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.11 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -44,6 +45,9 @@ def setupUi(self, TableViewWindow): self.cbTools.setObjectName("cbTools") self.cbTools.addItem("") self.horizontalLayout.addWidget(self.cbTools) + self.cbShowLinearData = QtWidgets.QCheckBox(self.centralwidget) + self.cbShowLinearData.setObjectName("cbShowLinearData") + self.horizontalLayout.addWidget(self.cbShowLinearData) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout.addItem(spacerItem) self.label = QtWidgets.QLabel(self.centralwidget) @@ -72,13 +76,15 @@ def setupUi(self, TableViewWindow): TableViewWindow.addAction(self.actionCloseWindow) self.retranslateUi(TableViewWindow) - self.actionCloseWindow.triggered.connect(TableViewWindow.close) + self.actionCloseWindow.triggered.connect(TableViewWindow.close) # type: ignore QtCore.QMetaObject.connectSlotsByName(TableViewWindow) def retranslateUi(self, TableViewWindow): _translate = QtCore.QCoreApplication.translate TableViewWindow.setWindowTitle(_translate("TableViewWindow", "Table View - Mission Support System")) self.cbTools.setItemText(0, _translate("TableViewWindow", "(select to open control)")) + self.cbShowLinearData.setToolTip(_translate("TableViewWindow", "Show a column with the data values of the linear view at each waypoint")) + self.cbShowLinearData.setText(_translate("TableViewWindow", "show data")) self.label.setText(_translate("TableViewWindow", "Waypoints:")) self.btAddWayPointToFlightTrack.setText(_translate("TableViewWindow", "insert")) self.btCloneWaypoint.setText(_translate("TableViewWindow", "clone")) diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index 199b453b3..abf9fa154 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -66,6 +66,17 @@ def __init__(self, parent=None, model=None, _id=None, tutorial_mode=False): self.setWindowIcon(QtGui.QIcon(icons('64x64'))) self.settings_tag = "tableview" + # Tooltip of while it is usable, a different one + # explains the greyed out checkbox. + self.show_linear_data_tooltip = self.cbShowLinearData.toolTip() + # Whether the user wants the data columns. The checkbox itself only + # shows them where a linear view provides data, this remembers the + # wish across flight tracks that have none. + self.show_linear_data_wanted = False + # Guards the checkbox against taking a state set by this window for + # the wish of the user. + self._updating_show_linear_data = False + self.setFlightTrackModel(model) self.tableWayPoints.setItemDelegate(ft.WaypointDelegate(self)) @@ -84,6 +95,7 @@ def __init__(self, parent=None, model=None, _id=None, tutorial_mode=False): self.btDeleteWayPoint.clicked.connect(self.removeWayPoint) self.btInvertDirection.clicked.connect(self.invertDirection) self.btRoundtrip.clicked.connect(self.make_roundtrip) + self.cbShowLinearData.toggled.connect(self.setLinearDataVisible) self.tableWayPoints.selectionModel().selectionChanged.connect(self.on_selection_changed) # Tool opener. @@ -101,6 +113,46 @@ def setPerformance(self, settings): self.resizeColumns() self.tableWayPoints.viewport().repaint() + def setLinearDataVisible(self, visible): + """ + Handler for the checkbox. Shows or hides the + columns with the data values of the linear view. + """ + if not self._updating_show_linear_data: + self.show_linear_data_wanted = visible + self.waypoints_model.set_linear_data_visible(visible) + + def update_linear_data(self): + """ + Slot called when the data retrieved by the linear view has changed. + The values are not part of the flight plan, hence the model does not + emit dataChanged() for them and the table has to be repainted here. + """ + self.update_show_linear_data_enabled() + self.tableWayPoints.viewport().update() + self.resizeColumns() + + def update_show_linear_data_enabled(self): + """ + Match the checkbox to the data of the active flight + track. As long as no linear view provides data for it there is nothing + to show, hence the checkbox is unchecked and greyed out; a tick without + data columns would be misleading. The wish of the user is remembered, + so the columns of a flight track reappear as soon as the linear view + has data for it, e.g. after switching back and forth between tracks. + """ + available = len(self.waypoints_model.linear_data_columns) > 0 + # The checkbox passes the change on to the flight track (toggled). + self._updating_show_linear_data = True + try: + self.cbShowLinearData.setChecked(available and self.show_linear_data_wanted) + finally: + self._updating_show_linear_data = False + self.cbShowLinearData.setEnabled(available) + self.cbShowLinearData.setToolTip( + self.show_linear_data_tooltip if available else + "No linear view provides data values for this flight track") + def on_selection_changed(self, index): """ Disables insert and clone when multiple rows are selected @@ -273,13 +325,37 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the table displays. """ + previous = self.waypoints_model super().setFlightTrackModel(model) + if previous is not None and previous is not self.waypoints_model: + # Stop listening to the flight track that is not displayed here + # any more, otherwise the connections pile up on every switch. + for signal, slot in ((previous.dataChanged, self.update_roundtrip_enabled), + (previous.linearDataChanged, self.update_linear_data)): + try: + signal.disconnect(slot) + except (TypeError, RuntimeError): + pass self.tableWayPoints.setModel(self.waypoints_model) # Automatically enable or disable roundtrip when data changes self.waypoints_model.dataChanged.connect(self.update_roundtrip_enabled) self.update_roundtrip_enabled() + self.waypoints_model.linearDataChanged.connect(self.update_linear_data) + if previous is None or previous is self.waypoints_model: + # Adopt what the flight track already shows, another table view + # of the same track may have switched the data columns on. + self.show_linear_data_wanted = self.waypoints_model.linear_data_visible + else: + # The wish of the user applies to the flight track that becomes + # the active one, its data is what the linear view retrieved for + # that track. The checkbox follows below, ticked only if there is + # data to show. + self.waypoints_model.set_linear_data_visible(self.show_linear_data_wanted) + self.update_show_linear_data_enabled() + self.resizeColumns() + def viewPerformance(self): """ Slot to toggle the view mode of the table between 'USER' and diff --git a/mslib/msui/ui/ui_tableview_window.ui b/mslib/msui/ui/ui_tableview_window.ui index 4c0ddda98..0a84f10cd 100644 --- a/mslib/msui/ui/ui_tableview_window.ui +++ b/mslib/msui/ui/ui_tableview_window.ui @@ -72,6 +72,16 @@ + + + + Show a column with the data values of the linear view at each waypoint + + + show data + + + diff --git a/tests/_test_msui/test_flighttrack.py b/tests/_test_msui/test_flighttrack.py index 4bea6f8a4..90065b7c9 100644 --- a/tests/_test_msui/test_flighttrack.py +++ b/tests/_test_msui/test_flighttrack.py @@ -24,9 +24,14 @@ limitations under the License. """ import json +import xml.etree.ElementTree as etree -from mslib.msui.flighttrack import WaypointsTableModel +from PyQt5 import QtCore + +from mslib.msui.flighttrack import (LINEAR_DATA_COLUMN, TABLE_FULL, Waypoint, WaypointsTableModel, + linear_data_columns, values_at_waypoints) from mslib.msui.performance_settings import DEFAULT_PERFORMANCE +from tests.utils import lsec_xml class Test_WaypointsTableModel_CorruptedSettings: @@ -101,3 +106,85 @@ def test_isinstance_check_with_various_types(self): "Dict should pass isinstance dict check" assert isinstance({}, dict), \ "Empty dict should pass isinstance dict check" + + +class Test_LinearData: + """ + Tests for the data columns that the linear view fills in the table view. + """ + + def setup_method(self): + self.waypoints = [Waypoint(0., 0., 0.), Waypoint(1., 1., 350.), + Waypoint(2., 2., 350.), Waypoint(3., 3., 0.)] + self.model = WaypointsTableModel("") + self.model.insertRows(0, rows=len(self.waypoints), waypoints=self.waypoints) + + def test_values_at_waypoints(self): + # Only the waypoints, not the interpolated points in between, are + # returned, and NaN values (aircraft on the ground) become None. + assert values_at_waypoints( + [0., 0.5, 1., 1.5, 2., 2.5, 3.], [0., 0.5, 1., 1.5, 2., 2.5, 3.], + [float("nan"), 5., 10., 12., 20., 22., float("nan")], + self.waypoints) == [None, 10., 20., None] + + def test_values_at_waypoints_without_matching_points(self): + assert values_at_waypoints([10., 11.], [10., 11.], [1., 2.], self.waypoints) == [None] * 4 + + def test_linear_data_columns(self): + columns = linear_data_columns([lsec_xml()], self.waypoints) + assert columns == [{"name": "Mole fraction of ozone (Linear)", + "unit": "ppmv", + "values": [None, 10., 20., None]}] + + def test_linear_data_columns_makes_names_unique(self): + columns = linear_data_columns([lsec_xml(), lsec_xml()], self.waypoints) + assert [column["name"] for column in columns] == ["Mole fraction of ozone (Linear)", + "Mole fraction of ozone (Linear) (2)"] + + def test_linear_data_columns_skips_incomplete_data(self): + incomplete = etree.fromstring("Empty") + assert linear_data_columns([incomplete], self.waypoints) == [] + + def test_columns_are_hidden_by_default(self): + self.model.set_linear_data_from_xml([lsec_xml()]) + assert self.model.columnCount() == len(TABLE_FULL) + assert self.model.visible_linear_data_columns() == [] + + def test_columns_are_shown_on_demand(self): + self.model.set_linear_data_from_xml([lsec_xml()]) + self.model.set_linear_data_visible(True) + assert self.model.columnCount() == len(TABLE_FULL) + 1 + assert self.model.headerData( + LINEAR_DATA_COLUMN, QtCore.Qt.Horizontal).value() == "Mole fraction of ozone (Linear)\n(ppmv)" + values = [self.model.data(self.model.index(row, LINEAR_DATA_COLUMN)).value() + for row in range(self.model.rowCount())] + assert values == ["", "10", "20", ""] + # The data columns cannot be edited. + assert not self.model.flags(self.model.index(0, LINEAR_DATA_COLUMN)) & QtCore.Qt.ItemIsEditable + + self.model.set_linear_data_visible(False) + assert self.model.columnCount() == len(TABLE_FULL) + + def test_data_stays_at_its_waypoint_when_waypoints_change(self): + self.model.set_linear_data_from_xml([lsec_xml()]) + self.model.set_linear_data_visible(True) + self.model.insertRows(0, waypoints=[Waypoint(10., 10., 0.)]) + values = [self.model.data(self.model.index(row, LINEAR_DATA_COLUMN)).value() + for row in range(self.model.rowCount())] + assert values == ["", "", "10", "20", ""] + + def test_clear_linear_data(self): + self.model.set_linear_data_from_xml([lsec_xml()]) + self.model.set_linear_data_visible(True) + self.model.clear_linear_data() + assert self.model.columnCount() == len(TABLE_FULL) + assert all(waypoint.linear_data == {} for waypoint in self.model.all_waypoint_data()) + + def test_linear_data_does_not_modify_the_flight_track(self): + changed = [] + self.model.modified = False + self.model.dataChanged.connect(lambda *args: changed.append(args)) + self.model.set_linear_data_visible(True) + self.model.set_linear_data_from_xml([lsec_xml()]) + assert changed == [] + assert not self.model.modified diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index bd4dfee77..7fd82d758 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -33,6 +33,7 @@ from mslib.msui.msui import MSUIMainWindow from mslib.msui.viewplotter import _DEFAULT_SETTINGS_LINEARVIEW from mslib.utils.config import config_loader +from tests.utils import lsec_xml WMS_REQUEST_TIMEOUT_MS = (config_loader(dataset="WMS_request_timeout") + 5) * 1000 @@ -77,6 +78,61 @@ def test_mouse_over(self): QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(100, 100), -1) + def test_draw_image_fills_waypoint_data(self): + """ + The retrieved values are handed over to the flight track model, where + the table view picks them up. + """ + waypoints = self.window.waypoints_model.all_waypoint_data() + self.window.mpl.canvas.draw_image([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "42", "13"))]) + + assert self.window.waypoints_model.linear_data_columns == [("Mole fraction of ozone (Linear)", "ppmv")] + assert [wp.linear_data for wp in waypoints] == [ + {}, {"Mole fraction of ozone (Linear)": 42.}, {"Mole fraction of ozone (Linear)": 13.}] + + def test_close_clears_waypoint_data(self): + waypoints = self.window.waypoints_model.all_waypoint_data() + self.window.mpl.canvas.draw_image([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "42", "13"))]) + + self.window.force_close = True + self.window.close() + + assert self.window.waypoints_model.linear_data_columns == [] + assert [wp.linear_data for wp in waypoints] == [{}, {}, {}] + + def test_switch_flight_track_updates_data(self): + """ + On a switch to another flight track the values of the track that is + not plotted here any more are dropped, and the plot of the new track + is requested for its waypoints. + """ + waypoints = self.window.waypoints_model.all_waypoint_data() + self.window.mpl.canvas.draw_image([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "42", "13"))]) + previous = self.window.waypoints_model + + other = ft.WaypointsTableModel("other") + other.insertRows(0, rows=2, waypoints=[ft.Waypoint(48.10, 10.27, 200), + ft.Waypoint(52.32, 9.21, 200)]) + + wms_control = self.window.docks[tv.WMS].widget() + requested = [] + self.window.mpl.canvas.waypoints_interactor.signal_get_lsec.connect( + lambda: requested.append(wms_control.waypoints_model)) + + self.window.setFlightTrackModel(other) + + # the WMS control knows the new flight track when it is asked for a plot + assert requested == [other] + # the values of the previous flight track are outdated and gone + assert previous.linear_data_columns == [] + assert [wp.linear_data for wp in waypoints] == [{}, {}, {}] + @mock.patch("mslib.msui.linearview.MSUI_LV_Options_Dialog") def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.lvoptionbtn, QtCore.Qt.LeftButton) @@ -118,3 +174,5 @@ def test_server_getmap(self, qtbot): self.query_server(qtbot, self.url) with qtbot.wait_signal(self.wms_control.image_displayed, timeout=WMS_REQUEST_TIMEOUT_MS): QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) + # the retrieved data is offered as a column of the table view + assert len(self.window.waypoints_model.linear_data_columns) == 1 diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index fbf779b85..1ed62f45b 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -33,6 +33,7 @@ from mslib.msui import flighttrack as ft from mslib.msui.performance_settings import DEFAULT_PERFORMANCE import mslib.msui.tableview as tv +from tests.utils import lsec_xml class Test_TableView: @@ -159,6 +160,198 @@ def test_performance(self, mockopen): QtTest.QTest.mouseClick(self.window.docks[1].widget().pbLoadPerformance, QtCore.Qt.LeftButton) assert mockopen.call_count == 1 + def test_show_linear_data(self): + """ + The "show data" checkbox appends the data columns of the linear view. + """ + model = self.window.waypoints_model + waypoints = model.all_waypoint_data() + model.set_linear_data_from_xml([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "0.05", "0.06", "0.07", "nan"))]) + # the data is hidden as long as the checkbox is unchecked + assert model.columnCount() == 15 + + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert self.window.cbShowLinearData.isChecked() + assert model.columnCount() == 16 + column = ft.LINEAR_DATA_COLUMN + assert model.headerData( + column, QtCore.Qt.Horizontal).value() == "Mole fraction of ozone (Linear)\n(ppmv)" + # no values where the aircraft is on the ground + assert [model.data(model.index(row, column)).value() for row in range(model.rowCount())] == \ + ["", "0.05", "0.06", "0.07", ""] + + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert not self.window.cbShowLinearData.isChecked() + assert model.columnCount() == 15 + + def test_show_linear_data_of_another_flight_track(self): + """ + Switching to another flight track keeps the "show data" checkbox and + shows the data columns that the linear view retrieved for that track. + """ + model = self.window.waypoints_model + waypoints = model.all_waypoint_data() + model.set_linear_data_from_xml([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "0.05", "0.06", "0.07", "nan"))]) + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert model.columnCount() == 16 + + # the linear view of the other flight track plots two layers + other = ft.WaypointsTableModel("other") + other.insertRows(0, rows=3, waypoints=[ft.Waypoint(48.10, 10.27, 200), + ft.Waypoint(52.32, 9.21, 200), + ft.Waypoint(52.55, 9.99, 200)]) + other_waypoints = other.all_waypoint_data() + other.set_linear_data_from_xml([ + lsec_xml(title=title, unit="ppmv", + lats=[wp.lat for wp in other_waypoints], + lons=[wp.lon for wp in other_waypoints], + values=values) + for title, values in (("Ozone", ("0.1", "0.2", "0.3")), + ("Water vapour", ("1", "2", "nan")))]) + + self.window.setFlightTrackModel(other) + assert self.window.cbShowLinearData.isChecked() + assert other.columnCount() == 17 + column = ft.LINEAR_DATA_COLUMN + assert [other.headerData(column + i, QtCore.Qt.Horizontal).value() for i in range(2)] == \ + ["Ozone\n(ppmv)", "Water vapour\n(ppmv)"] + assert [other.data(other.index(row, column)).value() for row in range(other.rowCount())] == \ + ["0.1", "0.2", "0.3"] + assert [other.data(other.index(row, column + 1)).value() for row in range(other.rowCount())] == \ + ["1", "2", ""] + + # the data of the other flight track is hidden again on demand + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert other.columnCount() == 15 + + # ... and the track shown before does not notify this window any more + assert model.receivers(model.linearDataChanged) == 0 + + def test_show_linear_data_without_linear_view(self): + """ + The "show data" checkbox is unchecked and greyed out as long as no + linear view provides data. + """ + model = self.window.waypoints_model + waypoints = model.all_waypoint_data() + # no linear view has plotted this flight track yet + assert not self.window.cbShowLinearData.isEnabled() + assert not self.window.cbShowLinearData.isChecked() + + data = [lsec_xml(lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "0.05", "0.06", "0.07", "nan"))] + model.set_linear_data_from_xml(data) + assert self.window.cbShowLinearData.isEnabled() + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert model.columnCount() == 16 + + # closing the linear view drops the data columns and the tick + model.clear_linear_data() + assert not self.window.cbShowLinearData.isEnabled() + assert not self.window.cbShowLinearData.isChecked() + assert not model.linear_data_visible + assert model.columnCount() == 15 + + # ... the tick and the columns are back with the next plot + model.set_linear_data_from_xml(data) + assert self.window.cbShowLinearData.isEnabled() + assert self.window.cbShowLinearData.isChecked() + assert model.columnCount() == 16 + + def test_show_linear_data_disabled_for_flight_track_without_data(self): + """ + Switching to a flight track that no linear view plots unchecks and + greys the "show data" checkbox out. + """ + model = self.window.waypoints_model + waypoints = model.all_waypoint_data() + model.set_linear_data_from_xml([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "0.05", "0.06", "0.07", "nan"))]) + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert self.window.cbShowLinearData.isEnabled() + + other = ft.WaypointsTableModel("other") + other.insertRows(0, rows=2, waypoints=[ft.Waypoint(48.10, 10.27, 200), + ft.Waypoint(52.32, 9.21, 200)]) + self.window.setFlightTrackModel(other) + assert not self.window.cbShowLinearData.isEnabled() + assert not self.window.cbShowLinearData.isChecked() + assert not other.linear_data_visible + assert other.columnCount() == 15 + + def test_show_linear_data_of_flight_tracks_switched_back_and_forth(self): + """ + The data columns follow the active flight track: they are shown as + soon as the linear view has retrieved the values of that track, also + when switching back and forth between flight tracks. + """ + model = self.window.waypoints_model + waypoints = model.all_waypoint_data() + data = [lsec_xml(lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "0.05", "0.06", "0.07", "nan"))] + model.set_linear_data_from_xml(data) + QtTest.QTest.mouseClick(self.window.cbShowLinearData, QtCore.Qt.LeftButton) + assert model.columnCount() == 16 + + other = ft.WaypointsTableModel("other") + other.insertRows(0, rows=2, waypoints=[ft.Waypoint(48.10, 10.27, 200), + ft.Waypoint(52.32, 9.21, 200)]) + other_waypoints = other.all_waypoint_data() + other_data = [lsec_xml(lats=[wp.lat for wp in other_waypoints], + lons=[wp.lon for wp in other_waypoints], values=("0.1", "0.2"))] + # the linear view drops the values of the track it does not plot any more + model.clear_linear_data() + self.window.setFlightTrackModel(other) + assert not self.window.cbShowLinearData.isEnabled() + assert not self.window.cbShowLinearData.isChecked() + + # the plot of the new flight track arrives and its data is shown + other.set_linear_data_from_xml(other_data) + assert self.window.cbShowLinearData.isEnabled() + assert self.window.cbShowLinearData.isChecked() + assert other.columnCount() == 16 + assert [other.data(other.index(row, ft.LINEAR_DATA_COLUMN)).value() + for row in range(other.rowCount())] == ["0.1", "0.2"] + + # ... and the same when switching back to the first flight track + other.clear_linear_data() + self.window.setFlightTrackModel(model) + assert not self.window.cbShowLinearData.isChecked() + model.set_linear_data_from_xml(data) + assert self.window.cbShowLinearData.isChecked() + assert model.columnCount() == 16 + assert [model.data(model.index(row, ft.LINEAR_DATA_COLUMN)).value() + for row in range(model.rowCount())] == ["", "0.05", "0.06", "0.07", ""] + + def test_show_linear_data_not_wanted_stays_hidden_on_switch(self): + """ + Data columns that the user has switched off stay off when the linear + view provides data for another flight track. + """ + model = self.window.waypoints_model + waypoints = model.all_waypoint_data() + model.set_linear_data_from_xml([lsec_xml( + lats=[wp.lat for wp in waypoints], lons=[wp.lon for wp in waypoints], + values=("nan", "0.05", "0.06", "0.07", "nan"))]) + assert not self.window.cbShowLinearData.isChecked() + + other = ft.WaypointsTableModel("other") + other.insertRows(0, rows=2, waypoints=[ft.Waypoint(48.10, 10.27, 200), + ft.Waypoint(52.32, 9.21, 200)]) + other_waypoints = other.all_waypoint_data() + self.window.setFlightTrackModel(other) + other.set_linear_data_from_xml([lsec_xml( + lats=[wp.lat for wp in other_waypoints], lons=[wp.lon for wp in other_waypoints], + values=("0.1", "0.2"))]) + assert self.window.cbShowLinearData.isEnabled() + assert not self.window.cbShowLinearData.isChecked() + assert other.columnCount() == 15 + def test_insert_point(self): """ Check insertion of points diff --git a/tests/utils.py b/tests/utils.py index a2948a2cb..adf1a785a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -26,6 +26,7 @@ limitations under the License. """ import requests +import xml.etree.ElementTree as etree from urllib.parse import urljoin @@ -87,6 +88,23 @@ """ +def lsec_xml(title="Mole fraction of ozone (Linear)", unit="ppmv", + lats=(0., 0.5, 1., 1.5, 2., 2.5, 3.), + lons=(0., 0.5, 1., 1.5, 2., 2.5, 3.), + values=("nan", "5", "10", "12", "20", "22", "nan")): + """ + Build a linear section response as it is returned by the WMS server for a + "LINE:1" request, see mslib.mswms.mpl_lsec. + """ + return etree.fromstring( + "" + f"{title}" + f"{','.join(str(lon) for lon in lons)}" + f"{','.join(str(lat) for lat in lats)}" + f"{','.join(values)}" + "") + + def callback_ok_image(status, response_headers): assert status == "200 OK" assert response_headers[0] == ('Content-type', 'image/png')