• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Open-MSS / MSS / 10653123390

01 Sep 2024 10:12AM UTC coverage: 69.967% (-0.07%) from 70.037%
10653123390

Pull #2495

github

web-flow
Merge d3a10b8f0 into 0b95679f6
Pull Request #2495: remove the conda/mamba based updater.

24 of 41 new or added lines in 5 files covered. (58.54%)

92 existing lines in 6 files now uncovered.

13843 of 19785 relevant lines covered (69.97%)

0.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.81
/mslib/msui/flighttrack.py
1
# -*- coding: utf-8 -*-
2
"""
3

4
    mslib.msui.flighttrack
5
    ~~~~~~~~~~~~~~~~~~~~~~
6

7
    Data model representing a flight track. The model is derived from
8
    QAbstractTableModel, so that it can directly be connected to any Qt view.
9

10
    For better understanding of the code, compare to the 'ships' example
11
    from chapter 14/16 of 'Rapid GUI Programming with Python and Qt: The
12
    Definitive Guide to PyQt Programming' (Mark Summerfield).
13

14
    The model includes a method for computing the distance between waypoints
15
    and for the entire flight track.
16

17
    This file is part of MSS.
18

19
    :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V.
20
    :copyright: Copyright 2011-2014 Marc Rautenhaus (mr)
21
    :copyright: Copyright 2016-2024 by the MSS team, see AUTHORS.
22
    :license: APACHE-2.0, see LICENSE for details.
23

24
    Licensed under the Apache License, Version 2.0 (the "License");
25
    you may not use this file except in compliance with the License.
26
    You may obtain a copy of the License at
27

28
       http://www.apache.org/licenses/LICENSE-2.0
29

30
    Unless required by applicable law or agreed to in writing, software
31
    distributed under the License is distributed on an "AS IS" BASIS,
32
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
33
    See the License for the specific language governing permissions and
34
    limitations under the License.
35
"""
36

37
import datetime
1✔
38
import logging
1✔
39
import os
1✔
40

41
import fs
1✔
42
import xml.dom.minidom
1✔
43
import xml.parsers.expat
1✔
44

45
from PyQt5 import QtGui, QtCore, QtWidgets
1✔
46

47
from mslib import __version__
1✔
48
from mslib.utils.units import units
1✔
49
from mslib.utils.coordinate import find_location, path_points, get_distance
1✔
50
from mslib.utils import thermolib
1✔
51
from mslib.utils.verify_waypoint_data import verify_waypoint_data
1✔
52
from mslib.utils.config import config_loader, save_settings_qsettings, load_settings_qsettings
1✔
53
from mslib.utils.config import MSUIDefaultConfig as mss_default
1✔
54
from mslib.utils.qt import variant_to_string, variant_to_float
1✔
55
from mslib.msui.performance_settings import DEFAULT_PERFORMANCE
1✔
56

57
from mslib.utils import writexml
1✔
58
xml.dom.minidom.Element.writexml = writexml
1✔
59
# Constants for identifying the table columns when the WaypointsTableModel is
60
# used with a QTableWidget.
61
LOCATION, LAT, LON, FLIGHTLEVEL, PRESSURE = list(range(5))
1✔
62
TIME_UTC = 9
1✔
63

64

65
def seconds_to_string(seconds):
1✔
66
    """
67
    Format a time given in seconds to a string HH:MM:SS. Used for the
68
    'leg time/cum. time' columns of the table view.
69
    """
70
    hours, seconds = divmod(int(seconds), 3600)
1✔
71
    minutes, seconds = divmod(seconds, 60)
1✔
72
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
1✔
73

74

75
TABLE_FULL = [
1✔
76
    ("Location                   ", lambda waypoint: waypoint.location, True),
77
    ("Lat\n(+-90)", lambda waypoint: round(float(waypoint.lat), 2), True),
78
    ("Lon\n(+-180)", lambda waypoint: round(float(waypoint.lon), 2), True),
79
    ("Flightlevel", lambda waypoint: waypoint.flightlevel, True),
80
    ("Pressure\n(hPa)", lambda waypoint: QtCore.QLocale().toString(waypoint.pressure / 100., 'f', 2), True),
81
    ("Leg dist.\n(km [nm])", lambda waypoint: f"{int(waypoint.distance_to_prev):d} "
82
                                              f"[{int(waypoint.distance_to_prev / 1.852):d}]", False),
83
    ("Cum. dist.\n(km [nm])", lambda waypoint: f"{int(waypoint.distance_total):d} "
84
                                               f"[{int(waypoint.distance_total / 1.852):d}]", False),
85
    ("Leg time", lambda waypoint: seconds_to_string(waypoint.leg_time), False),
86
    ("Cum. time", lambda waypoint: seconds_to_string(waypoint.cum_time), False),
87
    ("Time (UTC)", lambda waypoint: waypoint.utc_time.strftime("%Y-%m-%d %H:%M:%S"), False),
88
    ("Rem. fuel\n(lb)", lambda waypoint: f"{int(waypoint.rem_fuel):d}", False),
89
    ("Aircraft\nweight (lb)", lambda waypoint: f"{int(waypoint.weight):d}", False),
90
    ("Ceiling\naltitude (hft)", lambda waypoint: f"{waypoint.ceiling_alt:d}", False),
91
    ("Ascent rate\n(ft/minute)", lambda waypoint: f"{waypoint.ascent_rate:d}", False),
92
    ("Comments                        ", lambda waypoint: waypoint.comments, True),
93
]
94

95
TABLE_SHORT = [TABLE_FULL[_i] for _i in range(7)] + [TABLE_FULL[-1]] + [("", lambda _: "", False)] * 8
1✔
96

97

98
def load_from_xml_data(xml_content, name="Flight track"):
1✔
99
    try:
1✔
100
        doc = xml.dom.minidom.parseString(xml_content)
1✔
101
    except xml.parsers.expat.ExpatError as ex:
×
102
        raise SyntaxError(str(ex))
×
103

104
    ft_el = doc.getElementsByTagName("FlightTrack")[0]
1✔
105

106
    waypoints_list = []
1✔
107
    for wp_el in ft_el.getElementsByTagName("Waypoint"):
1✔
108

109
        location = wp_el.getAttribute("location")
1✔
110
        lat = float(wp_el.getAttribute("lat"))
1✔
111
        lon = float(wp_el.getAttribute("lon"))
1✔
112
        flightlevel = float(wp_el.getAttribute("flightlevel"))
1✔
113
        comments = wp_el.getElementsByTagName("Comments")[0]
1✔
114
        # If num of comments is 0(null comment), then return ''
115
        if len(comments.childNodes):
1✔
116
            comments = comments.childNodes[0].data.strip()
1✔
117
        else:
118
            comments = ""
1✔
119

120
        waypoints_list.append(Waypoint(lat, lon, flightlevel,
1✔
121
                                       location=location,
122
                                       comments=comments))
123
    return waypoints_list
1✔
124

125

126
class Waypoint:
1✔
127
    """
128
    Represents a waypoint with position, altitude and further
129
    properties. Used internally by WaypointsTableModel.
130
    """
131

132
    def __init__(self, lat=0., lon=0., flightlevel=0., location="", comments=""):
1✔
133
        self.location = location
1✔
134
        locations = config_loader(dataset='locations')
1✔
135
        if location in locations:
1✔
136
            self.lat, self.lon = locations[location]
1✔
137
        else:
138
            self.lat = lat
1✔
139
            self.lon = lon
1✔
140
        self.flightlevel = flightlevel
1✔
141
        self.pressure = thermolib.flightlevel2pressure(flightlevel * units.hft).magnitude
1✔
142
        self.distance_to_prev = 0.
1✔
143
        self.distance_total = 0.
1✔
144
        self.comments = comments
1✔
145

146
        # Performance fields (for values read from the flight performance
147
        # service).
148
        self.leg_time = None  # time from previous waypoint
1✔
149
        self.cum_time = None  # total time of flight
1✔
150
        self.utc_time = None  # time in UTC since given takeoff time
1✔
151
        self.leg_fuel = None  # fuel consumption since previous waypoint
1✔
152
        self.rem_fuel = None  # total fuel consumption
1✔
153
        self.weight = None  # aircraft gross weight
1✔
154
        self.ceiling_alt = None  # aircraft ceiling altitude
1✔
155
        self.ascent_rate = None  # aircraft ascent rate
1✔
156

157
        self.wpnumber_major = None
1✔
158
        self.wpnumber_minor = None
1✔
159

160
    def __str__(self):
1✔
161
        """
162
        String representation of the waypoint (e.g., when used with the print
163
        statement).
164
        """
165
        return f"WAYPOINT(LAT={self.lat:f}, LON={self.lon:f}, FL={self.flightlevel:f})"
×
166

167

168
class WaypointsTableModel(QtCore.QAbstractTableModel):
1✔
169
    """
170
    Qt-QAbstractTableModel-derived data structure representing a flight
171
    track composed of a number of waypoints.
172

173
    Objects of this class can be directly connected to any Qt view that is
174
    able to handle tables models.
175

176
    Provides methods to store and load the model to/from an XML file, to compute
177
    distances between the individual waypoints, and to interpret the results of
178
    flight performance calculations.
179
    """
180

181
    def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, data_dir=mss_default.mss_dir,
1✔
182
                 xml_content=None):
183
        super().__init__()
1✔
184
        self.name = name  # a name for this flight track
1✔
185
        self.filename = filename  # filename for store/load
1✔
186
        self.data_dir = data_dir
1✔
187
        self.modified = False  # for "save on exit"
1✔
188
        self.waypoints = []  # user-defined waypoints
1✔
189
        # file-save events are handled in a different manner
190
        self.mscolab_mode = mscolab_mode
1✔
191

192
        # self.aircraft.setErrorHandling("permissive")
193
        self.settings_tag = "performance"
1✔
194
        self.load_settings()
1✔
195

196
        # If a filename is passed to the constructor, load data from this file.
197
        if filename is not None:
1✔
198
            self.load_from_ftml(filename)
1✔
199

200
        # If xml string is passed to constructor, load data from that
201
        elif xml_content is not None:
1✔
202
            self.load_from_xml_data(xml_content)
1✔
203

204
        if waypoints:
1✔
205
            self.replace_waypoints(waypoints)
1✔
206

207
    def load_settings(self):
1✔
208
        """
209
        Load settings from the file self.settingsfile.
210
        """
211
        self.performance_settings = load_settings_qsettings(self.settings_tag, DEFAULT_PERFORMANCE)
1✔
212

213
    def save_settings(self):
1✔
214
        """
215
        Save the current settings (map appearance) to the file
216
        self.settingsfile.
217
        """
218
        save_settings_qsettings(self.settings_tag, self.performance_settings)
1✔
219

220
    def flags(self, index):
1✔
221
        """
222
        Used to specify which table columns can be edited by the user;
223
        overrides the corresponding QAbstractTableModel method.
224
        """
225
        if not index.isValid():
1✔
226
            return QtCore.Qt.ItemIsEnabled
×
227
        column = index.column()
1✔
228
        table = TABLE_SHORT
1✔
229
        if self.performance_settings["visible"]:
1✔
230
            table = TABLE_FULL
1✔
231
        if table[column][2]:
1✔
232
            return QtCore.Qt.ItemFlags(
1✔
233
                int(QtCore.QAbstractTableModel.flags(self, index) |
234
                    QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled |
235
                    QtCore.Qt.ItemIsDropEnabled))
236
        else:
237
            return QtCore.Qt.ItemFlags(
1✔
238
                int(QtCore.QAbstractTableModel.flags(self, index) |
239
                    QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled))
240

241
    def data(self, index, role=QtCore.Qt.DisplayRole):
1✔
242
        """
243
        Return a data field at the given index (of type QModelIndex,
244
        specifying row and column); overrides the corresponding
245
        QAbstractTableModel method.
246

247
        NOTE: Other roles (e.g. for display appearance) could be specified in
248
        this method as well. Cf. the 'ships' example in chapter 14/16 of 'Rapid
249
        GUI Programming with Python and Qt: The Definitive Guide to PyQt
250
        Programming' (Mark Summerfield).
251
        """
252
        waypoints = self.waypoints
1✔
253

254
        if not index.isValid() or not (0 <= index.row() < len(waypoints)):
1✔
255
            return QtCore.QVariant()
×
256
        waypoint = waypoints[index.row()]
1✔
257
        column = index.column()
1✔
258
        if role == QtCore.Qt.DisplayRole:
1✔
259
            if self.performance_settings["visible"]:
1✔
260
                return QtCore.QVariant(TABLE_FULL[column][1](waypoint))
1✔
261
            else:
262
                return QtCore.QVariant(TABLE_SHORT[column][1](waypoint))
1✔
263
        elif role == QtCore.Qt.TextAlignmentRole:
1✔
264
            return QtCore.QVariant(int(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter))
1✔
265
        return QtCore.QVariant()
1✔
266

267
    def waypoint_data(self, row):
1✔
268
        """
269
        Get the waypoint object defining the given row.
270
        """
271
        return self.waypoints[row]
1✔
272

273
    def all_waypoint_data(self):
1✔
274
        """
275
        Return the entire list of waypoints.
276
        """
277
        return self.waypoints
1✔
278

279
    def intermediate_points(self, numpoints=101, connection="greatcircle"):
1✔
280
        """
281
        Compute intermediate points between the waypoints.
282

283
        See mss_util.path_points() for additional arguments.
284

285
        Returns lats, lons.
286
        """
287
        return path_points(
×
288
            [wp.lat for wp in self.waypoints],
289
            [wp.lon for wp in self.waypoints],
290
            times=[wp.utc_time for wp in self.waypoints],
291
            numpoints=numpoints, connection=connection)
292

293
    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
1✔
294
        """
295
        Return data describing the table header; overrides the
296
        corresponding QAbstractTableModel method.
297
        """
298
        if role == QtCore.Qt.TextAlignmentRole:
1✔
299
            if orientation == QtCore.Qt.Horizontal:
1✔
300
                return QtCore.QVariant(int(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter))
1✔
301
            return QtCore.QVariant(int(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter))
1✔
302
        if role != QtCore.Qt.DisplayRole:
1✔
303
            return QtCore.QVariant()
1✔
304
        # Return the names of the table columns.
305
        if orientation == QtCore.Qt.Horizontal:
1✔
306
            if self.performance_settings["visible"]:
1✔
307
                return QtCore.QVariant(TABLE_FULL[section][0])
1✔
308
            else:
309
                return QtCore.QVariant(TABLE_SHORT[section][0])
1✔
310
        # Table rows (waypoints) are labelled with their number (= number of
311
        # waypoint).
312
        return QtCore.QVariant(int(section))
1✔
313

314
    def rowCount(self, index=QtCore.QModelIndex()):
1✔
315
        """
316
        Number of waypoints in the model.
317
        """
318
        return len(self.waypoints)
1✔
319

320
    def columnCount(self, index=QtCore.QModelIndex()):
1✔
321
        return len(TABLE_FULL)
1✔
322

323
    def setData(self, index, value, role=QtCore.Qt.EditRole, update=True):
1✔
324
        """
325
        Change a data element of the flight track; overrides the
326
        corresponding QAbstractTableModel method.
327

328
        NOTE: Performance computations loose their validity if a change is made.
329
        """
330
        if index.isValid() and 0 <= index.row() < len(self.waypoints):
1✔
331
            waypoint = self.waypoints[index.row()]
1✔
332
            column = index.column()
1✔
333
            index2 = index  # in most cases only one field is being changed
1✔
334
            if column == LOCATION:
1✔
335
                waypoint.location = variant_to_string(value)
×
336
            elif column == LAT:
1✔
337
                try:
1✔
338
                    # The table fields accept basically any input.
339
                    # If the string cannot be converted to "float" (raises ValueError), the user input is discarded.
340
                    value = variant_to_float(value)
1✔
341
                    if not (-90 <= value <= 90):
1✔
342
                        raise ValueError
×
343
                except TypeError as ex:
×
344
                    logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value)
×
345
                except ValueError as ex:
×
346
                    logging.error("%s %s '%s'", type(ex), ex, value)
×
347
                else:
348
                    waypoint.lat = value
1✔
349
                    waypoint.location = ""
1✔
350
                    loc = find_location(waypoint.lat, waypoint.lon, 1e-3)
1✔
351
                    if loc is not None:
1✔
UNCOV
352
                        waypoint.lat, waypoint.lon = loc[0]
×
UNCOV
353
                        waypoint.location = loc[1]
×
354
                    # A change of position requires an update of the distances.
355
                    if update:
1✔
356
                        self.update_distances(index.row())
×
357
                    # Notify the views that items between the edited item and
358
                    # the distance item of the corresponding waypoint have been
359
                    # changed.
360
                    # Delete the location name -- it won't be valid anymore
361
                    # after its coordinates have been changed.
362
                    index2 = self.createIndex(index.row(), LOCATION)
1✔
363
            elif column == LON:
1✔
364
                try:
1✔
365
                    # The table fields accept basically any input.
366
                    # If the string cannot be converted to "float" (raises ValueError), the user input is discarded.
367
                    value = variant_to_float(value)
1✔
368
                    if not (-720 <= value <= 720):
1✔
369
                        raise ValueError
×
370
                except TypeError as ex:
×
371
                    logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value)
×
372
                except ValueError as ex:
×
373
                    logging.error("%s %s '%s'", type(ex), ex, value)
×
374
                else:
375
                    waypoint.lon = value
1✔
376
                    waypoint.location = ""
1✔
377
                    loc = find_location(waypoint.lat, waypoint.lon, 1e-3)
1✔
378
                    if loc is not None:
1✔
UNCOV
379
                        waypoint.lat, waypoint.lon = loc[0]
×
UNCOV
380
                        waypoint.location = loc[1]
×
381
                    if update:
1✔
382
                        self.update_distances(index.row())
1✔
383
                    index2 = self.createIndex(index.row(), LOCATION)
1✔
384
            elif column == FLIGHTLEVEL:
×
385
                try:
×
386
                    # The table fields accept basically any input.
387
                    # If the string cannot be converted to "float" (raises ValueError), the user input is discarded.
388
                    flightlevel = variant_to_float(value)
×
389
                    pressure = float(thermolib.flightlevel2pressure(flightlevel * units.hft).magnitude)
×
390
                except TypeError as ex:
×
391
                    logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value)
×
392
                except ValueError as ex:
×
393
                    logging.error("%s %s '%s'", type(ex), ex, value)
×
394
                else:
395
                    waypoint.flightlevel = flightlevel
×
396
                    waypoint.pressure = pressure
×
397
                    if update:
×
398
                        self.update_distances(index.row())
×
399
                    # need to notify view of the second item that has been
400
                    # changed as well.
401
                    index2 = self.createIndex(index.row(), PRESSURE)
×
402
            elif column == PRESSURE:
×
403
                try:
×
404
                    # The table fields accept basically any input.
405
                    # If the string cannot be converted to "float" (raises ValueError), the user input is discarded.
406
                    pressure = variant_to_float(value) * 100  # convert hPa to Pa
×
407
                    if pressure > 200000:
×
408
                        raise ValueError
×
409
                    flightlevel = float(round(thermolib.pressure2flightlevel(pressure * units.Pa).magnitude))
×
410
                    pressure = float(thermolib.flightlevel2pressure(flightlevel * units.hft).magnitude)
×
411
                except TypeError as ex:
×
412
                    logging.error("unexpected error: %s %s %s %s", type(ex), ex, type(value), value)
×
413
                except ValueError as ex:
×
414
                    logging.error("%s", ex)
×
415
                else:
416
                    waypoint.pressure = pressure
×
417
                    waypoint.flightlevel = flightlevel
×
418
                    if update:
×
419
                        self.update_distances(index.row())
×
420
                    index2 = self.createIndex(index.row(), FLIGHTLEVEL)
×
421
            else:
422
                waypoint.comments = variant_to_string(value)
×
423
            self.modified = True
1✔
424
            # Performance computations loose their validity if a change is made.
425
            if update:
1✔
426
                self.dataChanged.emit(index, index2)
1✔
427
            return True
1✔
428
        return False
×
429

430
    def insertRows(self, position, rows=1, index=QtCore.QModelIndex(),
1✔
431
                   waypoints=None):
432
        """
433
        Insert waypoint; overrides the corresponding QAbstractTableModel
434
        method.
435
        """
436
        if not waypoints:
1✔
437
            waypoints = [Waypoint(0, 0, 0)] * rows
×
438

439
        assert len(waypoints) == rows, (waypoints, rows)
1✔
440

441
        self.beginInsertRows(QtCore.QModelIndex(), position,
1✔
442
                             position + rows - 1)
443
        for row, wp in enumerate(waypoints):
1✔
444
            self.waypoints.insert(position + row, wp)
1✔
445

446
        self.update_distances(position, rows=rows)
1✔
447
        self.endInsertRows()
1✔
448
        self.modified = True
1✔
449
        return True
1✔
450

451
    def removeRows(self, position, rows=1, index=QtCore.QModelIndex()):
1✔
452
        """
453
        Remove waypoint; overrides the corresponding QAbstractTableModel
454
        method.
455
        """
456
        # beginRemoveRows emits rowsAboutToBeRemoved(index, first, last).
457
        self.beginRemoveRows(QtCore.QModelIndex(), position,
1✔
458
                             position + rows - 1)
459
        self.waypoints = self.waypoints[:position] + self.waypoints[position + rows:]
1✔
460
        if position < len(self.waypoints):
1✔
461
            self.update_distances(position, rows=min(rows, len(self.waypoints) - position))
1✔
462

463
        # endRemoveRows emits rowsRemoved(index, first, last).
464
        self.endRemoveRows()
1✔
465
        self.modified = True
1✔
466
        return True
1✔
467

468
    def update_distances(self, position, rows=1):
1✔
469
        """
470
        Update all distances in a flight track that are affected by a
471
        waypoint change involving <rows> waypoints starting at index
472
        <position>.
473

474
        Distances are computed along great circles.
475

476
        If rows=1, the distance to the previous waypoint is updated for
477
        waypoints <position> and <position+1>. The total flight track distance
478
        is updated for all waypoint following <position>.
479

480
        If rows>1, the distances to the previous waypoints are updated
481
        according to the number of modified waypoints.
482
        """
483
        waypoints = self.waypoints
1✔
484
        aircraft = self.performance_settings["aircraft"]
1✔
485

486
        def get_duration_fuel(flightlevel0, flightlevel1, distance, weight, lastleg):
1✔
487
            if flightlevel0 == flightlevel1:
1✔
488
                tas, fuelflow = aircraft.get_cruise_performance(flightlevel0 * 100, weight)
1✔
489
                duration = 3600. * distance / (1.852 * tas)  # convert to s (tas is in nm/h)
1✔
490
                leg_fuel = duration * fuelflow / 3600.
1✔
491
                return duration, leg_fuel
1✔
492
            else:
493
                if flightlevel0 < flightlevel1:
1✔
494
                    duration0, dist0, fuel0 = aircraft.get_climb_performance(flightlevel0 * 100, weight)
1✔
495
                    duration1, dist1, fuel1 = aircraft.get_climb_performance(flightlevel1 * 100, weight)
1✔
496
                else:
497
                    duration0, dist0, fuel0 = aircraft.get_descent_performance(flightlevel0 * 100, weight)
1✔
498
                    duration1, dist1, fuel1 = aircraft.get_descent_performance(flightlevel1 * 100, weight)
1✔
499
                duration = (duration1 - duration0) * 60  # convert from min to s
1✔
500
                dist = (dist1 - dist0) * 1.852  # convert from nm to km
1✔
501
                fuel = fuel1 - fuel0
1✔
502
                if lastleg:
1✔
503
                    duration_p, fuel_p = get_duration_fuel(flightlevel0, flightlevel0, distance - dist, weight, False)
1✔
504
                else:
505
                    duration_p, fuel_p = get_duration_fuel(flightlevel1, flightlevel1, distance - dist, weight, False)
1✔
506
                return duration + duration_p, fuel + fuel_p
1✔
507

508
        pos = position
1✔
509
        for offset in range(rows):
1✔
510
            pos = position + offset
1✔
511
            wp1 = waypoints[pos]
1✔
512
            # The distance to the first waypoint is zero.
513
            if pos == 0:
1✔
514
                wp1.distance_to_prev = 0.
1✔
515
                wp1.distance_total = 0.
1✔
516

517
                wp1.leg_time = 0  # time from previous waypoint
1✔
518
                wp1.cum_time = 0  # total time of flight
1✔
519
                wp1.utc_time = self.performance_settings["takeoff_time"].toPyDateTime()
1✔
520
                wp1.weight = self.performance_settings["takeoff_weight"]
1✔
521
                wp1.leg_fuel = 0
1✔
522
                wp1.rem_fuel = self.performance_settings["takeoff_weight"] - self.performance_settings["empty_weight"]
1✔
523
                wp1.ascent_rate = 0
1✔
524
            else:
525
                wp0 = waypoints[pos - 1]
1✔
526
                wp1.distance_to_prev = get_distance(
1✔
527
                    wp0.lat, wp0.lon, wp1.lat, wp1.lon)
528

529
                last = (pos - 1 == rows)
1✔
530
                time, fuel = get_duration_fuel(
1✔
531
                    wp0.flightlevel, wp1.flightlevel, wp1.distance_to_prev, wp0.weight, lastleg=last)
532
                wp1.leg_time = time
1✔
533
                wp1.cum_time = wp0.cum_time + wp1.leg_time
1✔
534
                wp1.utc_time = wp0.utc_time + datetime.timedelta(seconds=wp1.leg_time)
1✔
535
                wp1.leg_fuel = fuel
1✔
536
                wp1.rem_fuel = wp0.rem_fuel - wp1.leg_fuel
1✔
537
                wp1.weight = wp0.weight - wp1.leg_fuel
1✔
538
                if wp1.leg_time != 0:
1✔
539
                    wp1.ascent_rate = int((wp1.flightlevel - wp0.flightlevel) * 100 / (wp1.leg_time / 60))
1✔
540
                else:
541
                    wp1.ascent_rate = 0
1✔
542
            wp1.ceiling_alt = aircraft.get_ceiling_altitude(wp1.weight)
1✔
543

544
        # Update the distance of the following waypoint as well.
545
        if pos < len(waypoints) - 1:
1✔
546
            wp2 = waypoints[pos + 1]
1✔
547
            wp2.distance_to_prev = get_distance(
1✔
548
                wp1.lat, wp1.lon, wp2.lat, wp2.lon)
549
            if wp2.leg_time != 0:
1✔
550
                wp2.ascent_rate = int((wp2.flightlevel - wp1.flightlevel) * 100 / (wp2.leg_time / 60))
1✔
551
            else:
552
                wp2.ascent_rate = 0
1✔
553

554
        # Update total distances of waypoint at index position and all
555
        # following waypoints.
556
        for i in range(max(min(position, 1), 1), len(waypoints)):
1✔
557
            wp0 = waypoints[i - 1]
1✔
558
            wp1 = waypoints[i]
1✔
559
            wp1.distance_total = wp0.distance_total + wp1.distance_to_prev
1✔
560
            wp1.weight = wp0.weight - wp0.leg_fuel
1✔
561
            last = (i + 1 == len(waypoints))
1✔
562
            time, fuel = get_duration_fuel(
1✔
563
                wp0.flightlevel, wp1.flightlevel, wp1.distance_to_prev, wp0.weight, lastleg=last)
564

565
            wp1.leg_time = time
1✔
566
            wp1.cum_time = wp0.cum_time + wp1.leg_time
1✔
567
            wp1.utc_time = wp0.utc_time + datetime.timedelta(seconds=wp1.leg_time)
1✔
568
            wp1.leg_fuel = fuel
1✔
569
            wp1.rem_fuel = wp0.rem_fuel - wp1.leg_fuel
1✔
570
            wp1.weight = wp0.weight - wp1.leg_fuel
1✔
571
            wp1.ceiling_alt = aircraft.get_ceiling_altitude(wp1.weight)
1✔
572

573
        index1 = self.createIndex(0, TIME_UTC)
1✔
574
        self.dataChanged.emit(index1, index1)
1✔
575

576
    def invert_direction(self):
1✔
577
        self.waypoints = self.waypoints[::-1]
1✔
578
        if len(self.waypoints) > 0:
1✔
579
            self.waypoints[0].distance_to_prev = 0
1✔
580
            self.waypoints[0].distance_total = 0
1✔
581
        for i in range(1, len(self.waypoints)):
1✔
582
            wp_comm = self.waypoints[i].comments
1✔
583
            if len(wp_comm) == 9 and wp_comm.startswith("Hexagon "):
1✔
584
                wp_comm = f"Hexagon {(8 - int(wp_comm[-1])):d}"
×
585
                self.waypoints[i].comments = wp_comm
×
586
        self.update_distances(position=0, rows=len(self.waypoints))
1✔
587
        index = self.index(0, 0)
1✔
588

589
        self.layoutChanged.emit()
1✔
590
        self.dataChanged.emit(index, index)
1✔
591

592
    def replace_waypoints(self, new_waypoints):
1✔
593
        self.waypoints = []
1✔
594
        self.insertRows(0, rows=len(new_waypoints), waypoints=new_waypoints)
1✔
595

596
    def save_to_ftml(self, filename=None):
1✔
597
        """
598
        Save the flight track to an XML file.
599

600
        Arguments:
601
        filename -- complete path to the file to save. If None, a previously
602
                    specified filename will be used. If no filename has been
603
                    specified at all, a ValueError exception will be raised.
604
        """
605
        if not filename:
1✔
606
            raise ValueError("filename to save flight track cannot be None or empty")
×
607

608
        self.filename = filename
1✔
609
        self.name = fs.path.basename(filename.replace(".ftml", "").strip())
1✔
610
        doc = self.get_xml_doc()
1✔
611
        dirname, name = fs.path.split(self.filename)
1✔
612
        file_dir = fs.open_fs(dirname)
1✔
613
        with file_dir.open(name, 'w') as file_object:
1✔
614
            doc.writexml(file_object, indent="  ", addindent="  ", newl="\n", encoding="utf-8")
1✔
615
        file_dir.close()
1✔
616

617
    def get_xml_doc(self):
1✔
618
        doc = xml.dom.minidom.Document()
1✔
619
        ft_el = doc.createElement("FlightTrack")
1✔
620
        ft_el.setAttribute("version", __version__)
1✔
621
        doc.appendChild(ft_el)
1✔
622
        # The list of waypoint elements.
623
        wp_el = doc.createElement("ListOfWaypoints")
1✔
624
        ft_el.appendChild(wp_el)
1✔
625
        for wp in self.waypoints:
1✔
626
            element = doc.createElement("Waypoint")
1✔
627
            wp_el.appendChild(element)
1✔
628
            element.setAttribute("location", str(wp.location))
1✔
629
            element.setAttribute("lat", str(wp.lat))
1✔
630
            element.setAttribute("lon", str(wp.lon))
1✔
631
            element.setAttribute("flightlevel", str(wp.flightlevel))
1✔
632
            comments = doc.createElement("Comments")
1✔
633
            comments.appendChild(doc.createTextNode(str(wp.comments)))
1✔
634
            element.appendChild(comments)
1✔
635
        return doc
1✔
636

637
    def get_xml_content(self):
1✔
638
        doc = self.get_xml_doc()
1✔
639
        return doc.toprettyxml(indent="  ", newl="\n")
1✔
640

641
    def load_from_ftml(self, filename):
1✔
642
        """
643
        Load a flight track from an XML file at <filename>.
644
        """
645
        _dirname, _name = os.path.split(filename)
1✔
646
        _fs = fs.open_fs(_dirname)
1✔
647
        xml_content = _fs.readtext(_name)
1✔
648
        name = os.path.basename(filename.replace(".ftml", "").strip())
1✔
649
        self.load_from_xml_data(xml_content, name)
1✔
650

651
    def load_from_xml_data(self, xml_content, name="Flight track"):
1✔
652
        self.name = name
1✔
653
        if verify_waypoint_data(xml_content):
1✔
654
            _waypoints_list = load_from_xml_data(xml_content, name)
1✔
655
            self.replace_waypoints(_waypoints_list)
1✔
656
        else:
657
            raise SyntaxError(f"Invalid flight track filename: {name}")
×
658

659
    def get_filename(self):
1✔
660
        return self.filename
1✔
661

662

663
#
664
# CLASS  WaypointDelegate
665
#
666

667

668
class WaypointDelegate(QtWidgets.QItemDelegate):
1✔
669
    """
670
    Qt delegate class for the appearance of the table view. Based on the
671
    'ships' example in chapter 14/16 of 'Rapid GUI Programming with Python
672
    and Qt: The Definitive Guide to PyQt Programming' (Mark Summerfield).
673
    """
674

675
    def __init__(self, parent=None):
1✔
676
        super().__init__(parent)
1✔
677

678
    def paint(self, painter, option, index):
1✔
679
        """
680
        Colors waypoints with a minor waypoint number (i.e. intermediate
681
        waypoints generated by the flight performance service) in red.
682
        """
683
        wpnumber_minor = index.model().waypoint_data(index.row()).wpnumber_minor
1✔
684
        if wpnumber_minor is not None and wpnumber_minor > 0:
1✔
685
            newpalette = QtGui.QPalette(option.palette)
×
686
            colour = QtGui.QColor(170, 0, 0)  # dark red
×
687
            newpalette.setColor(QtGui.QPalette.Text, colour)
×
688
            colour = QtGui.QColor(255, 255, 0)  # yellow
×
689
            newpalette.setColor(QtGui.QPalette.HighlightedText, colour)
×
690
            option.palette = newpalette
×
691
        QtWidgets.QItemDelegate.paint(self, painter, option, index)
1✔
692

693
    def createEditor(self, parent, option, index):
1✔
694
        """
695
        Create a combobox listing predefined locations in the LOCATION
696
        column.
697
        """
698
        if index.column() == LOCATION:
×
699
            combobox = QtWidgets.QComboBox(parent)
×
700
            adds = set(config_loader(dataset='locations'))
×
701
            if self.parent() is not None:
×
702
                for wp in self.parent().waypoints_model.all_waypoint_data():
×
703
                    if wp.location != "":
×
704
                        adds.add(wp.location)
×
705
            combobox.addItems(sorted(adds))
×
706
            combobox.setEditable(True)
×
707
            return combobox
×
708
        else:
709
            # All other columns get the standard editor.
710
            return QtWidgets.QItemDelegate.createEditor(self, parent, option, index)
×
711

712
    def setEditorData(self, editor, index):
1✔
713
        value = index.model().data(index, QtCore.Qt.DisplayRole).value()
×
714
        if index.column() in (LOCATION,):
×
715
            i = editor.findText(value)
×
716
            if i == -1:
×
717
                i = 0
×
718
            editor.setCurrentIndex(i)
×
719
        else:
720
            editor.insert(str(value))
×
721

722
    def setModelData(self, editor, model, index):
1✔
723
        """
724
        For the LOCATION column: If the user selects a location from the
725
        combobox, get the corresponding coordinates.
726
        """
727
        if index.column() == LOCATION:
×
728
            loc = editor.currentText()
×
729
            locations = config_loader(dataset='locations')
×
730
            if loc in locations:
×
731
                lat, lon = locations[loc]
×
732
                # Don't update distances and flight performance twice, hence
733
                # set update=False for LAT.
734
                model.setData(index.sibling(index.row(), LAT), QtCore.QVariant(lat), update=False)
×
735
                model.setData(index.sibling(index.row(), LON), QtCore.QVariant(lon))
×
736
            else:
737
                for wp in self.parent().waypoints_model.all_waypoint_data():
×
738
                    if loc == wp.location:
×
739
                        lat, lon = wp.lat, wp.lon
×
740
                        # Don't update distances and flight performance twice, hence
741
                        # set update=False for LAT.
742
                        model.setData(index.sibling(index.row(), LAT), QtCore.QVariant(lat), update=False)
×
743
                        model.setData(index.sibling(index.row(), LON), QtCore.QVariant(lon))
×
744

745
            model.setData(index, QtCore.QVariant(editor.currentText()))
×
746
        else:
747
            QtWidgets.QItemDelegate.setModelData(self, editor, model, index)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc