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

Open-MSS / MSS / 13108882369

03 Feb 2025 08:01AM UTC coverage: 72.405% (-0.006%) from 72.411%
13108882369

Pull #2618

github

web-flow
Merge bc58067d8 into 06d860ee6
Pull Request #2618: fix: set pinning of numpy<2.0

13820 of 19087 relevant lines covered (72.41%)

0.72 hits per line

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

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

4
    mslib.msui.remotesensing_dockwidget
5
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6

7
    Control widget to configure remote sensing overlays.
8

9
    This file is part of MSS.
10

11
    :copyright: Copyright 2017 Joern Ungermann
12
    :copyright: Copyright 2017-2024 by the MSS team, see AUTHORS.
13
    :license: APACHE-2.0, see LICENSE for details.
14

15
    Licensed under the Apache License, Version 2.0 (the "License");
16
    you may not use this file except in compliance with the License.
17
    You may obtain a copy of the License at
18

19
       http://www.apache.org/licenses/LICENSE-2.0
20

21
    Unless required by applicable law or agreed to in writing, software
22
    distributed under the License is distributed on an "AS IS" BASIS,
23
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
24
    See the License for the specific language governing permissions and
25
    limitations under the License.
26
"""
27
import collections
1✔
28

29
from matplotlib.collections import LineCollection
1✔
30
from matplotlib.colors import BoundaryNorm, ListedColormap
1✔
31
import numpy as np
1✔
32
from skyfield.api import Loader, Topos, utc
1✔
33
import skyfield_data
1✔
34

35
from PyQt5 import QtGui, QtWidgets
1✔
36
from mslib.msui.qt5 import ui_remotesensing_dockwidget as ui
1✔
37
from mslib.utils.time import jsec_to_datetime, datetime_to_jsec
1✔
38
from mslib.utils.coordinate import get_distance, rotate_point, fix_angle, normalize_longitude
1✔
39

40

41
EARTH_RADIUS = 6371.
1✔
42

43

44
class RemoteSensingControlWidget(QtWidgets.QWidget, ui.Ui_RemoteSensingDockWidget):
1✔
45
    """This class implements the remote sensing functionality as dockable widget.
46
    """
47

48
    def __init__(self, parent=None, view=None):
1✔
49
        """
50
        Arguments:
51
        parent -- Qt widget that is parent to this widget.
52
        view -- reference to mpl canvas class
53
        """
54
        super().__init__(parent)
1✔
55
        self.setupUi(self)
1✔
56

57
        self.view = view
1✔
58
        self.load_bsp = Loader(skyfield_data.get_skyfield_data_path(), verbose=False)
1✔
59

60
        self.planets = self.load_bsp('de421.bsp')
1✔
61
        self.timescale = self.load_bsp.timescale(builtin=True)
1✔
62

63
        button = self.btTangentsColour
1✔
64
        palette = QtGui.QPalette(button.palette())
1✔
65
        colour = QtGui.QColor()
1✔
66
        colour.setRgbF(1, 0, 0, 1)
1✔
67
        palette.setColor(QtGui.QPalette.Button, colour)
1✔
68
        button.setPalette(palette)
1✔
69

70
        self.dsbTangentHeight.setValue(10.)
1✔
71
        self.dsbObsAngleAzimuth.setValue(90.)
1✔
72
        self.dsbObsAngleElevation.setValue(-1.0)
1✔
73

74
        # update plot on every value change
75
        self.cbDrawTangents.stateChanged.connect(self.update_settings)
1✔
76
        self.cbShowSolarAngle.stateChanged.connect(self.update_settings)
1✔
77
        self.btTangentsColour.clicked.connect(self.set_tangentpoint_colour)
1✔
78
        self.dsbTangentHeight.valueChanged.connect(self.update_settings)
1✔
79
        self.dsbObsAngleAzimuth.valueChanged.connect(self.update_settings)
1✔
80
        self.dsbObsAngleElevation.valueChanged.connect(self.update_settings)
1✔
81
        self.cbSolarBody.currentIndexChanged.connect(self.update_settings)
1✔
82
        self.cbSolarAngleType.currentIndexChanged.connect(self.update_settings)
1✔
83
        self.lbSolarCmap.setText(
1✔
84
            "Solar angle colours, dark to light: reds (0-15), violets (15-45), greens (45-180)")
85
        self.solar_cmap = ListedColormap([
1✔
86
            (1.00, 0.00, 0.00, 1.0),
87
            (1.00, 0.45, 0.00, 1.0),
88
            (1.00, 0.75, 0.00, 1.0),
89
            (0.47, 0.10, 1.00, 1.0),
90
            (0.72, 0.38, 1.00, 1.0),
91
            (1.00, 0.55, 1.00, 1.0),
92
            (0.00, 0.70, 0.00, 1.0),
93
            (0.33, 0.85, 0.33, 1.0),
94
            (0.65, 1.00, 0.65, 1.0)])
95
        self.solar_norm = BoundaryNorm(
1✔
96
            [0, 5, 10, 15, 25, 35, 45, 90, 135, 180], self.solar_cmap.N)
97

98
        self.update_settings()
1✔
99

100
    @staticmethod
1✔
101
    def compute_view_angles(lon0, lat0, h0, lon1, lat1, h1, obs_azi, obs_ele):
1✔
102
        mlat = ((lat0 + lat1) / 2.)
1✔
103
        lon0 *= np.cos(np.deg2rad(mlat))
1✔
104
        lon1 *= np.cos(np.deg2rad(mlat))
1✔
105
        dlon = lon1 - lon0
1✔
106
        dlat = lat1 - lat0
1✔
107
        obs_azi_p = fix_angle(obs_azi + np.rad2deg(np.arctan2(dlon, dlat)))
1✔
108
        return obs_azi_p, obs_ele
1✔
109

110
    def compute_body_angle(self, body, jsec, lon, lat):
1✔
111
        t = self.timescale.utc(jsec_to_datetime(jsec).replace(tzinfo=utc))
1✔
112
        loc = self.planets["earth"] + Topos(lat, lon)
1✔
113
        astrometric = loc.at(t).observe(self.planets[body])
1✔
114
        alt, az, d = astrometric.apparent().altaz()
1✔
115
        return az.degrees, alt.degrees
1✔
116

117
    def update_settings(self):
1✔
118
        """
119
        Updates settings in TopView and triggers a redraw.
120
        """
121
        settings = {
1✔
122
            "reference": self,
123
            "draw_tangents": self.cbDrawTangents.isChecked(),
124
        }
125
        if self.cbShowSolarAngle.isChecked():
1✔
126
            settings["show_solar_angle"] = self.cbSolarAngleType.currentText(), self.cbSolarBody.currentText()
1✔
127
        else:
128
            settings["show_solar_angle"] = None
1✔
129

130
        self.view.set_remote_sensing_appearance(settings)
1✔
131

132
    def set_tangentpoint_colour(self):
1✔
133
        """Slot for the colour buttons: Opens a QColorDialog and sets the
134
           new button face colour.
135
        """
136
        button = self.btTangentsColour
×
137
        palette = QtGui.QPalette(button.palette())
×
138
        colour = palette.color(QtGui.QPalette.Button)
×
139
        colour = QtWidgets.QColorDialog.getColor(colour)
×
140
        if colour.isValid():
×
141
            palette.setColor(QtGui.QPalette.Button, colour)
×
142
            button.setPalette(palette)
×
143
        self.update_settings()
×
144

145
    def compute_tangent_lines(self, bmap, wp_vertices, wp_heights):
1✔
146
        """
147
        Computes Tangent points of limb sounders aboard the aircraft
148

149
        Args:
150
            bmap: Projection of TopView
151
            wp_vertices: waypoints of the flight path
152
            wp_heights: altitude of the waypoints of flight path
153

154
        Returns: LineCollection of dotted lines at tangent point locations
155
        """
156
        x, y = list(zip(*wp_vertices))
1✔
157
        wp_lons, wp_lats = bmap(x, y, inverse=True)
1✔
158
        if bmap.projection == "cyl":  # hack for wraparound
1✔
159
            wp_lons = normalize_longitude(wp_lons, -180, 180)
1✔
160
        fine_lines = [bmap.gcpoints2(
1✔
161
                      wp_lons[i], wp_lats[i], wp_lons[i + 1], wp_lats[i + 1], del_s=10., map_coords=False)
162
                      for i in range(len(wp_lons) - 1)]
163
        line_heights = [np.linspace(wp_heights[i], wp_heights[i + 1], num=len(fine_lines[i][0]))
1✔
164
                        for i in range(len(fine_lines))]
165
        # fine_lines = list of tuples with x-list and y-list for each segment
166
        tp_lines = [self.tangent_point_coordinates(
1✔
167
            _fine_line[0], _fine_line[1], _line_height,
168
            cut_height=self.dsbTangentHeight.value())
169
            for _fine_line, _line_height in zip(fine_lines, line_heights)]
170
        dir_lines = self.direction_coordinates(fine_lines)
1✔
171
        lines = tp_lines + dir_lines
1✔
172
        for i, line in enumerate(lines):
1✔
173
            line = np.asarray(line)
1✔
174
            if bmap.projection == "cyl":  # hack for wraparound
1✔
175
                line[:, 0], line[:, 1] = bmap(
1✔
176
                    normalize_longitude(line[:, 0], bmap.llcrnrlon, bmap.urcrnrlon),
177
                    line[:, 1])
178
            else:
179
                line[:, 0], line[:, 1] = bmap(line[:, 0], line[:, 1])
×
180
            lines[i] = line
1✔
181
        return LineCollection(
1✔
182
            lines,
183
            colors=QtGui.QPalette(self.btTangentsColour.palette()).color(QtGui.QPalette.Button).getRgbF(),
184
            zorder=2, animated=True, linewidth=3, linestyles=[':'] * len(tp_lines) + ['-'] * len(dir_lines))
185

186
    def compute_solar_lines(self, bmap, wp_vertices, wp_heights, wp_times, solartype):
1✔
187
        """
188
        Computes coloured overlay over the flight path that indicates
189
        the danger of looking into the sun with a limb sounder aboard
190
        the aircraft.
191

192
        Args:
193
            bmap: Projection of TopView
194
            wp_vertices: waypoints of the flight path
195
            wp_heights: altitude of the waypoints of flight path
196

197
        Returns: LineCollection of coloured lines according to the
198
                 angular distance between viewing direction and solar
199
                 angle
200
        """
201
        # calculate distances and times
202
        body, difftype = solartype
1✔
203

204
        times = [datetime_to_jsec(_wp_time) for _wp_time in wp_times]
1✔
205
        x, y = list(zip(*wp_vertices))
1✔
206
        wp_lons, wp_lats = bmap(x, y, inverse=True)
1✔
207
        if bmap.projection == "cyl":  # hack for wraparound
1✔
208
            wp_lons = normalize_longitude(wp_lons, bmap.llcrnrlon, bmap.urcrnrlon)
1✔
209
        fine_lines = [bmap.gcpoints2(wp_lons[i], wp_lats[i], wp_lons[i + 1], wp_lats[i + 1], map_coords=False) for i in
1✔
210
                      range(len(wp_lons) - 1)]
211
        line_heights = [np.linspace(wp_heights[i], wp_heights[i + 1], num=len(fine_lines[i][0])) for i in
1✔
212
                        range(len(fine_lines))]
213
        line_times = [np.linspace(times[i], times[i + 1], num=len(fine_lines[i][0])) for i in
1✔
214
                      range(len(fine_lines))]
215
        # fine_lines = list of tuples with x-list and y-list for each segment
216
        # lines = list of tuples with lon-list and lat-list for each segment
217
        heights = []
1✔
218
        times = []
1✔
219
        for i in range(len(fine_lines) - 1):
1✔
220
            heights.extend(line_heights[i][:-1])
1✔
221
            times.extend(line_times[i][:-1])
1✔
222
        heights.extend(line_heights[-1])
1✔
223
        times.extend(line_times[-1])
1✔
224
        solar_x = []
1✔
225
        solar_y = []
1✔
226
        for i in range(len(fine_lines) - 1):
1✔
227
            solar_x.extend(fine_lines[i][0][:-1])
1✔
228
            solar_y.extend(fine_lines[i][1][:-1])
1✔
229
        solar_x.extend(fine_lines[-1][0])
1✔
230
        solar_y.extend(fine_lines[-1][1])
1✔
231
        if bmap.projection == "cyl":  # hack for wraparound
1✔
232
            solar_x = normalize_longitude(solar_x, bmap.llcrnrlon, bmap.urcrnrlon)
1✔
233
        points = []
1✔
234
        old_wp = None
1✔
235
        total_distance = 0
1✔
236
        for i, (lon, lat) in enumerate(zip(solar_x, solar_y)):
1✔
237
            points.append([[lon, lat]])  # append double-list for later concatenation
1✔
238
            if old_wp is not None:
1✔
239
                wp_dist = get_distance(old_wp[0], old_wp[1], lat, lon) * 1000.
1✔
240
                total_distance += wp_dist
1✔
241
            old_wp = (lat, lon)
1✔
242
        vals = []
1✔
243
        for i in range(len(points) - 1):
1✔
244
            p0, p1 = points[i][0], points[i + 1][0]
1✔
245

246
            sol_azi, sol_ele = self.compute_body_angle(body, times[i], p0[0], p0[1])
1✔
247
            obs_azi, obs_ele = self.compute_view_angles(
1✔
248
                p0[0], p0[1], heights[i], p1[0], p1[1], heights[i + 1],
249
                self.dsbObsAngleAzimuth.value(), self.dsbObsAngleElevation.value())
250
            if sol_azi < 0:
1✔
251
                sol_azi += 360
×
252
            if obs_azi < 0:
1✔
253
                obs_azi += 360
×
254
            rating = self.calc_view_rating(obs_azi, obs_ele, sol_azi, sol_ele, heights[i], difftype)
1✔
255
            vals.append(rating)
1✔
256

257
        # convert lon, lat to map points
258
        for i in range(len(points)):
1✔
259
            points[i][0][0], points[i][0][1] = bmap(points[i][0][0], points[i][0][1])
1✔
260
        points = np.concatenate([points[:-1], points[1:]], axis=1)
1✔
261
        # plot
262
        solar_lines = LineCollection(points, cmap=self.solar_cmap, norm=self.solar_norm,
1✔
263
                                     zorder=2, linewidths=3, animated=True)
264
        solar_lines.set_array(np.array(vals))
1✔
265
        return solar_lines
1✔
266

267
    def tangent_point_coordinates(self, lon_lin, lat_lin, flight_alt=14, cut_height=12):
1✔
268
        """
269
        Computes coordinates of tangent points given coordinates of flight path.
270

271
        Args:
272
            lon_lin: longitudes of flight path
273
            lat_lin: latitudes of flight path
274
            flight_alt: altitude of aircraft (scalar or numpy array)
275
            cut_height: altitude of tangent points
276

277
        Returns: List of tuples of longitude/latitude coordinates
278

279
        """
280
        med_lon = np.median(lon_lin)
1✔
281
        lon_lin = normalize_longitude(lon_lin, med_lon - 180, med_lon + 180)
1✔
282
        lines = list(zip(lon_lin[0:-1], lon_lin[1:], lat_lin[0:-1], lat_lin[1:]))
1✔
283
        lines = [(x0 * np.cos(np.deg2rad(np.mean([y0, y1]))), x1 * np.cos(np.deg2rad(np.mean([y0, y1]))), y0, y1)
1✔
284
                 for x0, x1, y0, y1 in lines]
285

286
        direction = [(x1 - x0, y1 - y0) for x0, x1, y0, y1 in lines]
1✔
287
        direction = [(_x / np.hypot(_x, _y), _y / np.hypot(_x, _y))
1✔
288
                     for _x, _y in direction]
289
        los = [rotate_point(point, -self.dsbObsAngleAzimuth.value()) for point in direction]
1✔
290
        los.append(los[-1])
1✔
291

292
        if isinstance(flight_alt, (collections.abc.Sequence, np.ndarray)):
1✔
293
            dist = [(np.sqrt(max((EARTH_RADIUS + a) ** 2 - (EARTH_RADIUS + cut_height) ** 2, 0)) / 110.)
1✔
294
                    for a in flight_alt[:-1]]
295
            dist.append(dist[-1])
1✔
296
        else:
297
            dist = (np.sqrt((EARTH_RADIUS + flight_alt) ** 2 - (EARTH_RADIUS + cut_height) ** 2) / 110.)
1✔
298

299
        tp_dir = (np.array(los).T * dist).T
1✔
300

301
        tps = [(x0 + tp_x, y0 + tp_y, y0) for
1✔
302
               ((x0, x1, y0, y1), (tp_x, tp_y)) in zip(lines, tp_dir)]
303
        tps = [(x0 / np.cos(np.deg2rad(yp)), y0) for (x0, y0, yp) in tps]
1✔
304
        return tps
1✔
305

306
    def direction_coordinates(self, gc_lines):
1✔
307
        """
308
        Computes coordinates of tangent points given coordinates of flight path.
309

310
        Args:
311
            lon_lin: longitudes of flight path
312
            lat_lin: latitudes of flight path
313
            flight_alt: altitude of aircraft (scalar or numpy array)
314
            cut_height: altitude of tangent points
315

316
        Returns: List of tuples of longitude/latitude coordinates
317

318
        """
319
        lines = [(_line[0][mid], _line[0][mid + 1], _line[1][mid], _line[1][mid + 1])
1✔
320
                 for _line, mid in zip(gc_lines, [len(_line[0]) // 2 for _line in gc_lines])
321
                 if len(_line[0]) > 2]
322
        lens = [np.hypot(_line[0][0] - _line[0][-1], _line[0][0] - _line[0][-1]) * 110.
1✔
323
                for _line in gc_lines
324
                if len(_line[0]) > 2]
325
        lines = [(x0 * np.cos(np.deg2rad(np.mean([y0, y1]))), x1 * np.cos(np.deg2rad(np.mean([y0, y1]))), y0, y1)
1✔
326
                 for x0, x1, y0, y1 in lines]
327
        lines = [_x for _x, _l in zip(lines, lens) if _l > 10]
1✔
328

329
        direction = [(0.5 * (x0 + x1), 0.5 * (y0 + y1), x1 - x0, y1 - y0) for x0, x1, y0, y1 in lines]
1✔
330
        direction = [(_u, _v, _x / np.hypot(_x, _y), _y / np.hypot(_x, _y))
1✔
331
                     for _u, _v, _x, _y in direction]
332
        los = [rotate_point(point[2:], -self.dsbObsAngleAzimuth.value()) for point in direction]
1✔
333

334
        dist = 1.
1✔
335
        tp_dir = (np.array(los).T * dist).T
1✔
336

337
        tps = [(x0, y0, x0 + tp_x, y0 + tp_y) for
1✔
338
               ((x0, y0, _, _), (tp_x, tp_y)) in zip(direction, tp_dir)]
339
        tps = [[(x0 / np.cos(np.deg2rad(y0)), y0), (x1 / np.cos(np.deg2rad(y0)), y1)] for (x0, y0, x1, y1) in tps]
1✔
340
        return tps
1✔
341

342
    @staticmethod
1✔
343
    def calc_view_rating(obs_azi, obs_ele, sol_azi, sol_ele, height, difftype):
1✔
344
        """
345
        Calculates the angular distance between given directions under the
346
        condition that the sun is above the horizon.
347

348
        Args:
349
            obs_azi: observator azimuth angle
350
            obs_ele: observator elevation angle
351
            sol_azi: solar azimuth angle
352
            sol_ele: solar elevation angle
353
            height: altitude of observer
354

355
        Returns: angular distance or 180 degrees if sun is below horizon
356
        """
357
        delta_azi = obs_azi - sol_azi
1✔
358
        delta_ele = obs_ele - sol_ele
1✔
359
        if "horizon" in difftype:
1✔
360
            thresh = -np.rad2deg(np.arccos(EARTH_RADIUS / (height + EARTH_RADIUS))) - 3
1✔
361
            if sol_ele < thresh:
1✔
362
                delta_ele = 180
×
363

364
        if "azimuth" == difftype:
1✔
365
            return np.abs(obs_azi - sol_azi)
×
366
        elif "elevation" == difftype:
1✔
367
            return np.abs(obs_ele - sol_ele)
×
368
        else:
369
            return np.hypot(delta_azi, delta_ele)
1✔
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

© 2026 Coveralls, Inc