From db8b6722db18ae1aeeaa96ce010f48c9ba4c88bd Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Wed, 11 Nov 2026 15:06:19 +0100 Subject: [PATCH] Edit waypoint times in the table view, one change signal per change The takeoff time could only be set in the Performance Settings dock, which few users found, and the times were only shown with "Show Performance". Now the Time (UTC) column of the table view is always shown and editable with a date/time editor with calendar popup. Setting the time of a waypoint sets the takeoff time so that this waypoint is reached at the given time; all other times follow from the aircraft model. The takeoff time field and the Show Performance switch are removed from the dock, a hint points to the table instead. Read-only cells of the table (distances, times and fuel computed from the performance, the data of the linear view) use the palette's disabled text colour. Times: - All times are handled in UTC. QDateTimes are built directly in UTC instead of via local time, which gave invalid values for times in a daylight saving gap of the local zone (the editor opened at 1999-12-31 and shifted the flight there). A takeoff time stored by earlier versions with local time spec is reinterpreted as UTC by its fields, not converted. - Time (UTC), leg time and cumulative time are rounded to full seconds instead of truncated, so an entered 12:00:00 no longer shows 11:59:59. Committing the editor unchanged does not shift the flight by the rounding difference. Change signals of the flight track model: The model emitted dataChanged from update_distances in addition to setData, also inside row insertion and removal, so every edit redrew the views and requested the vertical and linear sections twice, and time edits reached MSColab. Now: - dataChanged once per change of the flight plan, its range covers all changed columns of the row; the derived data is updated before. - rowsInserted/rowsRemoved/modelReset for structural changes, without an additional dataChanged; invert_direction and replace_waypoints reset the model and mark it modified. - the new performanceDataChanged when only times, fuel or weight change (new takeoff time, new performance settings). - An unchanged location, name or comment emits nothing, unparsable input is rejected (setData returns False). Views dispatch on the changed columns: the linear view requests a new section only for a changed path, the top view redraws for position, name and altitude, and for times only when it shows solar angles. The side view redraws its x-axis (times, ceiling altitude) also after an altitude change, as the times and the aircraft weight depend on it. The table view repaints on performanceDataChanged. MSColab and the roundtrip buttons listen to the flight plan signals only; the connections of the table view and of MSColab move with the flight track (connect/disconnect_waypoints_model). Performance settings: - Performance is always computed and shown; the stored "visible" switch of earlier versions is dropped. - The default takeoff and empty weight are those of the dummy aircraft (90000 lb and 55000 lb) instead of 0, which showed negative fuel. - WaypointsTableModel.load_settings passes a copy of DEFAULT_PERFORMANCE: load_settings_qsettings updates its defaults in place, so all models shared, and modified, the module-level dict. - The dock's weight spin boxes have no keyboard tracking, so a typed value is applied once, not on every keystroke; a loaded aircraft is applied once, not for each changed weight. Docs and tutorials: the takeoff time and Show Performance steps are removed from the performance tutorial and its texts; docs/usage.rst describes the editable time. Tests count the signals and the costly view updates (WMS requests, top view and side view x-axis redraws) per change, check that every table cell with a new text is repainted and that the plots get the current waypoints, also after the views moved to another flight track. The test fixtures reset the QSettings file after every test. Co-Authored-By: Claude Opus 5.5 --- .../tutorial_performance_settings.rst | 7 +- docs/usage.rst | 4 +- mslib/msui/flighttrack.py | 211 ++++++++++++---- mslib/msui/mpl_pathinteractor.py | 79 ++++-- mslib/msui/mpl_qtwidget.py | 15 +- mslib/msui/mscolab.py | 30 ++- mslib/msui/performance_settings.py | 41 ++-- mslib/msui/qt5/ui_performance_dockwidget.py | 27 +-- mslib/msui/tableview.py | 29 ++- mslib/msui/topview.py | 4 +- mslib/msui/ui/ui_performance_dockwidget.ui | 51 ++-- tests/_test_msui/test_change_signals.py | 225 ++++++++++++++++++ tests/_test_msui/test_flighttrack.py | 151 +++++++++++- tests/_test_msui/test_mscolab.py | 22 +- tests/_test_msui/test_tableview.py | 160 +++++++++++-- tests/msui_fixtures.py | 4 +- .../tutorial_performancesettings.txt | 10 +- tutorials/tutorial_performancesettings.py | 26 -- 18 files changed, 858 insertions(+), 238 deletions(-) create mode 100644 tests/_test_msui/test_change_signals.py diff --git a/docs/tutorials/tutorial_performance_settings.rst b/docs/tutorials/tutorial_performance_settings.rst index 59820e2c2..6479ff0c3 100644 --- a/docs/tutorials/tutorial_performance_settings.rst +++ b/docs/tutorials/tutorial_performance_settings.rst @@ -16,18 +16,13 @@ The range-specific data of an aircraft can be taken into account in Tableview fo Now we’ll have to select a file called dot json file stored in the MSS database in the syntax of dot json. The information related to the performance data of the aircraft are stored here. Click on the "Select" option to select the Json file from the MSS docs database as an example. - Click on show performance for the related data to be shown. You can see the changes in the columns of the table view regarding the performance parameters of the aircraft. Giving some examples, things like the change in flight time, fuel, aircraft weight, ceiling altitude, etc. as the flight takes off from Nagpur and reaches Delhi, can be analysed. - There are options in the panel like take off time, maximum take off weight and aircraft weight without fuel. + There are options in the panel like maximum take off weight and aircraft weight without fuel. We can change these settings to see the how the performance data changes. Like, we now changed the maximum take off weight from ninety one thousands to eighty seven thousand kilograms. And the aircraft weight without fuel from fifty six thousands to forty eight thousands kilograms. - Here you can see we changed the take off time from 19th May 2024 to 19th October 2024. - Then we can uncheck the box of show Performance and then click it again and there you can see the changes - in the performance settings. - It just shows and hides the performance data. Now we are going to close the Top View. It asks for confirmation whether we want to close it or not. We select yes in the confirmation dialogue and it closes. diff --git a/docs/usage.rst b/docs/usage.rst index aced779d0..8f3c6df5a 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -195,7 +195,9 @@ Performance MSS may also roughly estimate the fuel consumption and thus range of the aircraft neglecting weather conditions given a proper configuration file specifying the aircraft performance. Such a file may be loaded using the 'performance settings' button -in Table View. The aircraft performance is specified using tables given in the JSON format. +in Table View. The table shows the estimated time (UTC) at each waypoint. Edit the time of any +waypoint to shift the whole flight; all other times follow. +The aircraft performance is specified using tables given in the JSON format. A basic configuration looks like the following file: .. literalinclude:: samples/config/msui/performance_simple.json.sample diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 123502b31..5507dea16 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -64,14 +64,49 @@ def seconds_to_string(seconds): """ - Format a time given in seconds to a string HH:MM:SS. Used for the - 'leg time/cum. time' columns of the table view. + Format a time given in seconds, rounded to full seconds, to a string + HH:MM:SS. Used for the 'leg time/cum. time' columns of the table view. """ - hours, seconds = divmod(int(seconds), 3600) + hours, seconds = divmod(math.floor(seconds + 0.5), 3600) minutes, seconds = divmod(seconds, 60) return f"{hours:02d}:{minutes:02d}:{seconds:02d}" +def format_utc_time(utc_time): + """ + Format a waypoint time rounded to full seconds, as the leg times are. + Times are computed from fractional leg durations, truncation would show + e.g. 11:59:59 for a time the user entered as 12:00:00. + """ + utc_time = (utc_time + datetime.timedelta(microseconds=500000)).replace(microsecond=0) + return utc_time.strftime("%Y-%m-%d %H:%M:%S") + + +def qdatetime_fields(qdatetime): + """ + Return the date and time fields of a QDateTime as naive datetime. All times + in MSS are UTC by definition, whatever time spec the QDateTime carries: + e.g. take off times stored by earlier versions have local spec, and their + fields are what the user entered and saw as UTC. Converting with toUTC() + would shift them by the offset of the local zone. + """ + date, time = qdatetime.date(), qdatetime.time() + return datetime.datetime(date.year(), date.month(), date.day(), + time.hour(), time.minute(), time.second(), time.msec() * 1000) + + +def utc_qdatetime(utc_time): + """ + Convert a naive datetime holding UTC to a QDateTime in UTC. Going via + local time (QDateTime(datetime), QDateTime.fromString) would give an + invalid QDateTime for times in a daylight saving gap of the local zone. + """ + return QtCore.QDateTime( + QtCore.QDate(utc_time.year, utc_time.month, utc_time.day), + QtCore.QTime(utc_time.hour, utc_time.minute, utc_time.second, utc_time.microsecond // 1000), + QtCore.Qt.UTC) + + TABLE_FULL = [ ("Location ", lambda waypoint: waypoint.location, True), ("Lat\n(+-90)", lambda waypoint: round(float(waypoint.lat), 2), True), @@ -84,7 +119,7 @@ def seconds_to_string(seconds): f"[{int(waypoint.distance_total / 1.852):d}]", False), ("Leg time", lambda waypoint: seconds_to_string(waypoint.leg_time), False), ("Cum. time", lambda waypoint: seconds_to_string(waypoint.cum_time), False), - ("Time (UTC)", lambda waypoint: waypoint.utc_time.strftime("%Y-%m-%d %H:%M:%S"), False), + ("Time (UTC)", lambda waypoint: format_utc_time(waypoint.utc_time), True), ("Rem. fuel\n(lb)", lambda waypoint: f"{int(waypoint.rem_fuel):d}", False), ("Aircraft\nweight (lb)", lambda waypoint: f"{int(waypoint.weight):d}", False), ("Ceiling\naltitude (hft)", lambda waypoint: f"{waypoint.ceiling_alt:d}", False), @@ -92,13 +127,18 @@ def seconds_to_string(seconds): ("Comments ", lambda waypoint: waypoint.comments, True), ] -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 view. All columns left of it are described by TABLE_FULL. LINEAR_DATA_COLUMN = len(TABLE_FULL) +def changed_columns(index1, index2): + """ + Return the set of columns covered by the range of a dataChanged() signal. + """ + return set(range(min(index1.column(), index2.column()), max(index1.column(), index2.column()) + 1)) + + 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. @@ -268,6 +308,22 @@ class WaypointsTableModel(QtCore.QAbstractTableModel): # Signal emitted when a waypoint is moved, inserted or deleted changeMessageSignal = QtCore.pyqtSignal(str) + # Signal emitted when the derived data of the waypoints (times, fuel, + # weight) has changed while the flight plan has not, i.e. for a new take + # off time or new performance settings. + # + # The signals of the model follow one contract, so that every change + # triggers each costly redraw (WMS requests, MSColab uploads) at most once: + # - dataChanged: a change of the flight plan itself (what get_xml_doc() + # writes), exactly one emit per setData() call, its range covers all + # changed columns of the row. The derived data of the waypoints is + # updated before, listeners recompute everything they need from it. + # - rowsInserted/rowsRemoved/modelReset: structural changes, without an + # additional dataChanged. + # - performanceDataChanged: derived data only, never in addition to the + # above. + # - linearDataChanged: see below. + performanceDataChanged = QtCore.pyqtSignal() # 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 @@ -310,10 +366,15 @@ def load_settings(self): """ Load settings from the file self.settingsfile. """ - settings = load_settings_qsettings(self.settings_tag, DEFAULT_PERFORMANCE) - # Ensure we have a dictionary. If QSettings returns a string/garbage, reset to default. - if not isinstance(settings, dict): - settings = DEFAULT_PERFORMANCE + # Copy, load_settings_qsettings updates the given defaults in place. + settings = load_settings_qsettings(self.settings_tag, dict(DEFAULT_PERFORMANCE)) + # Performance is always shown, earlier versions stored a switch for it. + settings.pop("visible", None) + takeoff_time = settings["takeoff_time"] + if not isinstance(takeoff_time, QtCore.QDateTime) or not takeoff_time.date().isValid(): + takeoff_time = DEFAULT_PERFORMANCE["takeoff_time"] + # Earlier versions stored the take off time with local time spec. + settings["takeoff_time"] = utc_qdatetime(qdatetime_fields(takeoff_time)) self.performance_settings = settings def save_settings(self): @@ -323,6 +384,16 @@ def save_settings(self): """ save_settings_qsettings(self.settings_tag, self.performance_settings) + def set_performance_settings(self, settings): + """ + Apply new performance settings, i.e. recompute times and fuel of all + waypoints, and store them. + """ + self.performance_settings = settings + self.update_distances(0) + self.save_settings() + self.performanceDataChanged.emit() + def flags(self, index): """ Used to specify which table columns can be edited by the user; @@ -331,11 +402,8 @@ def flags(self, index): if not index.isValid(): return QtCore.Qt.ItemIsEnabled column = index.column() - table = TABLE_SHORT - if self.performance_settings["visible"]: - table = TABLE_FULL # The data columns of the linear view are read only. - if column < LINEAR_DATA_COLUMN and table[column][2]: + if column < LINEAR_DATA_COLUMN and TABLE_FULL[column][2]: return QtCore.Qt.ItemFlags( int(QtCore.QAbstractTableModel.flags(self, index) | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled | @@ -365,12 +433,12 @@ def data(self, index, role=QtCore.Qt.DisplayRole): 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: - return QtCore.QVariant(TABLE_SHORT[column][1](waypoint)) + return QtCore.QVariant(TABLE_FULL[column][1](waypoint)) elif role == QtCore.Qt.TextAlignmentRole: return QtCore.QVariant(int(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)) + elif role == QtCore.Qt.ForegroundRole and not self.flags(index) & QtCore.Qt.ItemIsEditable: + # read-only cells, e.g. the computed performance data, in grey + return QtCore.QVariant(QtWidgets.QApplication.palette().brush(QtGui.QPalette.Disabled, QtGui.QPalette.Text)) return QtCore.QVariant() def waypoint_data(self, row): @@ -418,10 +486,7 @@ def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): 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: - return QtCore.QVariant(TABLE_SHORT[section][0]) + return QtCore.QVariant(TABLE_FULL[section][0]) # Table rows (waypoints) are labelled with their number (= number of # waypoint). return QtCore.QVariant(int(section)) @@ -543,8 +608,14 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): if index.isValid() and 0 <= index.row() < len(self.waypoints): waypoint = self.waypoints[index.row()] column = index.column() - index2 = index # in most cases only one field is being changed + # In most cases only one field is being changed. Otherwise index + # and index2 span all changed columns, left to right. + index2 = index if column == LOCATION: + if variant_to_string(value) == waypoint.location: + # e.g. a location picked in the table view, setting its + # coordinates has already set its name + return True waypoint.location = variant_to_string(value) elif column == LAT: try: @@ -555,8 +626,10 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): raise ValueError except TypeError as ex: logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value) + return False except ValueError as ex: logging.error("%s %s '%s'", type(ex), ex, value) + return False else: waypoint.lat = value waypoint.location = "" @@ -568,12 +641,9 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): if update: self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) - # Notify the views that items between the edited item and - # the distance item of the corresponding waypoint have been - # changed. - # Delete the location name -- it won't be valid anymore - # after its coordinates have been changed. - index2 = self.createIndex(index.row(), LOCATION) + # The location name has changed as well. + index = self.createIndex(index.row(), LOCATION) + index2 = self.createIndex(index.row(), LON) elif column == LON: try: # The table fields accept basically any input. @@ -583,8 +653,10 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): raise ValueError except TypeError as ex: logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value) + return False except ValueError as ex: logging.error("%s %s '%s'", type(ex), ex, value) + return False else: waypoint.lon = value waypoint.location = "" @@ -595,7 +667,8 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): if update: self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) - index2 = self.createIndex(index.row(), LOCATION) + index = self.createIndex(index.row(), LOCATION) + index2 = self.createIndex(index.row(), LON) elif column == FLIGHTLEVEL: try: # The table fields accept basically any input. @@ -604,16 +677,17 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): pressure = float(thermolib.flightlevel2pressure(flightlevel * units.hft).magnitude) except TypeError as ex: logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value) + return False except ValueError as ex: logging.error("%s %s '%s'", type(ex), ex, value) + return False else: waypoint.flightlevel = flightlevel waypoint.pressure = pressure if update: self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) - # need to notify view of the second item that has been - # changed as well. + # The pressure has changed as well. index2 = self.createIndex(index.row(), PRESSURE) elif column == PRESSURE: try: @@ -626,16 +700,44 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): pressure = float(thermolib.flightlevel2pressure(flightlevel * units.hft).magnitude) except TypeError as ex: logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value) + return False except ValueError as ex: logging.error("%s", ex) + return False else: waypoint.pressure = pressure waypoint.flightlevel = flightlevel if update: self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) - index2 = self.createIndex(index.row(), FLIGHTLEVEL) + # The flight level has changed as well. + index = self.createIndex(index.row(), FLIGHTLEVEL) + index2 = self.createIndex(index.row(), PRESSURE) + elif column == TIME_UTC: + # Shift the whole flight so that this waypoint is reached at the given time. + if isinstance(value, QtCore.QDateTime): + value = qdatetime_fields(value) + try: + if not isinstance(value, datetime.datetime): + value = datetime.datetime.strptime(variant_to_string(value).strip(), "%Y-%m-%d %H:%M:%S") + # The table shows full seconds: leaving the editor unchanged + # must not shift the flight by the rounding difference. + if format_utc_time(value) == format_utc_time(waypoint.utc_time): + return True + takeoff = utc_qdatetime(value - datetime.timedelta(seconds=waypoint.cum_time)) + except (ValueError, OverflowError) as ex: + logging.error("%s %s '%s'", type(ex), ex, value) + return False + self.performance_settings = dict(self.performance_settings, takeoff_time=takeoff) + self.save_settings() + # The leg times do not depend on the take off time. + self._update_utc_times() + # The flight plan itself is unchanged. + self.performanceDataChanged.emit() + return True else: + if variant_to_string(value) == waypoint.comments: + return True waypoint.comments = variant_to_string(value) self.modified = True # Performance computations loose their validity if a change is made. @@ -746,7 +848,6 @@ def get_duration_fuel(flightlevel0, flightlevel1, distance, weight, lastleg): wp1.leg_time = 0 # time from previous waypoint wp1.cum_time = 0 # total time of flight - wp1.utc_time = self.performance_settings["takeoff_time"].toPyDateTime() wp1.weight = self.performance_settings["takeoff_weight"] wp1.leg_fuel = 0 wp1.rem_fuel = self.performance_settings["takeoff_weight"] - self.performance_settings["empty_weight"] @@ -761,7 +862,6 @@ def get_duration_fuel(flightlevel0, flightlevel1, distance, weight, lastleg): wp0.flightlevel, wp1.flightlevel, wp1.distance_to_prev, wp0.weight, lastleg=last) wp1.leg_time = time wp1.cum_time = wp0.cum_time + wp1.leg_time - wp1.utc_time = wp0.utc_time + datetime.timedelta(seconds=wp1.leg_time) wp1.leg_fuel = fuel wp1.rem_fuel = wp0.rem_fuel - wp1.leg_fuel wp1.weight = wp0.weight - wp1.leg_fuel @@ -794,16 +894,23 @@ def get_duration_fuel(flightlevel0, flightlevel1, distance, weight, lastleg): wp1.leg_time = time wp1.cum_time = wp0.cum_time + wp1.leg_time - wp1.utc_time = wp0.utc_time + datetime.timedelta(seconds=wp1.leg_time) wp1.leg_fuel = fuel wp1.rem_fuel = wp0.rem_fuel - wp1.leg_fuel wp1.weight = wp0.weight - wp1.leg_fuel wp1.ceiling_alt = aircraft.get_ceiling_altitude(wp1.weight) - index1 = self.createIndex(0, TIME_UTC) - self.dataChanged.emit(index1, index1) + self._update_utc_times() + + def _update_utc_times(self): + """ + Set the time of all waypoints from the take off time and their flight time. + """ + takeoff_time = qdatetime_fields(self.performance_settings["takeoff_time"]) + for waypoint in self.waypoints: + waypoint.utc_time = takeoff_time + datetime.timedelta(seconds=waypoint.cum_time) def invert_direction(self): + self.beginResetModel() self.waypoints = self.waypoints[::-1] if len(self.waypoints) > 0: self.waypoints[0].distance_to_prev = 0 @@ -813,12 +920,19 @@ def invert_direction(self): if len(wp_comm) == 9 and wp_comm.startswith("Hexagon "): wp_comm = f"Hexagon {(8 - int(wp_comm[-1])):d}" self.waypoints[i].comments = wp_comm - self.update_distances(position=0, rows=len(self.waypoints)) - self.layoutChanged.emit() + if len(self.waypoints) > 0: + self.update_distances(position=0, rows=len(self.waypoints)) + self.endResetModel() + self.modified = True def replace_waypoints(self, new_waypoints): - self.waypoints = [] - self.insertRows(0, rows=len(new_waypoints), waypoints=new_waypoints, data_copied=True) + self.changeMessageSignal.emit("Imported from another flight track") + self.beginResetModel() + self.waypoints = list(new_waypoints) + if len(self.waypoints) > 0: + self.update_distances(position=0, rows=len(self.waypoints)) + self.endResetModel() + self.modified = True def save_to_ftml(self, filename=None): """ @@ -927,6 +1041,12 @@ def createEditor(self, parent, option, index): combobox.addItems(sorted(adds)) combobox.setEditable(True) return combobox + elif index.column() == TIME_UTC: + editor = QtWidgets.QDateTimeEdit(parent) + editor.setCalendarPopup(True) + editor.setDisplayFormat("yyyy-MM-dd HH:mm:ss") + editor.setTimeSpec(QtCore.Qt.UTC) + return editor else: # All other columns get the standard editor. return QtWidgets.QItemDelegate.createEditor(self, parent, option, index) @@ -938,6 +1058,9 @@ def setEditorData(self, editor, index): if i == -1: i = 0 editor.setCurrentIndex(i) + elif index.column() == TIME_UTC: + # Start from the displayed time, i.e. full seconds. + editor.setDateTime(utc_qdatetime(datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S"))) else: editor.insert(str(value)) @@ -965,5 +1088,7 @@ def setModelData(self, editor, model, index): model.setData(index.sibling(index.row(), LON), QtCore.QVariant(lon)) model.setData(index, QtCore.QVariant(editor.currentText())) + elif index.column() == TIME_UTC: + model.setData(index, editor.dateTime()) else: QtWidgets.QItemDelegate.setModelData(self, editor, model, index) diff --git a/mslib/msui/mpl_pathinteractor.py b/mslib/msui/mpl_pathinteractor.py index 353dcd243..574ab64a2 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -858,23 +858,28 @@ def set_waypoints_model(self, waypoints): # If a model exists, disconnect from the old change() signals. wpm = self.waypoints_model if wpm: - wpm.dataChanged.disconnect(self.qt_data_changed_listener) - wpm.rowsInserted.disconnect(self.qt_insert_remove_point_listener) - wpm.rowsRemoved.disconnect(self.qt_insert_remove_point_listener) + for signal, slot in self._model_connections(wpm): + signal.disconnect(slot) # Set the new waypoints model. self.waypoints_model = waypoints # Connect to the new model's signals. wpm = self.waypoints_model - wpm.dataChanged.connect(self.qt_data_changed_listener) - wpm.rowsInserted.connect(self.qt_insert_remove_point_listener) - wpm.rowsRemoved.connect(self.qt_insert_remove_point_listener) + for signal, slot in self._model_connections(wpm): + signal.connect(slot) # Redraw. self.plotter.update_from_waypoints(wpm.all_waypoint_data()) self.redraw_figure() - def qt_insert_remove_point_listener(self, index, first, last): - """Listens to rowsInserted() and rowsRemoved() signals emitted - by the flight track data model. The view can thus react to + def _model_connections(self, wpm): + return ((wpm.dataChanged, self.qt_data_changed_listener), + (wpm.rowsInserted, self.qt_insert_remove_point_listener), + (wpm.rowsRemoved, self.qt_insert_remove_point_listener), + (wpm.modelReset, self.qt_insert_remove_point_listener), + (wpm.performanceDataChanged, self.qt_performance_data_changed_listener)) + + def qt_insert_remove_point_listener(self, *args): + """Listens to rowsInserted(), rowsRemoved() and modelReset() signals + emitted by the flight track data model. The view can thus react to data changes induced by another view (table, side view). """ self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) @@ -888,6 +893,13 @@ def qt_data_changed_listener(self, index1, index2): # REIMPLEMENT IN SUBCLASSES. pass + def qt_performance_data_changed_listener(self): + """Listens to performanceDataChanged() signals emitted by the flight + track data model, i.e. to changed times of the waypoints. + """ + # REIMPLEMENT IN SUBCLASSES that show times. + pass + def get_ind_under_point(self, event): """Get the index of the waypoint vertex under the point specified by event within epsilon tolerance. @@ -1088,20 +1100,30 @@ def qt_data_changed_listener(self, index1, index2): data model. The side view can thus react to data changes induced by another view (table, top view). """ - # If the altitude of a point has changed, only the plotted flight - # profile needs to be redrawn (redraw_path()). If the horizontal - # position of a waypoint has changed, the entire figure needs to be - # redrawn, as this affects the x-position of all points. - self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) - if index1.column() in [ft.FLIGHTLEVEL, ft.PRESSURE, ft.LOCATION]: + # If the altitude of a point has changed, the plotted flight profile + # needs to be redrawn (redraw_path()), and the x-axis, as the flight + # times and the aircraft weight (ceiling altitude) depend on the + # altitude. If the horizontal position of a waypoint has changed, the + # entire figure needs to be redrawn, as this affects the x-position of + # all points. + columns = ft.changed_columns(index1, index2) + if columns & {ft.LAT, ft.LON}: + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) + self.redraw_figure() + elif columns & {ft.FLIGHTLEVEL, ft.PRESSURE, ft.LOCATION}: + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) self.plotter.redraw_path( self.plotter.pathpatch.get_path().vertices, self.waypoints_model.all_waypoint_data()) - elif index1.column() in [ft.LAT, ft.LON]: - self.redraw_figure() - elif index1.column() in [ft.TIME_UTC]: - if self.redraw_xaxis is not None: + if columns & {ft.FLIGHTLEVEL, ft.PRESSURE} and self.redraw_xaxis is not None: self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes) + def qt_performance_data_changed_listener(self): + """The times of the waypoints label the x-axis. + """ + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) + if self.redraw_xaxis is not None: + self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes) + class LPathInteractor(PathInteractor): """ @@ -1152,8 +1174,11 @@ def qt_data_changed_listener(self, index1, index2): data model. The linear view can thus react to data changes induced by another view (table, top view, side view). """ - self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) - self.redraw_figure() + # A new section is requested only for a changed path, not e.g. for a + # changed name or comment. + if ft.changed_columns(index1, index2) & {ft.LAT, ft.LON, ft.FLIGHTLEVEL, ft.PRESSURE}: + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) + self.redraw_figure() class HPathInteractor(PathInteractor): @@ -1326,9 +1351,15 @@ def qt_data_changed_listener(self, index1, index2): data model. The top view can thus react to data changes induced by another view (table, side view). """ - # Update the top view if the horizontal position of any point has been - # changed. - if index1.column() in [ft.LOCATION, ft.LAT, ft.LON, ft.FLIGHTLEVEL]: + # Update the top view if the horizontal position or the name of any + # point has been changed, or its flight level (tangent and solar lines). + if ft.changed_columns(index1, index2) & {ft.LOCATION, ft.LAT, ft.LON, ft.FLIGHTLEVEL, ft.PRESSURE}: + self.update() + + def qt_performance_data_changed_listener(self): + """Only the solar angle lines depend on the times of the waypoints. + """ + if self.plotter.show_solar_angle is not None: self.update() def update(self): diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index 68d4691b7..d7cf9ceba 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -461,10 +461,7 @@ def redraw_xaxis(self, lats, lons, times): a vertical section image if one exists, as it is invalid after a path change. """ - times_visible = False - if self.waypoints_model is not None: - times_visible = self.waypoints_model.performance_settings["visible"] - self.plotter.redraw_xaxis(lats, lons, times, times_visible) + self.plotter.redraw_xaxis(lats, lons, times, self.waypoints_model is not None) for _line in self.ceiling_alt: _line.remove() @@ -488,8 +485,7 @@ def redraw_xaxis(self, lats, lons, times): xs, thermolib.flightlevel2pressure(np.asarray(ys) * units.hft).magnitude, color="k", ls="--") self.update_ceiling( - self.plotter.get_settings()["draw_ceiling"] and - self.waypoints_model.performance_settings["visible"], + self.plotter.get_settings()["draw_ceiling"], self.plotter.get_settings()["colour_ceiling"]) highlight = [[wp.lat, wp.lon] for wp in self.waypoints_model.waypoints] self.plotter.draw_vertical_lines(highlight, lats, lons) @@ -560,11 +556,8 @@ def set_settings(self, settings, save=False): settings = self.plotter.get_settings() self.set_flight_levels(settings["flightlevels"]) self.set_flight_levels_visible(settings["draw_flightlevels"]) - self.update_ceiling( - settings["draw_ceiling"] and ( - self.waypoints_model is not None and - self.waypoints_model.performance_settings["visible"]), - settings["colour_ceiling"]) + self.update_ceiling(settings["draw_ceiling"] and self.waypoints_model is not None, + settings["colour_ceiling"]) self.update_vertical_extent_from_settings() if self.waypoints_interactor is not None: diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index dfc022383..38beceb90 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -1119,7 +1119,7 @@ def create_local_operation_file(self, local_dir): def reload_local_wp(self): self.waypoints_model = ft.WaypointsTableModel(filename=self.local_ftml_file, data_dir=self.data_dir) self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.connect_waypoints_model() self.reload_view_windows() def operation_category_handler(self, update_operations=True): @@ -1162,7 +1162,7 @@ def fetch_wp_mscolab(self): self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.save_to_ftml(self.local_ftml_file) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.connect_waypoints_model() self.reload_view_windows() show_popup(self.ui, "Success", "New Waypoints Fetched To Local File!", icon=1) self.merge_dialog.close() @@ -1181,7 +1181,7 @@ def save_wp_mscolab(self, comment=None): self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.save_to_ftml(self.local_ftml_file) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.connect_waypoints_model() self.reload_view_windows() show_popup(self.ui, "Success", "New Waypoints Saved To Server!", icon=1) self.merge_dialog.close() @@ -1630,7 +1630,7 @@ def load_wps_from_server(self): self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.name = self.active_operation_name - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.connect_waypoints_model() def reload_operations(self): logging.debug('reload_operations') @@ -1647,13 +1647,29 @@ def reload_wps_from_server(self): self.load_wps_from_server() self.reload_view_windows() + def _flight_plan_signals(self): + """ + The signals of a change of the flight plan of the active operation, which is sent to the server or written + to the local file. Only the flight plan is stored, not the times and fuel (performanceDataChanged). + """ + model = self.waypoints_model + return model.dataChanged, model.rowsInserted, model.rowsRemoved, model.modelReset + + def connect_waypoints_model(self): + for signal in self._flight_plan_signals(): + signal.connect(self.handle_waypoints_changed) + + def disconnect_waypoints_model(self): + for signal in self._flight_plan_signals(): + signal.disconnect(self.handle_waypoints_changed) + def handle_waypoints_changed(self, _1=None, _2=None, _3=None, version_name=None): logging.debug("handle_waypoints_changed") if self.ui.workLocallyCheckbox.isChecked(): self.waypoints_model.save_to_ftml(self.local_ftml_file) else: xml_content = self.waypoints_model.get_xml_content() - # one change emits dataChanged twice, e.g. from setData and from update_distances; a reloaded flight + # skip content that was sent already, e.g. a cell committed with its old value; a reloaded flight # track is a new model, so after it the same content is sent again sent = (self.waypoints_model, self.active_op_id, xml_content) if version_name is None and self.last_sent_flight_track is not None and \ @@ -1707,12 +1723,12 @@ def handle_import_msc(self, file_path, extension, function, pickertype): if not verify_waypoint_data(xml_content): show_popup(self.ui, "Import Failed", f"The file - {file_name}, was not imported!", 0) return - self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) + self.disconnect_waypoints_model() self.waypoints_model = model self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) # the server refuses longer version names self.handle_waypoints_changed(version_name=file_name[:MAX_VERSION_NAME_LENGTH]) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.connect_waypoints_model() self.reload_view_windows() show_popup(self.ui, "Import Success", f"The file - {file_name}, was imported successfully!", 1) diff --git a/mslib/msui/performance_settings.py b/mslib/msui/performance_settings.py index 0c6ec2343..0e23e7f82 100644 --- a/mslib/msui/performance_settings.py +++ b/mslib/msui/performance_settings.py @@ -37,10 +37,9 @@ DEFAULT_PERFORMANCE = { "aircraft": aircraft.SimpleAircraft(aircraft.AIRCRAFT_DUMMY), - "visible": False, - "takeoff_weight": 0, + "takeoff_weight": aircraft.AIRCRAFT_DUMMY["takeoff_weight"], "takeoff_time": QtCore.QDateTime.currentDateTimeUtc(), - "empty_weight": 0, + "empty_weight": aircraft.AIRCRAFT_DUMMY["empty_weight"], "ceiling_alt": [410], } @@ -66,16 +65,14 @@ def __init__(self, parent=None, view=None, settings_dict=None): settings_dict = DEFAULT_PERFORMANCE self.aircraft = settings_dict["aircraft"] self.lbAircraftName.setText(self.aircraft.name) - self.cbShowPerformance.setChecked(settings_dict["visible"]) self.dsbTakeoffWeight.setValue(settings_dict["takeoff_weight"]) self.dsbEmptyWeight.setValue( settings_dict.get("empty_weight", settings_dict["takeoff_weight"] - settings_dict.get("fuel", 0))) - self.dteTakeoffTime.setDateTime(settings_dict["takeoff_time"]) - # Connecting signals + # Connecting signals. Each update recomputes and redraws the flight + # track, the spin boxes have no keyboard tracking, so that typing a + # value emits valueChanged once, not on every key stroke. self.pbLoadPerformance.clicked.connect(self.load_performance) - self.cbShowPerformance.stateChanged.connect(self.update_parent_performance) - self.dteTakeoffTime.dateTimeChanged.connect(self.update_parent_performance) self.dsbTakeoffWeight.valueChanged.connect(self.update_parent_performance) self.dsbEmptyWeight.valueChanged.connect(self.update_parent_performance) @@ -88,8 +85,8 @@ def get_settings(self): """ settings_dict = { "aircraft": self.aircraft, - "visible": self.cbShowPerformance.isChecked(), - "takeoff_time": self.dteTakeoffTime.dateTime(), + # The takeoff time is set by editing the time column of the table view. + "takeoff_time": self.parent.waypoints_model.performance_settings["takeoff_time"], "takeoff_weight": self.dsbTakeoffWeight.value(), "empty_weight": self.dsbEmptyWeight.value() } @@ -111,15 +108,21 @@ def load_performance(self): performance = json.load(tf) self.aircraft = aircraft.SimpleAircraft(performance) self.lbAircraftName.setText(self.aircraft.name) - self.dsbTakeoffWeight.setValue(self.aircraft.takeoff_weight) - if not any(hasattr(self.aircraft, _x) for _x in ("fuel", "empty_weight")): - raise KeyError("empty_weight") - if hasattr(self.aircraft, "empty_weight"): - self.dsbEmptyWeight.setValue(self.aircraft.empty_weight) - else: - self.dsbEmptyWeight.setValue(self.aircraft.takeoff_weight - self.aircraft.fuel) - - self.update_parent_performance() + # Apply the new aircraft once, not for each changed weight. + self.dsbTakeoffWeight.blockSignals(True) + self.dsbEmptyWeight.blockSignals(True) + try: + self.dsbTakeoffWeight.setValue(self.aircraft.takeoff_weight) + if not any(hasattr(self.aircraft, _x) for _x in ("fuel", "empty_weight")): + raise KeyError("empty_weight") + if hasattr(self.aircraft, "empty_weight"): + self.dsbEmptyWeight.setValue(self.aircraft.empty_weight) + else: + self.dsbEmptyWeight.setValue(self.aircraft.takeoff_weight - self.aircraft.fuel) + finally: + self.dsbTakeoffWeight.blockSignals(False) + self.dsbEmptyWeight.blockSignals(False) + self.update_parent_performance() except KeyError as ex: QtWidgets.QMessageBox.critical(self, self.tr("Performance JSON Load"), diff --git a/mslib/msui/qt5/ui_performance_dockwidget.py b/mslib/msui/qt5/ui_performance_dockwidget.py index d7cfdedb0..570d5a249 100644 --- a/mslib/msui/qt5/ui_performance_dockwidget.py +++ b/mslib/msui/qt5/ui_performance_dockwidget.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'mslib/msui/ui/ui_performance_dockwidget.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 @@ -41,22 +42,18 @@ def setupUi(self, PerformanceDockWidget): self.label_4.setObjectName("label_4") self.horizontalLayout_4.addWidget(self.label_4) self.dsbEmptyWeight = QtWidgets.QDoubleSpinBox(PerformanceDockWidget) + self.dsbEmptyWeight.setKeyboardTracking(False) self.dsbEmptyWeight.setDecimals(0) self.dsbEmptyWeight.setMaximum(9999999.0) self.dsbEmptyWeight.setSingleStep(1000.0) self.dsbEmptyWeight.setObjectName("dsbEmptyWeight") self.horizontalLayout_4.addWidget(self.dsbEmptyWeight) self.gridLayout.addLayout(self.horizontalLayout_4, 3, 0, 1, 1) - self.horizontalLayout_3 = QtWidgets.QHBoxLayout() - self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.label_3 = QtWidgets.QLabel(PerformanceDockWidget) + self.label_3.setWordWrap(True) self.label_3.setIndent(1) self.label_3.setObjectName("label_3") - self.horizontalLayout_3.addWidget(self.label_3) - self.dteTakeoffTime = QtWidgets.QDateTimeEdit(PerformanceDockWidget) - self.dteTakeoffTime.setObjectName("dteTakeoffTime") - self.horizontalLayout_3.addWidget(self.dteTakeoffTime) - self.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1) + self.gridLayout.addWidget(self.label_3, 0, 0, 1, 2) self.horizontalLayout_5 = QtWidgets.QHBoxLayout() self.horizontalLayout_5.setContentsMargins(-1, 4, -1, -1) self.horizontalLayout_5.setObjectName("horizontalLayout_5") @@ -65,20 +62,13 @@ def setupUi(self, PerformanceDockWidget): self.label_5.setObjectName("label_5") self.horizontalLayout_5.addWidget(self.label_5) self.dsbTakeoffWeight = QtWidgets.QDoubleSpinBox(PerformanceDockWidget) + self.dsbTakeoffWeight.setKeyboardTracking(False) self.dsbTakeoffWeight.setDecimals(0) self.dsbTakeoffWeight.setMaximum(9999999.0) self.dsbTakeoffWeight.setSingleStep(1000.0) self.dsbTakeoffWeight.setObjectName("dsbTakeoffWeight") self.horizontalLayout_5.addWidget(self.dsbTakeoffWeight) self.gridLayout.addLayout(self.horizontalLayout_5, 1, 0, 1, 1) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.cbShowPerformance = QtWidgets.QCheckBox(PerformanceDockWidget) - self.cbShowPerformance.setLayoutDirection(QtCore.Qt.LeftToRight) - self.cbShowPerformance.setTristate(False) - self.cbShowPerformance.setObjectName("cbShowPerformance") - self.horizontalLayout.addWidget(self.cbShowPerformance) - self.gridLayout.addLayout(self.horizontalLayout, 0, 1, 1, 1) self.retranslateUi(PerformanceDockWidget) QtCore.QMetaObject.connectSlotsByName(PerformanceDockWidget) @@ -90,6 +80,5 @@ def retranslateUi(self, PerformanceDockWidget): self.lbAircraftName.setText(_translate("PerformanceDockWidget", "DUMMY")) self.pbLoadPerformance.setText(_translate("PerformanceDockWidget", "Select")) self.label_4.setText(_translate("PerformanceDockWidget", "Aircraft weight (no fuel) (lb)")) - self.label_3.setText(_translate("PerformanceDockWidget", "Take off time")) + self.label_3.setText(_translate("PerformanceDockWidget", "Set the waypoint times by editing the Time (UTC) column of the table view.")) self.label_5.setText(_translate("PerformanceDockWidget", "Maximum take off weight (lb)")) - self.cbShowPerformance.setText(_translate("PerformanceDockWidget", "Show Performance")) diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index ad2380f3e..14ffd87b7 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -112,11 +112,17 @@ def setPerformance(self, settings): """ Updating Table View with updated performance settings. """ - self.waypoints_model.performance_settings = settings - self.waypoints_model.update_distances(0) - self.waypoints_model.save_settings() + # The table is repainted on performanceDataChanged. + self.waypoints_model.set_performance_settings(settings) self.resizeColumns() - self.tableWayPoints.viewport().repaint() + + def update_performance_data(self): + """ + Slot called when the times or fuel of the waypoints have changed. They + 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.tableWayPoints.viewport().update() def setLinearDataVisible(self, visible): """ @@ -334,6 +340,14 @@ def resizeColumns(self): for column in range(self.waypoints_model.columnCount()): self.tableWayPoints.resizeColumnToContents(column) + def _model_connections(self, model): + return ((model.dataChanged, self.update_roundtrip_enabled), + (model.rowsInserted, self.update_roundtrip_enabled), + (model.rowsRemoved, self.update_roundtrip_enabled), + (model.modelReset, self.update_roundtrip_enabled), + (model.performanceDataChanged, self.update_performance_data), + (model.linearDataChanged, self.update_linear_data)) + def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the table displays. @@ -343,8 +357,7 @@ def setFlightTrackModel(self, 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)): + for signal, slot in self._model_connections(previous): try: signal.disconnect(slot) except (TypeError, RuntimeError): @@ -352,10 +365,10 @@ def setFlightTrackModel(self, model): self.tableWayPoints.setModel(self.waypoints_model) # Automatically enable or disable roundtrip when data changes - self.waypoints_model.dataChanged.connect(self.update_roundtrip_enabled) + for signal, slot in self._model_connections(self.waypoints_model): + signal.connect(slot) 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. diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index f50aa4966..1b8ccbbbf 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -360,7 +360,9 @@ def setup_top_view(self): self.setFlightTrackModel(self.waypoints_model) # Automatically enable or disable roundtrip when data changes - self.waypoints_model.dataChanged.connect(self.update_roundtrip_enabled) + for signal in (self.waypoints_model.dataChanged, self.waypoints_model.rowsInserted, + self.waypoints_model.rowsRemoved, self.waypoints_model.modelReset): + signal.connect(self.update_roundtrip_enabled) self.update_roundtrip_enabled() self.mpl.navbar.push_current() diff --git a/mslib/msui/ui/ui_performance_dockwidget.ui b/mslib/msui/ui/ui_performance_dockwidget.ui index f01afa4e5..1ac2a36ac 100644 --- a/mslib/msui/ui/ui_performance_dockwidget.ui +++ b/mslib/msui/ui/ui_performance_dockwidget.ui @@ -80,6 +80,9 @@ + + false + 0 @@ -93,22 +96,18 @@ - - - - - - Take off time - - - 1 - - - - - - - + + + + Set the waypoint times by editing the Time (UTC) column of the table view. + + + true + + + 1 + + @@ -127,6 +126,9 @@ + + false + 0 @@ -140,23 +142,6 @@ - - - - - - Qt::LeftToRight - - - Show Performance - - - false - - - - - diff --git a/tests/_test_msui/test_change_signals.py b/tests/_test_msui/test_change_signals.py new file mode 100644 index 000000000..4ae600ad8 --- /dev/null +++ b/tests/_test_msui/test_change_signals.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_msui.test_change_signals + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + After each change of a flight track all views show the current data, and each costly update of + the views happens at most once: the WMS requests of the side and linear views, the redraws of + the top view and of the x-axis of the side view. + + This file is part of MSS. + + :copyright: Copyright 2026 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import mock +import pytest + +from PyQt5 import QtCore, QtGui, QtTest + +from mslib.msui import flighttrack as ft +from mslib.msui import msui +from mslib.msui.aircraft import SimpleAircraft + + +COMMENTS = len(ft.TABLE_FULL) - 1 + +# the waypoint data each plot shows +SHOWN = { + "top": ("lat", "lon", "location"), + "side": ("lat", "lon", "flightlevel", "pressure", "location", "utc_time"), + "linear": ("lat", "lon", "flightlevel", "pressure"), +} + + +def _set(column, value): + return lambda model: model.setData(model.index(1, column), value) + + +def _set_performance(model): + model.set_performance_settings(dict(model.performance_settings, takeoff_weight=1000)) + + +def _replace(model): + model.replace_waypoints([ft.Waypoint(5., 5., 100.), ft.Waypoint(6., 6., 200.), ft.Waypoint(7., 7., 300.)]) + + +def _shown(plot, waypoints): + return [tuple(getattr(waypoint, name) for name in SHOWN[plot]) for waypoint in waypoints] + + +class PaintedRegion(QtCore.QObject): + """ + The region of a widget painted since the start. + """ + + def __init__(self, widget): + super().__init__() + self.region = QtGui.QRegion() + widget.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Paint: + self.region = self.region.united(event.region()) + return False + + +class Test_ChangeSignalsOfViews: + @pytest.fixture(autouse=True) + def setup(self, qtbot): + self.qtbot = qtbot + self.window = msui.MSUIMainWindow() + self.window.create_new_flight_track() + self.model = self.window.active_flight_track + # the views are opened on another flight track, so that the switch moves their connections + self.window.create_new_flight_track() + self.other = self.window.active_flight_track + self.window.show() + QtTest.QTest.qWaitForWindowExposed(self.window) + for action in (self.window.actionTopView, self.window.actionSideView, self.window.actionLinearView, + self.window.actionTableView): + action.trigger() + views = {view.name: view for view in + (self.window.listViews.item(i).window for i in range(self.window.listViews.count()))} + self.window.activate_flight_track(self.window.listFlightTracks.item(0)) + assert self.window.active_flight_track is self.model + # a flight track keeps at least two waypoints, also after removing one + self.model.insertRows(1, waypoints=[ft.Waypoint(50., 10., 200.)]) + self.table = views["Table View"].tableWayPoints + views["Table View"].resize(3000, 400) + self.top, self.side, self.linear = [ + views[name].mpl.canvas for name in ("Top View", "Side View", "Linear View")] + qtbot.wait(100) + yield + for view in views.values(): + view.hide() + self.window.hide() + + @pytest.fixture + def calls(self): + """ + Count the costly updates of the views. + """ + counts = dict.fromkeys(("top", "vsec", "xaxis", "lsec"), 0) + + def count(name): + def inc(*args, **kwargs): + counts[name] += 1 + return inc + + self.side.waypoints_interactor.signal_get_vsec.connect(count("vsec")) + self.linear.waypoints_interactor.signal_get_lsec.connect(count("lsec")) + top_plotter = self.top.waypoints_interactor.plotter + side_plotter = self.side.plotter + with mock.patch.object(top_plotter, "redraw_path", side_effect=count("top")), \ + mock.patch.object(side_plotter, "redraw_xaxis", side_effect=count("xaxis")), \ + mock.patch("mslib.msui.flighttrack.save_settings_qsettings"): + yield counts + + @pytest.fixture + def plots(self): + """ + The waypoint data each plot got last. + """ + shown = {plot: _shown(plot, self.model.waypoints) for plot in SHOWN} + + def spy(plot, update_from_waypoints): + def update(waypoints, *args, **kwargs): + shown[plot] = _shown(plot, waypoints) + return update_from_waypoints(waypoints, *args, **kwargs) + return update + + plotters = {"top": self.top.waypoints_interactor.plotter, "side": self.side.waypoints_interactor.plotter, + "linear": self.linear.waypoints_interactor.plotter} + patches = [mock.patch.object(plotter, "update_from_waypoints", + side_effect=spy(plot, plotter.update_from_waypoints)) + for plot, plotter in plotters.items()] + for patch in patches: + patch.start() + yield shown + for patch in patches: + patch.stop() + + def _table_texts(self): + model = self.model + return [[model.data(model.index(row, column)).value() for column in range(model.columnCount())] + for row in range(model.rowCount())] + + @pytest.mark.parametrize("change, expected", [ + (_set(ft.LAT, 1.5), {"top": 1, "vsec": 1, "xaxis": 1, "lsec": 1}), + (_set(ft.LON, 1.5), {"top": 1, "vsec": 1, "xaxis": 1, "lsec": 1}), + (_set(ft.FLIGHTLEVEL, 300), {"top": 1, "vsec": 0, "xaxis": 1, "lsec": 1}), + (_set(ft.PRESSURE, 250), {"top": 1, "vsec": 0, "xaxis": 1, "lsec": 1}), + (_set(ft.LOCATION, "Somewhere"), {"top": 1, "vsec": 0, "xaxis": 0, "lsec": 0}), + (_set(COMMENTS, "a comment"), {"top": 0, "vsec": 0, "xaxis": 0, "lsec": 0}), + (_set(ft.TIME_UTC, "2030-01-01 12:00:00"), {"top": 0, "vsec": 0, "xaxis": 1, "lsec": 0}), + (lambda model: model.insertRows(1), {"top": 1, "vsec": 1, "xaxis": 1, "lsec": 1}), + (lambda model: model.removeRows(1), {"top": 1, "vsec": 1, "xaxis": 1, "lsec": 1}), + (lambda model: model.invert_direction(), {"top": 1, "vsec": 1, "xaxis": 1, "lsec": 1}), + (_replace, {"top": 1, "vsec": 1, "xaxis": 1, "lsec": 1}), + (_set_performance, {"top": 0, "vsec": 0, "xaxis": 1, "lsec": 0}), + ], ids=["lat", "lon", "flightlevel", "pressure", "location", "comments", "time", "insert", "remove", + "invert", "replace", "performance"]) + def test_views_are_updated_once(self, calls, plots, change, expected): + before = self._table_texts() + painted = PaintedRegion(self.table.viewport()) + change(self.model) + self.qtbot.wait(100) + + assert calls == expected + for plot in SHOWN: + assert plots[plot] == _shown(plot, self.model.waypoints), plot + # each table cell with a new text is repainted + viewport = self.table.viewport().rect() + for row, texts in enumerate(self._table_texts()): + for column, text in enumerate(texts): + if row >= len(before) or before[row][column] != text: + rect = self.table.visualRect(self.model.index(row, column)) + assert viewport.contains(rect), (row, column) + assert painted.region.contains(rect), (row, column) + + def test_inactive_flight_track_updates_no_view(self, calls): + self.other.setData(self.other.index(1, ft.LAT), 1.5) + self.other.insertRows(1) + assert calls == {"top": 0, "vsec": 0, "xaxis": 0, "lsec": 0} + + @pytest.mark.parametrize("column, value", [(ft.FLIGHTLEVEL, 350), (ft.PRESSURE, 240)], + ids=["flightlevel", "pressure"]) + def test_side_view_shows_times_after_altitude_change(self, column, value): + # an aircraft that is faster at higher altitudes, so the altitude changes the times + climb = [[weight, altitude, altitude / 1000, altitude / 200, altitude / 20] + for weight in (80000, 95000) for altitude in (0, 40000)] + aircraft = SimpleAircraft({ + "name": "TEST", "takeoff_weight": 91000, "empty_weight": 56000, "climb": climb, "descent": climb, + "cruise": [[weight, altitude, 380 + altitude / 400, 3000] + for weight in (80000, 95000) for altitude in (20000, 40000)]}) + with mock.patch("mslib.msui.flighttrack.save_settings_qsettings"): + self.model.set_performance_settings(dict(self.model.performance_settings, aircraft=aircraft)) + + def labels(): + return [label.get_text() for label in self.side.plotter.ax.get_xticklabels()] + + before = labels() + self.model.setData(self.model.index(1, column), value) + after = labels() + # what a complete redraw of the x-axis shows + self.side.waypoints_interactor.qt_performance_data_changed_listener() + assert after == labels() != before + + def test_top_view_redraws_for_time_with_solar_angle(self, calls): + self.top.waypoints_interactor.plotter.show_solar_angle = ("sun", "angle") + _set(ft.TIME_UTC, "2030-01-01 12:00:00")(self.model) + assert calls == {"top": 1, "vsec": 0, "xaxis": 1, "lsec": 0} diff --git a/tests/_test_msui/test_flighttrack.py b/tests/_test_msui/test_flighttrack.py index de9803e45..9298aa476 100644 --- a/tests/_test_msui/test_flighttrack.py +++ b/tests/_test_msui/test_flighttrack.py @@ -24,12 +24,15 @@ limitations under the License. """ import json +import mock +import pytest import xml.etree.ElementTree as etree -from PyQt5 import QtCore +from PyQt5 import QtCore, QtGui from mslib.msui.flighttrack import (LINEAR_DATA_COLUMN, TABLE_FULL, Waypoint, WaypointsTableModel, - linear_data_columns, values_at_waypoints) + linear_data_columns, values_at_waypoints, changed_columns, + LOCATION, LAT, LON, FLIGHTLEVEL, PRESSURE, TIME_UTC) from mslib.msui.performance_settings import DEFAULT_PERFORMANCE from tests.utils import lsec_xml @@ -188,3 +191,147 @@ def test_linear_data_does_not_modify_the_flight_track(self): self.model.set_linear_data_from_xml([lsec_xml()]) assert changed == [] assert not self.model.modified + + +class SignalCounter: + """ + Counts the change signals of a WaypointsTableModel and checks that every + dataChanged range is valid, i.e. top left is above and left of bottom right. + """ + SIGNALS = ("dataChanged", "rowsInserted", "rowsRemoved", "layoutChanged", "modelReset", + "performanceDataChanged") + + def __init__(self, model): + self.counts = dict.fromkeys(self.SIGNALS, 0) + for name in self.SIGNALS: + getattr(model, name).connect(lambda *args, name=name: self._count(name, args)) + + def _count(self, name, args): + if name == "dataChanged": + top_left, bottom_right = args[:2] + assert top_left.row() <= bottom_right.row(), (top_left.row(), bottom_right.row()) + assert top_left.column() <= bottom_right.column(), (top_left.column(), bottom_right.column()) + self.counts[name] += 1 + + def emitted(self): + return {name: count for name, count in self.counts.items() if count > 0} + + +COMMENTS = len(TABLE_FULL) - 1 + + +def _set(column, value): + return lambda model: model.setData(model.index(1, column), value) + + +def _set_displayed_time(model): + model.setData(model.index(1, TIME_UTC), model.data(model.index(1, TIME_UTC)).value()) + + +def _set_performance(model): + model.set_performance_settings(dict(model.performance_settings, takeoff_weight=1000)) + + +class Test_ChangeSignals: + """ + Each change of the flight track emits exactly one signal. dataChanged + stands for a change of the flight plan only (what MSColab stores), a change + of the times or fuel alone emits performanceDataChanged. + """ + + def setup_method(self): + self.model = WaypointsTableModel("") + self.model.insertRows(0, rows=4, waypoints=[ + Waypoint(0., 0., 0.), Waypoint(1., 1., 350.), Waypoint(2., 2., 350.), Waypoint(3., 3., 0.)]) + self.model.modified = False + + @pytest.mark.parametrize("change, signals, modified", [ + (_set(LOCATION, "Somewhere"), {"dataChanged": 1}, True), + (_set(LAT, 1.5), {"dataChanged": 1}, True), + (_set(LON, 1.5), {"dataChanged": 1}, True), + (_set(FLIGHTLEVEL, 300), {"dataChanged": 1}, True), + (_set(PRESSURE, 250), {"dataChanged": 1}, True), + (_set(COMMENTS, "a comment"), {"dataChanged": 1}, True), + (_set(TIME_UTC, "2030-01-01 12:00:00"), {"performanceDataChanged": 1}, False), + (_set_displayed_time, {}, False), + (_set(TIME_UTC, "noon"), {}, False), + (_set(LAT, "north"), {}, False), + (_set(LOCATION, ""), {}, False), + (_set(COMMENTS, ""), {}, False), + (lambda model: model.insertRows(1), {"rowsInserted": 1}, True), + (lambda model: model.removeRows(1), {"rowsRemoved": 1}, True), + (lambda model: model.invert_direction(), {"modelReset": 1}, True), + (lambda model: model.replace_waypoints([Waypoint(5., 5., 0.), Waypoint(6., 6., 0.)]), + {"modelReset": 1}, True), + (_set_performance, {"performanceDataChanged": 1}, False), + ], ids=["location", "lat", "lon", "flightlevel", "pressure", "comments", "time", "unchanged time", + "invalid time", "invalid lat", "unchanged location", "unchanged comments", "insert", "remove", "invert", + "replace", "performance"]) + def test_one_signal_per_change(self, change, signals, modified): + counter = SignalCounter(self.model) + with mock.patch("mslib.msui.flighttrack.save_settings_qsettings"): + change(self.model) + assert counter.emitted() == signals + assert self.model.modified == modified + + def test_changed_columns(self): + model = self.model + assert changed_columns(model.index(1, LOCATION), model.index(1, LON)) == {LOCATION, LAT, LON} + assert changed_columns(model.index(1, COMMENTS), model.index(1, COMMENTS)) == {COMMENTS} + + @pytest.mark.parametrize("column, value, columns", [ + (LAT, 1.5, {LOCATION, LAT, LON}), + (LON, 1.5, {LOCATION, LAT, LON}), + (FLIGHTLEVEL, 300, {FLIGHTLEVEL, PRESSURE}), + (PRESSURE, 250, {FLIGHTLEVEL, PRESSURE}), + (LOCATION, "Somewhere", {LOCATION}), + ]) + def test_changed_range(self, column, value, columns): + ranges = [] + self.model.dataChanged.connect(lambda index1, index2, *_: ranges.append(changed_columns(index1, index2))) + self.model.setData(self.model.index(1, column), value) + assert ranges == [columns] + + +def test_default_aircraft_has_fuel(): + model = WaypointsTableModel(waypoints=[Waypoint(0., 0., 0.), Waypoint(10., 10., 300.)]) + assert (model.waypoints[0].weight, model.waypoints[0].rem_fuel) == (90000, 35000) + assert 0 < model.waypoints[1].rem_fuel < 35000 + + +def test_read_only_cells_are_grey(qapp): + model = WaypointsTableModel("") + model.insertRows(0, rows=2, waypoints=[Waypoint(0., 0., 0.), Waypoint(1., 1., 350.)]) + grey = qapp.palette().brush(QtGui.QPalette.Disabled, QtGui.QPalette.Text) + for column, (_, _, editable) in enumerate(TABLE_FULL): + foreground = model.data(model.index(1, column), QtCore.Qt.ForegroundRole) + assert foreground == (None if editable else grey) + + +class Test_UTC: + """ + All times in MSS are UTC by definition, the fields of a QDateTime count, not its time spec. + """ + + @mock.patch("mslib.msui.flighttrack.save_settings_qsettings") + def test_local_spec_is_taken_as_utc(self, mocksave): + model = WaypointsTableModel("", waypoints=[Waypoint(0., 0., 0.), Waypoint(1., 1., 350.)]) + local = QtCore.QDateTime(QtCore.QDate(2030, 6, 1), QtCore.QTime(10, 0), QtCore.Qt.LocalTime) + assert model.setData(model.index(0, TIME_UTC), local) + assert model.data(model.index(0, TIME_UTC)).value() == "2030-06-01 10:00:00" + assert model.performance_settings["takeoff_time"].timeSpec() == QtCore.Qt.UTC + + def test_legacy_takeoff_time_keeps_its_fields(self): + """ + Earlier versions stored the take off time with local spec, and a + "visible" switch for the performance columns. + """ + local = QtCore.QDateTime(QtCore.QDate(2030, 6, 1), QtCore.QTime(10, 0), QtCore.Qt.LocalTime) + stored = dict(DEFAULT_PERFORMANCE, takeoff_time=local, visible=False) + with mock.patch("mslib.msui.flighttrack.load_settings_qsettings", return_value=stored): + model = WaypointsTableModel("", waypoints=[Waypoint(0., 0., 0.)]) + takeoff = model.performance_settings["takeoff_time"] + assert takeoff.timeSpec() == QtCore.Qt.UTC + assert takeoff.toString("yyyy-MM-dd HH:mm:ss") == "2030-06-01 10:00:00" + assert "visible" not in model.performance_settings + assert model.data(model.index(0, TIME_UTC)).value() == "2030-06-01 10:00:00" diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index b356407c0..b77a24b6d 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -43,7 +43,7 @@ from mslib.mscolab.api import endpoints from mslib.mscolab.api.message_type import MessageType from mslib.mscolab.models import Permission, User -from mslib.msui.flighttrack import LON, WaypointsTableModel +from mslib.msui.flighttrack import LON, TIME_UTC, Waypoint, WaypointsTableModel from PyQt5 import QtCore, QtGui, QtTest, QtWidgets from mslib.msui.utils.config import MSUIDefaultConfig, read_config_file, config_loader, modify_config_file from tests.utils import create_msui_settings_file, ExceptionMock @@ -1278,18 +1278,30 @@ def test_waypoint_change_is_sent_once(self, qtbot): self._activate_operation_at_index(0) mscolab_ = self.window.mscolab model = mscolab_.waypoints_model - with mock.patch.object(mscolab_.conn, "save_file") as save_file: - # setData emits dataChanged twice, from update_distances and for the changed cell + with mock.patch.object(mscolab_.conn, "save_file") as save_file, \ + mock.patch("mslib.msui.flighttrack.save_settings_qsettings"): + # times and fuel are not part of the flight plan + model.setData(model.index(0, TIME_UTC), "2030-01-01 12:00:00") + model.set_performance_settings(dict(model.performance_settings, takeoff_weight=1000)) + save_file.assert_not_called() model.setData(model.index(0, LON), QtCore.QVariant(model.waypoint_data(0).lon + 1)) save_file.assert_called_once() assert save_file.call_args.args[:2] == (mscolab_.active_op_id, model.get_xml_content()) + for change in (lambda: model.insertRows(1, waypoints=[Waypoint(50., 10., 200.)]), + lambda: model.removeRows(1), + model.invert_direction): + save_file.reset_mock() + change() + save_file.assert_called_once() + assert save_file.call_args.args[1] == model.get_xml_content() + save_file.reset_mock() # a reloaded flight track, e.g. after the server refused the change, is sent again when it changes mscolab_.waypoints_model = WaypointsTableModel(xml_content=model.get_xml_content()) mscolab_.handle_waypoints_changed() - assert save_file.call_count == 2 + assert save_file.call_count == 1 # a version name is always sent mscolab_.handle_waypoints_changed(version_name="imported") - assert save_file.call_count == 3 + assert save_file.call_count == 2 def test_user_chosen_text_is_not_markup(self, qtbot): markup = 'bold' diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index 8fc057756..7a0543118 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -25,8 +25,10 @@ limitations under the License. """ +import datetime import mock import os +import time import pytest from PyQt5 import QtWidgets, QtCore, QtTest @@ -141,24 +143,16 @@ def test_performance(self, mockopen): Check effect of performance settings on TableView """ self.window.cbTools.currentIndexChanged.emit(2) - - self.window.waypoints_model.performance_settings = DEFAULT_PERFORMANCE - self.window.waypoints_model.update_distances(0) - self.window.waypoints_model.dataChanged.emit( - self.window.waypoints_model.index(0, 0), self.window.waypoints_model.index(0, 0)) - self.window.resizeColumns() - assert self.window.waypoints_model.columnCount() == 15 - visible = dict(DEFAULT_PERFORMANCE) - visible["visible"] = True - self.window.waypoints_model.performance_settings = visible - self.window.waypoints_model.update_distances(0) - self.window.waypoints_model.dataChanged.emit( - self.window.waypoints_model.index(0, 0), self.window.waypoints_model.index(0, 0)) - self.window.resizeColumns() - assert self.window.waypoints_model.columnCount() == 15 - # todo this does not check that actually something happens + model = self.window.waypoints_model + assert model.columnCount() == 15 + changes = [] + model.performanceDataChanged.connect(lambda: changes.append(1)) QtTest.QTest.mouseClick(self.window.docks[1].widget().pbLoadPerformance, QtCore.Qt.LeftButton) assert mockopen.call_count == 1 + # the aircraft is applied once, not for each weight of the dock + assert changes == [1] + assert model.performance_settings["aircraft"] is not DEFAULT_PERFORMANCE["aircraft"] + assert model.waypoints[0].weight == model.performance_settings["takeoff_weight"] == 91000 def test_show_linear_data(self): """ @@ -490,18 +484,13 @@ def test_copy_table_to_clipboard(self): """ Copying the table to the clipboard produces CSV text with the columns currently shown, leaving out columns that are empty for - every waypoint (e.g. the performance columns hidden by default). + every waypoint (e.g. data columns of the linear view without data). Numbers use the decimal point of the active QLocale ('.' for the C locale used here), which also determines the field delimiter. """ - self.window.waypoints_model.performance_settings = DEFAULT_PERFORMANCE - self.window.resizeColumns() - rows = self._copy_table_to_clipboard_csv(QtCore.QLocale.c(), delimiter=",") header = [cell.strip() for cell in rows[0]] - expected_header = [ - "Location", "Lat (+-90)", "Lon (+-180)", "Flightlevel", "Pressure (hPa)", - "Leg dist. (km [nm])", "Cum. dist. (km [nm])", "Comments"] + expected_header = [" ".join(column[0].split()) for column in ft.TABLE_FULL] assert header == expected_header assert len(rows) == 1 + len(self.window.waypoints_model.waypoints) assert rows[1][0] == "EDMO" @@ -520,3 +509,128 @@ def test_copy_table_to_clipboard_uses_locale_decimal_point(self): de_locale = QtCore.QLocale(QtCore.QLocale.German, QtCore.QLocale.Germany) rows = self._copy_table_to_clipboard_csv(de_locale, delimiter=";") assert rows[2][1:3] == ["48,1", "10,27"] + + @mock.patch("mslib.msui.flighttrack.save_settings_qsettings") + def test_edit_time_shifts_flight(self, mocksave): + """ + Editing the time of one waypoint sets the takeoff time such that the + waypoint is reached at that time; all other times follow. + """ + default_takeoff = DEFAULT_PERFORMANCE["takeoff_time"] + model = self.window.waypoints_model + assert model.flags(model.index(2, ft.TIME_UTC)) & QtCore.Qt.ItemIsEditable + legs = [wp.utc_time - model.waypoints[0].utc_time for wp in model.waypoints] + model.modified = False + + assert model.setData(model.index(2, ft.TIME_UTC), "2030-01-01 12:00:00") + target = datetime.datetime(2030, 1, 1, 12) + # QDateTime holds milliseconds only + ms = datetime.timedelta(milliseconds=1) + assert abs(model.waypoints[2].utc_time - target) < ms + assert model.data(model.index(2, ft.TIME_UTC)).value() == "2030-01-01 12:00:00" + takeoff = model.performance_settings["takeoff_time"].toPyDateTime() + assert abs(takeoff - (target - legs[2])) < ms + assert all(abs((wp.utc_time - takeoff) - leg) < ms for wp, leg in zip(model.waypoints, legs)) + assert DEFAULT_PERFORMANCE["takeoff_time"] is default_takeoff + + # a time edit is no change of the flight plan + assert not model.modified + # unparsable input is discarded + assert not model.setData(model.index(2, ft.TIME_UTC), "noon") + assert model.data(model.index(2, ft.TIME_UTC)).value() == "2030-01-01 12:00:00" + + @mock.patch("mslib.msui.flighttrack.save_settings_qsettings") + def test_edit_time_with_editor(self, mocksave): + model = self.window.waypoints_model + index = model.index(3, ft.TIME_UTC) + delegate = self.window.tableWayPoints.itemDelegate() + editor = delegate.createEditor(self.window.tableWayPoints, QtWidgets.QStyleOptionViewItem(), index) + assert isinstance(editor, QtWidgets.QDateTimeEdit) + delegate.setEditorData(editor, index) + assert editor.dateTime().toString("yyyy-MM-dd HH:mm:ss") == model.data(index).value() + target = QtCore.QDateTime(2031, 6, 15, 8, 30, 0) + target.setTimeSpec(QtCore.Qt.UTC) + editor.setDateTime(target) + delegate.setModelData(editor, model, index) + assert model.data(index).value() == "2031-06-15 08:30:00" + + def test_pick_location_changes_once(self): + """ + Picking a location sets its coordinates and name, a single change of the flight track. + """ + model = self.window.waypoints_model + index = model.index(1, ft.LOCATION) + changes = [] + model.dataChanged.connect(lambda *args: changes.append(args)) + delegate = self.window.tableWayPoints.itemDelegate() + editor = delegate.createEditor(self.window.tableWayPoints, QtWidgets.QStyleOptionViewItem(), index) + editor.setCurrentText("Hamburg") + delegate.setModelData(editor, model, index) + assert len(changes) == 1 + assert (model.waypoints[1].location, model.waypoints[1].lat, model.waypoints[1].lon) == ("Hamburg", 53.55, 9.99) + + @mock.patch("mslib.msui.flighttrack.save_settings_qsettings") + def test_unchanged_editor_keeps_time(self, mocksave): + """ + The editor shows full seconds; committing it unchanged must not shift + the flight by the rounding difference. + """ + model = self.window.waypoints_model + model.setData(model.index(1, ft.TIME_UTC), "2030-01-01 12:00:00") + index = model.index(3, ft.TIME_UTC) + assert model.waypoints[3].utc_time.microsecond != 0 + takeoff = model.performance_settings["takeoff_time"] + changes = [] + model.performanceDataChanged.connect(lambda: changes.append(1)) + delegate = self.window.tableWayPoints.itemDelegate() + editor = delegate.createEditor(self.window.tableWayPoints, QtWidgets.QStyleOptionViewItem(), index) + delegate.setEditorData(editor, index) + delegate.setModelData(editor, model, index) + assert model.performance_settings["takeoff_time"] == takeoff + assert changes == [] + + @mock.patch("mslib.msui.flighttrack.save_settings_qsettings") + def test_performance_dock_keeps_edited_time(self, mocksave): + model = self.window.waypoints_model + self.window.cbTools.currentIndexChanged.emit(2) + model.setData(model.index(1, ft.TIME_UTC), "2030-01-01 12:00:00") + changes = [] + model.performanceDataChanged.connect(lambda: changes.append(1)) + spinbox = self.window.docks[1].widget().dsbTakeoffWeight + spinbox.selectAll() + QtTest.QTest.keyClicks(spinbox, "80000") + assert changes == [] + QtTest.QTest.keyClick(spinbox, QtCore.Qt.Key_Return) + # the flight track is updated once, not for each key stroke + assert changes == [1] + assert model.performance_settings["takeoff_weight"] == 80000 + assert model.data(model.index(1, ft.TIME_UTC)).value() == "2030-01-01 12:00:00" + + @pytest.mark.skipif(not hasattr(time, "tzset"), reason="time.tzset is available on Unix only") + @mock.patch("mslib.msui.flighttrack.save_settings_qsettings") + def test_edit_time_in_local_dst_gap(self, mocksave, monkeypatch): + """ + Times are UTC throughout; a time that does not exist in the local + time zone (daylight saving gap) must still round trip through the editor. + """ + monkeypatch.setenv("TZ", "Europe/Berlin") + time.tzset() + try: + model = self.window.waypoints_model + index = model.index(1, ft.TIME_UTC) + assert model.setData(index, "2030-03-31 02:30:00") + assert model.data(index).value() == "2030-03-31 02:30:00" + delegate = self.window.tableWayPoints.itemDelegate() + editor = delegate.createEditor(self.window.tableWayPoints, QtWidgets.QStyleOptionViewItem(), index) + delegate.setEditorData(editor, index) + assert editor.dateTime().toString("yyyy-MM-dd HH:mm:ss") == "2030-03-31 02:30:00" + delegate.setModelData(editor, model, index) + assert model.data(index).value() == "2030-03-31 02:30:00" + finally: + monkeypatch.undo() + time.tzset() + + def test_seconds_rounded(self): + assert ft.seconds_to_string(3599.5) == "01:00:00" + assert ft.seconds_to_string(3599.4) == "00:59:59" + assert ft.format_utc_time(datetime.datetime(2030, 1, 1, 11, 59, 59, 500000)) == "2030-01-01 12:00:00" diff --git a/tests/msui_fixtures.py b/tests/msui_fixtures.py index 5ae59ddc9..2f658a711 100644 --- a/tests/msui_fixtures.py +++ b/tests/msui_fixtures.py @@ -32,7 +32,7 @@ from PyQt5 import QtWidgets # tests.constants sets MSUI_CONFIG_PATH, which mslib.msui.utils.config needs at import time -from tests.constants import create_msui_settings_file +from tests.constants import MSUI_CONFIG_PATH, create_msui_settings_file from mslib.msui.utils.config import modify_config_file, read_config_file @@ -73,6 +73,8 @@ def reset_config(): """Reset the msui configuration file used in the tests after every test.""" create_msui_settings_file("{}") read_config_file() + # the widget settings (save_settings_qsettings), e.g. the performance settings of the flight tracks + (MSUI_CONFIG_PATH / "msui-core.conf").unlink(missing_ok=True) @pytest.fixture diff --git a/tutorials/textfiles/tutorial_performancesettings.txt b/tutorials/textfiles/tutorial_performancesettings.txt index 17d0a606f..a2f789e75 100644 --- a/tutorials/textfiles/tutorial_performancesettings.txt +++ b/tutorials/textfiles/tutorial_performancesettings.txt @@ -21,14 +21,12 @@ The information related to the performance data of the aircraft are stored here. Click on the "select" option to select the Json file from the MSS docs database as an example. -Click on show performance for the related data to be shown. - You can see the changes in the columns of the table view regarding the performance parameters of the aircraft. Giving some examples, things like the change in flight time, fuel, aircraft weight, ceiling altitude, etc as the flight takes off from Nagpur and reaches Delhi, can be analysed. -There are options in the panel like take off time, maximum take off weight and aircraft weight without fuel. +There are options in the panel like maximum take off weight and aircraft weight without fuel. We can change these settings to see the how the performance data changes. @@ -38,12 +36,6 @@ Like, we now changed the maximum take off weight from ninety one thousands to ei And the aircraft weight without fuel from fifty six thousands to forty eight thousands kilograms. -Here you can see we changed the take off time from 6th August 2021 to 4th August 2021. - -Then we can uncheck the box of show Performance and then click it again and there you can see the changes in the performance settings. - - -It just shows and hides the performance data. Now we are going to close the topview. diff --git a/tutorials/tutorial_performancesettings.py b/tutorials/tutorial_performancesettings.py index cfd11ef4c..3f6f20f8d 100644 --- a/tutorials/tutorial_performancesettings.py +++ b/tutorials/tutorial_performancesettings.py @@ -67,11 +67,6 @@ def automate_performance(): find_and_click_picture('tableviewwindow-select.png', 'Select button not found') type_path(sample_path, clear=True) - # Checking the Show Performance checkbox to display the settings file in the table view - find_and_click_picture('tableviewwindow-show-performance.png', - 'Show performance button not found', - bounding_box=(0, 0, 140, 23)) - # Changing the maximum take off weight find_and_click_picture('tableviewwindow-maximum-take-off-weight-lb.png', 'Max take off weight lb not found') @@ -87,30 +82,9 @@ def automate_performance(): pag.click(x + 300, y, duration=2) type_and_key('48000') - # Changing the take off time of the dummy aircraft - find_and_click_picture('tableviewwindow-take-off-time.png', - 'take off time not found') - x, y = pag.position() - pag.click(x + 410, y, duration=2) - type_and_key('') - for _ in range(5): - pag.press('up') - pag.sleep(2) - type_and_key('04', interval=0.5) - # update tutorial images create_tutorial_images() - # Showing and hiding the performance settings - for _ in range(2): - find_and_click_picture('tableviewwindow-show-performance.png', - 'show performance button not found', - bounding_box=(0, 0, 140, 23)) - # move the mouse pointer to a different location, so that the image can be found - pag.move(50, 50) - # update tutorial images - create_tutorial_images() - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish(close_widgets=2)