diff --git a/ST16.ftml b/ST16.ftml new file mode 100644 index 000000000..3b6accdc1 --- /dev/null +++ b/ST16.ftml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/components.rst b/docs/components.rst index a470ca807..2fea63491 100644 --- a/docs/components.rst +++ b/docs/components.rst @@ -9,5 +9,6 @@ Components mswms mscolab gentutorials + mssautoplot diff --git a/docs/mssautoplot.rst b/docs/mssautoplot.rst new file mode 100644 index 000000000..145e7dcb2 --- /dev/null +++ b/docs/mssautoplot.rst @@ -0,0 +1,81 @@ +mssautoplot - A CLI tool for automation +======================================= + +A CLI tool which enables users to download a set of plots according to the given configuration. The configuration for downloading the plots is located in "mssautoplot.json". In this file you can specify the default settings. + +How to use +---------- + +The CLI tool has the following parameters: + ++--------------+-------+----------------------------------------------------------------------+ +| Parameter | Type | Description | ++--------------+-------+----------------------------------------------------------------------+ +| ``--cpath`` | TEXT | Path of the config file where the configuration is specified. | ++--------------+-------+----------------------------------------------------------------------+ +| ``--ftrack`` | TEXT | Flight track. | ++--------------+-------+----------------------------------------------------------------------+ +| ``--itime`` | TEXT | Initial time. | ++--------------+-------+----------------------------------------------------------------------+ +| ``--vtime`` | TEXT | Valid time. | ++--------------+-------+----------------------------------------------------------------------+ +| ``--intv`` |INTEGER| Time interval in hours. | ++--------------+-------+----------------------------------------------------------------------+ +| ``--stime`` | TEXT | Starting time for downloading multiple plots with a fixed interval.| ++--------------+-------+----------------------------------------------------------------------+ +| ``--etime`` | TEXT | Ending time for downloading multiple plots with a fixed interval. | ++--------------+-------+----------------------------------------------------------------------+ + +A short description of how to start the program is given by the ``--help`` option. + +Examples +~~~~~~~~ + +Here are a few examples on how to use this tool, + +1. ``mssautoplot --cpath mssautoplot.json`` + +The above command downloads the required number of plots with the default settings from mss_autoplot.json into the output folder. + +2. ``mssautoplot --cpath mssautoplot.json --itime="" --vtime="2019-09-02T00:00:00"`` + +The above command will download all the plots configured in mss_autoplot.json of initial time "" and valid time "2019-09-02T00:00:00". This can be used to create a daily "standard set" of plots with/without an actual flight track, e.g., for daily morning briefings + +For downloading plots of multiple flight tracks, specify the flight track and it's configuration in mss_autoplot.json. This can be used to create a "standard set" of plots for all actual flights of a campaign. See below: + +"automated_plotting_flights": [ + ["flight1", "section1", "vertical1", "filename1", "init_time1", "time1"] + ["flight2", "section2, "vertical2", "filename2", "init_time2", "time2"]] + +3. ``mssautoplot --cpath mssautoplot.json --stime="2019-09-01T00:00:00" --etime="2019-09-02T00:00:00" --intv=6`` + +The above command will download plots of the with/without flight track from start time "2019-09-01T00:00:00" to end time "2019-09-02T00:00:00". The user would need to compulsorily specify the init_time and time in mss_autoplot.json inorder to use this functionality. + + +Settings file +-------------- + +This file includes configuration settings to generate plots in automated fashion. It includes, + + - View + - URL of the WMS server + - URL of mscolab server + - List of layers to be plotted + - Style + - Elevation + - List of initial time and forecast hours + - List of flight paths + - List of operations (from MSCOLAB) + - Level + - Map section + - Resolution of the plot + - Path of output folder + + +If you don't have a mss_autoplot.json then default configuration is in place. + +Store this mss_autoplot.json in a path, e.g. “$HOME/.config/msui” + +**/$HOME/.config/msui/mssautoplot.json** + +.. literalinclude:: samples/config/msui/mssautoplot.json.sample \ No newline at end of file diff --git a/docs/samples/config/msui/mssautoplot.json.sample b/docs/samples/config/msui/mssautoplot.json.sample new file mode 100644 index 000000000..421c71c31 --- /dev/null +++ b/docs/samples/config/msui/mssautoplot.json.sample @@ -0,0 +1,96 @@ + +{ + "view":"topview" + "data_dir": "~/mssdata", + "filepicker_default": "default", + + "import_plugins": { + "FliteStar": ["fls", "mslib.plugins.io.flitestar", "load_from_flitestar"], + "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"] + }, + + "export_plugins": { + "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], + "KML": ["kml", "mslib.plugins.io.kml", "save_to_kml"], + "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] + }, + + + "layout": { + "topview": [963, 702], + "sideview": [913, 557], + "tableview": [1236, 424], + "immutable": false + }, + + "locations": { + "EDMO": [48.08, 11.28], + "Hannover": [52.37, 9.74], + "Hamburg": [53.55, 9.99], + "Juelich": [50.92, 6.36], + "Leipzig": [51.34, 12.37], + "Muenchen": [48.14, 11.57], + "Stuttgart": [48.78, 9.18], + "Wien": [48.20833, 16.373064], + "Zugspitze": [47.42, 10.98], + "Kiruna": [67.821, 20.336], + "Ny-Alesund": [78.928, 11.986] + }, + + "predefined_map_sections": { + "01 Europe (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": -15.0, "llcrnrlat": 35.0, + "urcrnrlon": 30.0, "urcrnrlat": 65.0}}, + "02 Germany (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": 5.0, "llcrnrlat": 45.0, + "urcrnrlon": 15.0, "urcrnrlat": 57.0}}, + "03 Global (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": -180.0, "llcrnrlat": -90.0, + "urcrnrlon": 180.0, "urcrnrlat": 90.0}}, + "04 Shannon (stereo)": {"CRS": "EPSG:77752350", + "map": {"llcrnrlon": -45.0, "llcrnrlat": 22.0, + "urcrnrlon": 45.0, "urcrnrlat": 63.0}}, + "05 Northern Hemisphere (stereo)": {"CRS": "EPSG:77790000", + "map": {"llcrnrlon": -45.0, "llcrnrlat": 0.0, + "urcrnrlon": 135.0, "urcrnrlat": 0.0}}, + "06 Southern Hemisphere (stereo)": {"CRS": "EPSG:77890000", + "map": {"llcrnrlon": 45.0, "llcrnrlat": 0.0, + "urcrnrlon": -135.0, "urcrnrlat": 0.0}} + }, + + "new_flighttrack_template": ["Kiruna", "Ny-Alesund"], + "new_flighttrack_flightlevel": 250, + "num_interpolation_points": 201, + "num_labels": 10, + + "WMS_request_timeout": 30, + + "default_WMS": ["http://www.your-server.de/forecasts"], + "default_VSEC_WMS": ["http://www.your-server.de/forecasts"], + "default_LSEC_WMS": ["http://www.your-server.de/forecasts"], + + "default_MSCOLAB": ["http://www.your-mscolab-server.de/"], + "WMS_login": { + "http://www.your-server.de/forecasts" : ["youruser", "yourpassword"] + }, + "MSC_login": { + "http://www.your-mscolab-server.de" : ["youruser", "yourpassword"] + }, + + "automated_plotting_flights": [ + ["", "", "", "", "", ""] + ], + "automated_plotting_hsecs": [ + ["", "", "", ""] + ], + "automated_plotting_vsecs": [ + ["","", "", ""] + ], + "automated_plotting_lsecs": [ + ["", "", ""] + ] + + "MSCOLAB_mailid": "", + "MSCOLAB_password": "" + "MSCOLAB_operations":[] +} diff --git a/mslib/msui/constants.py b/mslib/msui/constants.py index be82bd6d8..b2715dd1e 100644 --- a/mslib/msui/constants.py +++ b/mslib/msui/constants.py @@ -69,6 +69,28 @@ except IOError: logging.error(f'"{MSUI_SETTINGS}" can''t be created') +# ToDo refactor to a function +MSS_AUTOPLOT = os.getenv('MSS_AUTOPLOT', os.path.join(MSUI_CONFIG_PATH, "mssautoplot.json")) + +# We try to create an empty MSUI_SETTINGS file if not existing +# but there can be a permission problem +if '://' in MSS_AUTOPLOT: + dir_path, file_name = fs.path.split(MSS_AUTOPLOT) + try: + _fs = fs.open_fs(dir_path) + if not _fs.exists(file_name): + with _fs.open(file_name, 'w') as fid: + fid.write("{}") + except fs.errors.CreateFailed: + logging.error(f'"{MSS_AUTOPLOT}" can''t be created') +else: + if not os.path.exists(MSS_AUTOPLOT): + try: + with open(MSS_AUTOPLOT, 'w') as fid: + fid.write("{}") + except IOError: + logging.error(f'"{MSS_AUTOPLOT}" can''t be created') + WMS_LOGIN_CACHE = {} MSC_LOGIN_CACHE = {} diff --git a/mslib/msui/mpl_pathinteractor.py b/mslib/msui/mpl_pathinteractor.py index 75faf841e..a8e8a4f32 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -51,7 +51,7 @@ import matplotlib.patches as mpatches from PyQt5 import QtCore, QtWidgets -from mslib.utils.coordinate import get_distance, find_location, latlon_points, normalize_longitude +from mslib.utils.coordinate import get_distance, find_location, latlon_points, path_points from mslib.utils.units import units from mslib.utils.thermolib import pressure2flightlevel from mslib.msui import flighttrack as ft @@ -157,11 +157,10 @@ def transform_waypoint(self, wps_list, index): """ return (0, 0) - def update_from_WaypointsTableModel(self, wps_model): + def update_from_waypoints(self, wps): """ """ Path = mpath.Path - wps = wps_model.all_waypoint_data() pathdata = [] # on a expired mscolab server wps is an empty list if len(wps) > 0: @@ -193,7 +192,7 @@ def __init__(self, *args, **kwargs): self.numintpoints = kwargs.pop("numintpoints") super().__init__(*args, **kwargs) - def update_from_WaypointsTableModel(self, wps_model): + def update_from_waypoints(self, wps): """Extended version of the corresponding WaypointsPath method. The idea is to generate a field 'intermediate_indexes' that stores @@ -210,13 +209,16 @@ def update_from_WaypointsTableModel(self, wps_model): waypoints makes no sense). """ # Compute intermediate points. - lats, lons, times = wps_model.intermediate_points( + lats, lons, times = path_points( + [wp.lat for wp in wps], + [wp.lon for wp in wps], + times=[wp.utc_time for wp in wps], numpoints=self.numintpoints, connection="greatcircle") if lats is not None: # Determine indices of waypoints in list of intermediate points. # Store these indices. - waypoints = [[wp.lat, wp.lon] for wp in wps_model.all_waypoint_data()] + waypoints = [[wp.lat, wp.lon] for wp in wps] intermediate_indexes = [] ipoint = 0 for i, (lat, lon) in enumerate(zip(lats, lons)): @@ -232,7 +234,7 @@ def update_from_WaypointsTableModel(self, wps_model): self.itimes = times # Call super method. - super().update_from_WaypointsTableModel(wps_model) + super().update_from_waypoints(wps) def transform_waypoint(self, wps_list, index): """Returns the x-index of the waypoint and its pressure. @@ -278,14 +280,13 @@ def __init__(self, *args, **kwargs): self.wp_codes = np.array([], dtype=np.uint8) self.wp_vertices = np.array([]) - def update_from_WaypointsTableModel(self, wps_model): + def update_from_waypoints(self, wps): """Get waypoint coordinates from flight track model, get intermediate great circle vertices from map instance. """ Path = mpath.Path # Waypoint coordinates. - wps = wps_model.all_waypoint_data() if len(wps) > 0: pathdata = [(Path.MOVETO, self.transform_waypoint(wps, 0))] for i in range(len(wps[1:])): @@ -341,7 +342,7 @@ def index_of_closest_segment(self, x, y, eps=5): # -class PathInteractor(QtCore.QObject): +class PathPlotter(object): """An interactive matplotlib path editor. Allows vertices of a path patch to be interactively picked and moved around. @@ -350,11 +351,10 @@ class PathInteractor(QtCore.QObject): """ showverts = True # show the vertices of the path patch - epsilon = 12 # picking points - def __init__(self, ax=None, waypoints=None, mplpath=None, + def __init__(self, ax, mplpath=None, facecolor='blue', edgecolor='yellow', linecolor='blue', markerfacecolor='red', marker='o', label_waypoints=True): @@ -373,10 +373,8 @@ def __init__(self, ax=None, waypoints=None, mplpath=None, matplotlib plot() or scatter() routines for more information. label_waypoints -- put labels with the waypoint numbers on the waypoints. """ - QtCore.QObject.__init__(self) self.waypoints_model = None self.background = None - self._ind = None # the active vertex # Create a PathPatch representing the interactively editable path # (vertical profile or horizontal flight track in subclasses). @@ -407,48 +405,6 @@ def __init__(self, ax=None, waypoints=None, mplpath=None, canvas.mpl_connect('draw_event', self.draw_callback) self.canvas = canvas - # Set the waypoints model, connect to the change() signals of the model - # and redraw the figure. - self.set_waypoints_model(waypoints) - - def set_waypoints_model(self, waypoints): - """Change the underlying waypoints data structure. Disconnect change() - signals of an already existing model and connect to the new model. - Redraw the map. - """ - # 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) - # 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) - # Redraw. - self.pathpatch.get_path().update_from_WaypointsTableModel(wpm) - 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 - data changes induced by another view (table, side view). - """ - self.pathpatch.get_path().update_from_WaypointsTableModel(self.waypoints_model) - self.redraw_figure() - - def qt_data_changed_listener(self, index1, index2): - """Listens to dataChanged() signals emitted by the flight track - data model. The view can thus react to data changes induced - by another view (table, top view). - """ - # REIMPLEMENT IN SUBCLASSES. - pass - def draw_callback(self, event): """Called when the figure is redrawn. Stores background data (for later restoration) and draws artists. @@ -472,15 +428,512 @@ def draw_callback(self, event): # (see infos on http://www.scipy.org/Cookbook/Matplotlib/Animations). # self.canvas.blit(self.ax.bbox) + def set_vertices_visible(self, showverts=True): + """Set the visibility of path vertices (the line plot). + """ + self.showverts = showverts + self.line.set_visible(self.showverts) + for t in self.wp_labels: + t.set_visible(showverts and self.label_waypoints) + if not self.showverts: + self._ind = None + self.canvas.draw() + + def set_patch_visible(self, showpatch=True): + """Set the visibility of path patch (the area). + """ + self.pathpatch.set_visible(showpatch) + self.canvas.draw() + + def set_labels_visible(self, visible=True): + """Set the visibility of the waypoint labels. + """ + self.label_waypoints = visible + for t in self.wp_labels: + t.set_visible(self.showverts and self.label_waypoints) + self.canvas.draw() + + def redraw_path(self, vertices=None, waypoints_model_data=[]): + """Redraw the matplotlib artists that represent the flight track + (path patch and line). + + If vertices are specified, they will be applied to the graphics + output. Otherwise the vertex array obtained from the path patch + will be used. + """ + if vertices is None: + vertices = self.pathpatch.get_path().vertices + self.line.set_data(list(zip(*vertices))) + # Draw waypoint labels. + for wp in self.wp_labels: + wp.remove() + self.wp_labels = [] # remove doesn't seem to be necessary + x, y = list(zip(*vertices)) + for i, wpd, in enumerate(waypoints_model_data): + textlabel = f"{str(i):} " + if wpd.location != "": + textlabel = f"{wpd.location:} " + print("LABEL", i, wpd, x[i], y[i], textlabel) + text = self.ax.text( + x[i], y[i], + textlabel, + bbox=dict(boxstyle="round", + facecolor="white", + alpha=0.5, + edgecolor="none"), + fontweight="bold", + zorder=4, + rotation=90, + animated=True, + clip_on=True, + visible=self.showverts and self.label_waypoints) + self.wp_labels.append(text) + + if self.background: + self.canvas.restore_region(self.background) + try: + self.ax.draw_artist(self.pathpatch) + except ValueError as error: + logging.error("ValueError Exception %s", error) + self.ax.draw_artist(self.line) + for t in self.wp_labels: + self.ax.draw_artist(t) + self.canvas.blit(self.ax.bbox) + + redraw_figure = redraw_path + + def set_path_color(self, line_color=None, marker_facecolor=None, + patch_facecolor=None): + """Set the color of the path patch elements. + Arguments (options): + line_color -- color of the path line + marker_facecolor -- color of the waypoints + patch_facecolor -- color of the patch covering the path area + """ + if line_color is not None: + self.line.set_color(line_color) + if marker_facecolor is not None: + self.line.set_markerfacecolor(marker_facecolor) + if patch_facecolor is not None: + self.pathpatch.set_facecolor(patch_facecolor) + + def update_from_waypoints(self, wps): + self.pathpatch.get_path().update_from_waypoints(wps) + + +class PathH_GCPlotter(PathPlotter): + def __init__(self, mplmap, mplpath=None, facecolor='none', edgecolor='none', + linecolor='blue', markerfacecolor='red', show_marker=True, + marker='', label_waypoints=True): + super().__init__(mplmap.ax, mplpath=PathH_GC([[0, 0]], map=mplmap), + facecolor='none', edgecolor='none', linecolor=linecolor, + markerfacecolor=markerfacecolor, marker='', + label_waypoints=label_waypoints) + self.map = mplmap + self.wp_scatter = None + self.markerfacecolor = markerfacecolor + self.tangent_lines = None + self.show_tangent_points = False + self.solar_lines = None + self.show_marker = show_marker + self.show_solar_angle = None + self.remote_sensing = None + + def appropriate_epsilon(self, px=5): + """Determine an epsilon value appropriate for the current projection and + figure size. + The epsilon value gives the distance required in map projection + coordinates that corresponds to approximately px Pixels in screen + coordinates. The value can be used to find the line/point that is + closest to a click while discarding clicks that are too far away + from any geometry feature. + """ + # (bounds = left, bottom, width, height) + ax_bounds = self.ax.bbox.bounds + width = int(round(ax_bounds[2])) + map_delta_x = np.hypot(self.map.llcrnry - self.map.urcrnry, self.map.llcrnrx - self.map.urcrnrx) + map_coords_per_px_x = map_delta_x / width + return map_coords_per_px_x * px + + def redraw_path(self, wp_vertices=None, waypoints_model_data=[]): + """Redraw the matplotlib artists that represent the flight track + (path patch, line and waypoint scatter). + If waypoint vertices are specified, they will be applied to the + graphics output. Otherwise the vertex array obtained from the path + patch will be used. + """ + if wp_vertices is None: + wp_vertices = self.pathpatch.get_path().wp_vertices + if len(wp_vertices) == 0: + raise IOError("mscolab session expired") + vertices = self.pathpatch.get_path().vertices + else: + # If waypoints have been provided, compute the intermediate + # great circle points for the line instance. + x, y = list(zip(*wp_vertices)) + lons, lats = self.map(x, y, inverse=True) + x, y = self.map.gcpoints_path(lons, lats) + vertices = list(zip(x, y)) + + # Set the line to disply great circle points, remove existing + # waypoints scatter instance and draw a new one. This is + # necessary as scatter() does not provide a set_data method. + self.line.set_data(list(zip(*vertices))) + + if self.tangent_lines is not None: + self.tangent_lines.remove() + self.tangent_lines = None + if self.solar_lines is not None: + self.solar_lines.remove() + self.solar_lines = None + + if len(waypoints_model_data) > 0: + wp_heights = [(wpd.flightlevel * 0.03048) for wpd in waypoints_model_data] + wp_times = [wpd.utc_time for wpd in waypoints_model_data] + + if self.show_tangent_points: + assert self.remote_sensing is not None + self.tangent_lines = self.remote_sensing.compute_tangent_lines( + self.map, wp_vertices, wp_heights) + self.ax.add_collection(self.tangent_lines) + + if self.show_solar_angle is not None: + assert self.remote_sensing is not None + self.solar_lines = self.remote_sensing.compute_solar_lines( + self.map, wp_vertices, wp_heights, wp_times, self.show_solar_angle) + self.ax.add_collection(self.solar_lines) + + if self.wp_scatter is not None: + self.wp_scatter.remove() + self.wp_scatter = None + + x, y = list(zip(*wp_vertices)) + + if self.map.projection == "cyl": # hack for wraparound + x = np.array(x) + x[x < self.map.llcrnrlon] += 360 + x[x > self.map.urcrnrlon] -= 360 + # (animated is important to remove the old scatter points from the map) + self.wp_scatter = self.ax.scatter( + x, y, color=self.markerfacecolor, s=20, zorder=3, animated=True, visible=self.show_marker) + self.wp_scatter = None + + # Draw waypoint labels. + label_offset = self.appropriate_epsilon(px=5) + for wp_label in self.wp_labels: + wp_label.remove() + self.wp_labels = [] # remove doesn't seem to be necessary + for i, wpd in enumerate(waypoints_model_data): + textlabel = str(i) + if wpd.location != "": + textlabel = f"{wpd.location}" + label_offset = 0 + text = self.ax.text( + x[i] + label_offset, y[i] + label_offset, textlabel, + bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.6, "edgecolor": "none"}, + fontweight="bold", zorder=4, animated=True, clip_on=True, + visible=self.showverts and self.label_waypoints) + self.wp_labels.append(text) + + # Redraw the artists. + if self.background: + self.canvas.restore_region(self.background) + try: + self.ax.draw_artist(self.pathpatch) + except ValueError as error: + logging.debug("ValueError Exception '%s'", error) + self.ax.draw_artist(self.line) + if self.wp_scatter is not None: + self.ax.draw_artist(self.wp_scatter) + + for t in self.wp_labels: + self.ax.draw_artist(t) + if self.show_tangent_points: + self.ax.draw_artist(self.tangent_lines) + if self.show_solar_angle is not None: + self.ax.draw_artist(self.solar_lines) + self.canvas.blit(self.ax.bbox) + + + def draw_callback(self, event): + """Extends PathInteractor.draw_callback() by drawing the scatter + instance. + """ + super().draw_callback(event) + if self.wp_scatter: + self.ax.draw_artist(self.wp_scatter) + if self.show_solar_angle: + self.ax.draw_artist(self.solar_lines) + if self.show_tangent_points: + self.ax.draw_artist(self.tangent_lines) + + def set_path_color(self, line_color=None, marker_facecolor=None, + patch_facecolor=None): + """Set the color of the path patch elements. + Arguments (options): + line_color -- color of the path line + marker_facecolor -- color of the waypoints + patch_facecolor -- color of the patch covering the path area + """ + super().set_path_color(line_color, marker_facecolor, + patch_facecolor) + if marker_facecolor is not None and self.wp_scatter is not None: + self.wp_scatter.set_facecolor(marker_facecolor) + self.wp_scatter.set_edgecolor(marker_facecolor) + self.markerfacecolor = marker_facecolor + + def set_vertices_visible(self, showverts=True): + """Set the visibility of path vertices (the line plot). + """ + super().set_vertices_visible(showverts) + if self.wp_scatter is not None: + self.wp_scatter.set_visible(self.show_marker) + + def set_tangent_visible(self, visible): + self.show_tangent_points = visible + + def set_solar_angle_visible(self, visible): + self.show_solar_angle = visible + + def set_remote_sensing(self, ref): + self.remote_sensing = ref + + +class PathV_Plotter(PathPlotter): + def __init__(self, ax, redraw_xaxis=None, clear_figure=None, numintpoints=101): + """Constructor passes a PathV instance its parent. + + Arguments: + ax -- matplotlib.Axes object into which the path should be drawn. + waypoints -- flighttrack.WaypointsModel instance. + numintpoints -- number of intermediate interpolation points. The entire + flight track will be interpolated to this number of + points. + redrawXAxis -- callback function to redraw the x-axis on path changes. + """ + super().__init__( + ax=ax, mplpath=PathV([[0, 0]], numintpoints=numintpoints)) + self.numintpoints = numintpoints + self.redraw_xaxis = redraw_xaxis + self.clear_figure = clear_figure + + def get_num_interpolation_points(self): + return self.numintpoints + + def redraw_path(self, vertices=None, waypoints_model_data=[]): + """Redraw the matplotlib artists that represent the flight track + (path patch and line). + + If vertices are specified, they will be applied to the graphics + output. Otherwise the vertex array obtained from the path patch + will be used. + """ + if vertices is None: + vertices = self.pathpatch.get_path().vertices + self.line.set_data(list(zip(*vertices))) + x, y = list(zip(*vertices)) + # Draw waypoint labels. + for wp in self.wp_labels: + wp.remove() + self.wp_labels = [] # remove doesn't seem to be necessary + for i, wpd, in enumerate(waypoints_model_data): + textlabel = f"{str(i):} " + if wpd.location != "": + textlabel = f"{wpd.location:} " + print("LABEL", i, wpd, x[i], y[i], textlabel) + text = self.ax.text( + x[i], y[i], + textlabel, + bbox=dict(boxstyle="round", + facecolor="white", + alpha=0.5, + edgecolor="none"), + fontweight="bold", + zorder=4, + rotation=90, + animated=True, + clip_on=True, + visible=self.showverts and self.label_waypoints) + self.wp_labels.append(text) + + # if self.background: + # self.canvas.restore_region(self.background) + try: + self.ax.draw_artist(self.pathpatch) + except ValueError as error: + logging.error("ValueError Exception %s", error) + self.ax.draw_artist(self.line) + for t in self.wp_labels: + self.ax.draw_artist(t) + self.canvas.blit(self.ax.bbox) + + def get_lat_lon(self, event, wpm): + x = event.xdata + vertices = self.pathpatch.get_path().vertices + best_index = 1 + # if x axis has increasing coordinates + if vertices[-1, 0] > vertices[0, 0]: + for index, vertex in enumerate(vertices): + if x >= vertex[0]: + best_index = index + 1 + # if x axis has decreasing coordinates + else: + for index, vertex in enumerate(vertices): + if x <= vertex[0]: + best_index = index + 1 + # number of subcoordinates is determined by difference in x coordinates + number_of_intermediate_points = math.floor(vertices[best_index, 0] - vertices[best_index - 1, 0]) + vert_xs, vert_ys = latlon_points( + vertices[best_index - 1, 0], vertices[best_index - 1, 1], + vertices[best_index, 0], vertices[best_index, 1], + number_of_intermediate_points, connection="linear") + lats, lons = latlon_points( + wpm[best_index - 1].lat, wpm[best_index - 1].lon, + wpm[best_index].lat, wpm[best_index].lon, + number_of_intermediate_points, connection="greatcircle") + + # best_index1 is the best index among the intermediate coordinates to fit the hovered point + # if x axis has increasing coordinates + best_index1 = np.argmin(abs(vert_xs - x)) + # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood + return (lats[best_index1], lons[best_index1]), best_index + + +class PathL_Plotter(PathPlotter): + def __init__(self, ax, redraw_xaxis=None, clear_figure=None, numintpoints=101): + """Constructor passes a PathV instance its parent. + + Arguments: + ax -- matplotlib.Axes object into which the path should be drawn. + waypoints -- flighttrack.WaypointsModel instance. + numintpoints -- number of intermediate interpolation points. The entire + flight track will be interpolated to this number of + points. + redrawXAxis -- callback function to redraw the x-axis on path changes. + """ + super().__init__( + ax=ax, mplpath=PathV([[0, 0]], numintpoints=numintpoints)) + self.numintpoints = numintpoints + self.redraw_xaxis = redraw_xaxis + self.clear_figure = clear_figure + + def get_num_interpolation_points(self): + return self.numintpoints + + def get_lat_lon(self, event, wpm): + x = event.xdata + vertices = self.pathpatch.get_path().vertices + best_index = 1 + # if x axis has increasing coordinates + if vertices[-1, 0] > vertices[0, 0]: + for index, vertex in enumerate(vertices): + if x >= vertex[0]: + best_index = index + 1 + # if x axis has decreasing coordinates + else: + for index, vertex in enumerate(vertices): + if x <= vertex[0]: + best_index = index + 1 + # number of subcoordinates is determined by difference in x coordinates + number_of_intermediate_points = int(abs(vertices[best_index, 0] - vertices[best_index - 1, 0])) + vert_xs, vert_ys = latlon_points( + vertices[best_index - 1, 0], vertices[best_index - 1, 1], + vertices[best_index, 0], vertices[best_index, 1], + number_of_intermediate_points, connection="linear") + lats, lons = latlon_points( + wpm.waypoint_data(best_index - 1).lat, wpm.waypoint_data(best_index - 1).lon, + wpm.waypoint_data(best_index).lat, wpm.waypoint_data(best_index).lon, + number_of_intermediate_points, connection="greatcircle") + alts = np.linspace(wpm.waypoint_data(best_index - 1).flightlevel, + wpm.waypoint_data(best_index).flightlevel, number_of_intermediate_points) + + best_index1 = np.argmin(abs(vert_xs - x)) + # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood + return (lats[best_index1], lons[best_index1], alts[best_index1]), best_index + + +class PathInteractor(QtCore.QObject): + """An interactive matplotlib path editor. Allows vertices of a path patch + to be interactively picked and moved around. + Superclass for the path editors used by the top and side views of the + Mission Support System. + """ + + showverts = True # show the vertices of the path patch + epsilon = 12 + + # picking points + + def __init__(self, plotter, waypoints=None): + """The constructor initializes the path patches, overlying line + plot and connects matplotlib signals. + Arguments: + ax -- matplotlib.Axes object into which the path should be drawn. + waypoints -- flighttrack.WaypointsModel instance. + mplpath -- matplotlib.path.Path instance + facecolor -- facecolor of the patch + edgecolor -- edgecolor of the patch + linecolor -- color of the line plotted above the patch edges + markerfacecolor -- color of the markers that represent the waypoints + marker -- symbol of the markers that represent the waypoints, see + matplotlib plot() or scatter() routines for more information. + label_waypoints -- put labels with the waypoint numbers on the waypoints. + """ + QtCore.QObject.__init__(self) + self._ind = None # the active vertex + self.plotter = plotter + + # Set the waypoints model, connect to the change() signals of the model + # and redraw the figure. + self.waypoints_model = None + self.set_waypoints_model(waypoints) + + def set_waypoints_model(self, waypoints): + """Change the underlying waypoints data structure. Disconnect change() + signals of an already existing model and connect to the new model. + Redraw the map. + """ + # 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) + # 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) + # 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 + data changes induced by another view (table, side view). + """ + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) + self.redraw_figure() + + def qt_data_changed_listener(self, index1, index2): + """Listens to dataChanged() signals emitted by the flight track + data model. The view can thus react to data changes induced + by another view (table, top view). + """ + # REIMPLEMENT IN SUBCLASSES. + pass + def get_ind_under_point(self, event): """Get the index of the waypoint vertex under the point specified by event within epsilon tolerance. - Uses display coordinates. If no waypoint vertex is found, None is returned. """ - xy = np.asarray(self.pathpatch.get_path().vertices) - xyt = self.pathpatch.get_transform().transform(xy) + xy = np.asarray(self.plotter.pathpatch.get_path().vertices) + xyt = self.plotter.pathpatch.get_transform().transform(xy) xt, yt = xyt[:, 0], xyt[:, 1] d = np.hypot(xt - event.x, yt - event.y) ind = d.argmin() @@ -493,7 +946,7 @@ def button_press_callback(self, event): the vertex closest to the click, as long as a vertex is within epsilon tolerance of the click. """ - if not self.showverts: + if not self.plotter.showverts: return if event.inaxes is None: return @@ -504,77 +957,20 @@ def button_press_callback(self, event): def set_vertices_visible(self, showverts=True): """Set the visibility of path vertices (the line plot). """ - self.showverts = showverts - self.line.set_visible(self.showverts) - for t in self.wp_labels: - t.set_visible(showverts and self.label_waypoints) - if not self.showverts: - self._ind = None - self.canvas.draw() + self.plotter.set_vertices_visible(showverts) def set_patch_visible(self, showpatch=True): """Set the visibility of path patch (the area). """ - self.pathpatch.set_visible(showpatch) - self.canvas.draw() + self.plotter.set_patch_visible(showpatch) def set_labels_visible(self, visible=True): """Set the visibility of the waypoint labels. """ - self.label_waypoints = visible - for t in self.wp_labels: - t.set_visible(self.showverts and self.label_waypoints) - self.canvas.draw() + self.plotter.set_labels_visible(visible) def redraw_path(self, vertices=None): - """Redraw the matplotlib artists that represent the flight track - (path patch and line). - - If vertices are specified, they will be applied to the graphics - output. Otherwise the vertex array obtained from the path patch - will be used. - """ - if vertices is None: - vertices = self.pathpatch.get_path().vertices - self.line.set_data(list(zip(*vertices))) - - # Draw waypoint labels. - for wp in self.wp_labels: - wp.remove() - self.wp_labels = [] # remove doesn't seem to be necessary - x, y = list(zip(*vertices)) - wpd = self.waypoints_model.all_waypoint_data() - for i in range(len(wpd)): - textlabel = f"{str(i):} " - if wpd[i].location != "": - textlabel = f"{wpd[i].location:} " - t = self.ax.text(x[i], - y[i], - textlabel, - bbox=dict(boxstyle="round", - facecolor="white", - alpha=0.5, - edgecolor="none"), - fontweight="bold", - zorder=4, - rotation=90, - animated=True, - clip_on=True, - visible=self.showverts and self.label_waypoints) - self.wp_labels.append(t) - - if self.background: - self.canvas.restore_region(self.background) - try: - self.ax.draw_artist(self.pathpatch) - except ValueError as error: - logging.error("ValueError Exception %s", error) - self.ax.draw_artist(self.line) - for t in self.wp_labels: - self.ax.draw_artist(t) - self.canvas.blit(self.ax.bbox) - - redraw_figure = redraw_path + self.plotter.redraw_path(vertices, self.waypoints_model.all_waypoint_data()) def confirm_delete_waypoint(self, row): """Open a QMessageBox and ask the user if he really wants to @@ -601,20 +997,7 @@ def confirm_delete_waypoint(self, row): def set_path_color(self, line_color=None, marker_facecolor=None, patch_facecolor=None): - """Set the color of the path patch elements. - - Arguments (options): - line_color -- color of the path line - marker_facecolor -- color of the waypoints - patch_facecolor -- color of the patch covering the path area - """ - if line_color is not None: - self.line.set_color(line_color) - if marker_facecolor is not None: - self.line.set_markerfacecolor(marker_facecolor) - if patch_facecolor is not None: - self.pathpatch.set_facecolor(patch_facecolor) - + self.plotter.set_path_color(line_color, marker_facecolor, patch_facecolor) # # CLASS VPathInteractor @@ -638,14 +1021,10 @@ def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpo points. redrawXAxis -- callback function to redraw the x-axis on path changes. """ - self.numintpoints = numintpoints + plotter = PathV_Plotter(ax, redraw_xaxis=redraw_xaxis, clear_figure=clear_figure, numintpoints=numintpoints) self.redraw_xaxis = redraw_xaxis self.clear_figure = clear_figure - super().__init__( - ax=ax, waypoints=waypoints, mplpath=PathV([[0, 0]], numintpoints=numintpoints)) - - def get_num_interpolation_points(self): - return self.numintpoints + super().__init__(plotter=plotter, waypoints=waypoints) def redraw_figure(self): """For the side view, changes in the horizontal position of a waypoint @@ -654,7 +1033,7 @@ def redraw_figure(self): Calls the callback function 'redrawXAxis()'. """ - self.redraw_path() + self.plotter.redraw_path(waypoints_model_data=self.waypoints_model.all_waypoint_data()) # emit signal to redraw map self.signal_get_vsec.emit() if self.clear_figure() is not None: @@ -662,10 +1041,10 @@ def redraw_figure(self): if self.redraw_xaxis is not None: try: - self.redraw_xaxis(self.path.ilats, self.path.ilons, self.path.itimes) + self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes) except AttributeError as err: logging.debug("%s" % err) - self.ax.figure.canvas.draw() + self.plotter.ax.figure.canvas.draw() def button_release_delete_callback(self, event): """Called whenever a mouse button is released. @@ -698,7 +1077,7 @@ def button_release_insert_callback(self, event): y = event.ydata wpm = self.waypoints_model flightlevel = float(pressure2flightlevel(y * units.Pa).magnitude) - [lat, lon], best_index = self.get_lat_lon(event) + [lat, lon], best_index = self.plotter.get_lat_lon(event, wpm.all_waypoint_data()) loc = find_location(lat, lon) # skipped tolerance which uses appropriate_epsilon_km if loc is not None: (lat, lon), location = loc @@ -711,36 +1090,8 @@ def button_release_insert_callback(self, event): self._ind = None def get_lat_lon(self, event): - x = event.xdata - wpm = self.waypoints_model - vertices = self.pathpatch.get_path().vertices - best_index = 1 - # if x axis has increasing coordinates - if vertices[-1, 0] > vertices[0, 0]: - for index, vertex in enumerate(vertices): - if x >= vertex[0]: - best_index = index + 1 - # if x axis has decreasing coordinates - else: - for index, vertex in enumerate(vertices): - if x <= vertex[0]: - best_index = index + 1 - # number of subcoordinates is determined by difference in x coordinates - number_of_intermediate_points = math.floor(vertices[best_index, 0] - vertices[best_index - 1, 0]) - vert_xs, vert_ys = latlon_points( - vertices[best_index - 1, 0], vertices[best_index - 1, 1], - vertices[best_index, 0], vertices[best_index, 1], - number_of_intermediate_points, connection="linear") - lats, lons = latlon_points( - wpm.waypoint_data(best_index - 1).lat, wpm.waypoint_data(best_index - 1).lon, - wpm.waypoint_data(best_index).lat, wpm.waypoint_data(best_index).lon, - number_of_intermediate_points, connection="greatcircle") - - # best_index1 is the best index among the intermediate coordinates to fit the hovered point - # if x axis has increasing coordinates - best_index1 = np.argmin(abs(vert_xs - x)) - # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood - return (lats[best_index1], lons[best_index1]), best_index + lat_lon, ind = self.plotter.get_lat_lon(event, self.waypoints_model.all_waypoint_data()) + return lat_lon, ind def button_release_move_callback(self, event): """Called whenever a mouse button is released. @@ -751,7 +1102,7 @@ def button_release_move_callback(self, event): if self._ind is not None: # Submit the new pressure (the only value that can be edited # in the side view) to the data model. - vertices = self.pathpatch.get_path().vertices + vertices = self.plotter.pathpatch.get_path().vertices pressure = vertices[self._ind, 1] # http://doc.trolltech.com/4.3/qabstractitemmodel.html#createIndex qt_index = self.waypoints_model.createIndex(self._ind, ft.PRESSURE) @@ -771,11 +1122,11 @@ def motion_notify_callback(self, event): """ if not self.showverts or self._ind is None or event.inaxes is None or event.button != 1: return - vertices = self.pathpatch.get_path().vertices + vertices = self.plotter.pathpatch.get_path().vertices # Set the new y position of the vertex to event.ydata. Keep the # x coordinate. vertices[self._ind] = vertices[self._ind, 0], event.ydata - self.redraw_path(vertices) + self.plotter.redraw_path(vertices, self.waypoints_model.all_waypoint_data()) def qt_data_changed_listener(self, index1, index2): """Listens to dataChanged() signals emitted by the flight track @@ -786,14 +1137,14 @@ def qt_data_changed_listener(self, index1, index2): # 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.pathpatch.get_path().update_from_WaypointsTableModel(self.waypoints_model) + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) if index1.column() in [ft.FLIGHTLEVEL, ft.PRESSURE, ft.LOCATION]: - self.redraw_path(self.pathpatch.get_path().vertices) + 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: - self.redraw_xaxis(self.path.ilats, self.path.ilons, self.path.itimes) + self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes) class LPathInteractor(PathInteractor): @@ -813,14 +1164,8 @@ def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpo points. redrawXAxis -- callback function to redraw the x-axis on path changes. """ - self.numintpoints = numintpoints - self.clear_figure = clear_figure - self.redraw_xaxis = redraw_xaxis - super().__init__( - ax=ax, waypoints=waypoints, mplpath=PathV([[0, 0]], numintpoints=numintpoints)) - - def get_num_interpolation_points(self): - return self.numintpoints + plotter = PathL_Plotter(ax, redraw_xaxis=redraw_xaxis, clear_figure=clear_figure, numintpoints=numintpoints) + super().__init__(plotter=plotter, waypoints=waypoints) def redraw_figure(self): """For the linear view, changes in the horizontal or vertical position of a waypoint @@ -828,7 +1173,7 @@ def redraw_figure(self): redraw of the figure necessary. """ # emit signal to redraw map - self.redraw_xaxis() + self.plotter.redraw_xaxis() self.signal_get_lsec.emit() def redraw_path(self, vertices=None): @@ -842,43 +1187,16 @@ def draw_callback(self, event): pass def get_lat_lon(self, event): - x = event.xdata wpm = self.waypoints_model - vertices = self.pathpatch.get_path().vertices - best_index = 1 - # if x axis has increasing coordinates - if vertices[-1, 0] > vertices[0, 0]: - for index, vertex in enumerate(vertices): - if x >= vertex[0]: - best_index = index + 1 - # if x axis has decreasing coordinates - else: - for index, vertex in enumerate(vertices): - if x <= vertex[0]: - best_index = index + 1 - # number of subcoordinates is determined by difference in x coordinates - number_of_intermediate_points = int(abs(vertices[best_index, 0] - vertices[best_index - 1, 0])) - vert_xs, vert_ys = latlon_points( - vertices[best_index - 1, 0], vertices[best_index - 1, 1], - vertices[best_index, 0], vertices[best_index, 1], - number_of_intermediate_points, connection="linear") - lats, lons = latlon_points( - wpm.waypoint_data(best_index - 1).lat, wpm.waypoint_data(best_index - 1).lon, - wpm.waypoint_data(best_index).lat, wpm.waypoint_data(best_index).lon, - number_of_intermediate_points, connection="greatcircle") - alts = np.linspace(wpm.waypoint_data(best_index - 1).flightlevel, - wpm.waypoint_data(best_index).flightlevel, number_of_intermediate_points) - - best_index1 = np.argmin(abs(vert_xs - x)) - # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood - return (lats[best_index1], lons[best_index1], alts[best_index1]), best_index + lat_lon, ind = self.plotter.get_lat_lon(event, wpm) + return lat_lon, ind def qt_data_changed_listener(self, index1, index2): """Listens to dataChanged() signals emitted by the flight track data model. The linear view can thus react to data changes induced by another view (table, top view, side view). """ - self.pathpatch.get_path().update_from_WaypointsTableModel(self.waypoints_model) + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) self.redraw_figure() # @@ -901,21 +1219,12 @@ def __init__(self, mplmap, waypoints, mplmap -- mpl_map.MapCanvas instance into which the path should be drawn. waypoints -- flighttrack.WaypointsModel instance. """ - self.map = mplmap - self.wp_scatter = None - self.markerfacecolor = markerfacecolor - self.tangent_lines = None - self.show_tangent_points = False - self.solar_lines = None - self.show_marker = show_marker - self.show_solar_angle = None - self.remote_sensing = None - super().__init__( - ax=mplmap.ax, waypoints=waypoints, - mplpath=PathH_GC([[0, 0]], map=mplmap), + plotter = PathH_GCPlotter( + mplmap, mplpath=PathH_GC([[0, 0]], map=mplmap), facecolor='none', edgecolor='none', linecolor=linecolor, - markerfacecolor=markerfacecolor, marker='', - label_waypoints=label_waypoints) + markerfacecolor=markerfacecolor, marker='', label_waypoints=label_waypoints) + super().__init__(plotter=plotter, waypoints=waypoints) + self.redraw_path() def appropriate_epsilon(self, px=5): """Determine an epsilon value appropriate for the current projection and @@ -927,12 +1236,7 @@ def appropriate_epsilon(self, px=5): closest to a click while discarding clicks that are too far away from any geometry feature. """ - # (bounds = left, bottom, width, height) - ax_bounds = self.ax.bbox.bounds - width = int(round(ax_bounds[2])) - map_delta_x = np.hypot(self.map.llcrnry - self.map.urcrnry, self.map.llcrnrx - self.map.urcrnrx) - map_coords_per_px_x = map_delta_x / width - return map_coords_per_px_x * px + return self.plotter.appropriate_epsilon(px) def appropriate_epsilon_km(self, px=5): """Determine an epsilon value appropriate for the current projection and @@ -945,15 +1249,16 @@ def appropriate_epsilon_km(self, px=5): from any geometry feature. """ # (bounds = left, bottom, width, height) - ax_bounds = self.ax.bbox.bounds + ax_bounds = self.plotter.ax.bbox.bounds diagonal = math.hypot(round(ax_bounds[2]), round(ax_bounds[3])) - map_delta = get_distance(self.map.llcrnrlat, self.map.llcrnrlon, self.map.urcrnrlat, self.map.urcrnrlon) + map = self.plotter.map + map_delta = get_distance(map.llcrnrlat, map.llcrnrlon, map.urcrnrlat, map.urcrnrlon) km_per_px = map_delta / diagonal return km_per_px * px def get_lat_lon(self, event): - return self.map(event.xdata, event.ydata, inverse=True)[::-1] + return self.plotter.map(event.xdata, event.ydata, inverse=True)[::-1] def button_release_insert_callback(self, event): """Called whenever a mouse button is released. @@ -972,13 +1277,13 @@ def button_release_insert_callback(self, event): # Get position for new vertex. x, y = event.xdata, event.ydata - best_index = self.pathpatch.get_path().index_of_closest_segment( + best_index = self.plotter.pathpatch.get_path().index_of_closest_segment( x, y, eps=self.appropriate_epsilon()) logging.debug("TopView insert point: clicked at (%f, %f), " "best index: %d", x, y, best_index) - self.pathpatch.get_path().insert_vertex(best_index, [x, y], WaypointsPath.LINETO) + self.plotter.pathpatch.get_path().insert_vertex(best_index, [x, y], WaypointsPath.LINETO) - lon, lat = self.map(x, y, inverse=True) + lon, lat = self.plotter.map(x, y, inverse=True) loc = find_location(lat, lon, tolerance=self.appropriate_epsilon_km(px=15)) if loc is not None: (lat, lon), location = loc @@ -995,7 +1300,7 @@ def button_release_insert_callback(self, event): flightlevel = 0 new_wp = ft.Waypoint(lat, lon, flightlevel, location=location) wpm.insertRows(best_index, rows=1, waypoints=[new_wp]) - self.redraw_path() + self.plotter.redraw_path(waypoints_model_data=self.waypoints_model.all_waypoint_data()) self._ind = None @@ -1006,9 +1311,9 @@ def button_release_move_callback(self, event): return # Submit the new position to the data model. - vertices = self.pathpatch.get_path().wp_vertices - lon, lat = self.map(vertices[self._ind][0], vertices[self._ind][1], - inverse=True) + vertices = self.plotter.pathpatch.get_path().wp_vertices + lon, lat = self.plotter.map(vertices[self._ind][0], vertices[self._ind][1], + inverse=True) loc = find_location(lat, lon, tolerance=self.appropriate_epsilon_km(px=15)) if loc is not None: lat, lon = loc[0] @@ -1043,9 +1348,9 @@ def motion_notify_callback(self, event): return if event.button != 1: return - wp_vertices = self.pathpatch.get_path().wp_vertices + wp_vertices = self.plotter.pathpatch.get_path().wp_vertices wp_vertices[self._ind] = event.xdata, event.ydata - self.redraw_path(wp_vertices) + self.plotter.redraw_path(wp_vertices, waypoints_model_data=self.waypoints_model.all_waypoint_data()) def qt_data_changed_listener(self, index1, index2): """Listens to dataChanged() signals emitted by the flight track @@ -1061,8 +1366,7 @@ def update(self): """Update the path plot by updating coordinates and intermediate great circle points from the path patch, then redrawing. """ - self.pathpatch.get_path().update_from_WaypointsTableModel( - self.waypoints_model) + self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data()) self.redraw_path() def redraw_path(self, wp_vertices=None): @@ -1073,92 +1377,7 @@ def redraw_path(self, wp_vertices=None): graphics output. Otherwise the vertex array obtained from the path patch will be used. """ - - if wp_vertices is None: - wp_vertices = self.pathpatch.get_path().wp_vertices - if len(wp_vertices) == 0: - raise IOError("mscolab session expired") - vertices = self.pathpatch.get_path().vertices - else: - # If waypoints have been provided, compute the intermediate - # great circle points for the line instance. - x, y = list(zip(*wp_vertices)) - lons, lats = self.map(x, y, inverse=True) - x, y = self.map.gcpoints_path(lons, lats) - vertices = list(zip(x, y)) - - # Set the line to disply great circle points, remove existing - # waypoints scatter instance and draw a new one. This is - # necessary as scatter() does not provide a set_data method. - self.line.set_data(list(zip(*vertices))) - - wp_heights = [(wp.flightlevel * 0.03048) for wp in self.waypoints_model.all_waypoint_data()] - wp_times = [wp.utc_time for wp in self.waypoints_model.all_waypoint_data()] - - if self.tangent_lines is not None: - self.tangent_lines.remove() - if self.show_tangent_points: - assert self.remote_sensing is not None - self.tangent_lines = self.remote_sensing.compute_tangent_lines( - self.map, wp_vertices, wp_heights) - self.ax.add_collection(self.tangent_lines) - else: - self.tangent_lines = None - - if self.solar_lines is not None: - self.solar_lines.remove() - if self.show_solar_angle is not None: - assert self.remote_sensing is not None - self.solar_lines = self.remote_sensing.compute_solar_lines( - self.map, wp_vertices, wp_heights, wp_times, self.show_solar_angle) - self.ax.add_collection(self.solar_lines) - else: - self.solar_lines = None - - if self.wp_scatter is not None: - self.wp_scatter.remove() - x, y = list(zip(*wp_vertices)) - - if self.map.projection == "cyl": # hack for wraparound - x = normalize_longitude(x, self.map.llcrnrlon, self.map.urcrnrlon) - # (animated is important to remove the old scatter points from the map) - self.wp_scatter = self.ax.scatter( - x, y, color=self.markerfacecolor, s=20, zorder=3, animated=True, visible=self.show_marker) - - # Draw waypoint labels. - label_offset = self.appropriate_epsilon(px=5) - for wp_label in self.wp_labels: - wp_label.remove() - self.wp_labels = [] # remove doesn't seem to be necessary - wpd = self.waypoints_model.all_waypoint_data() - for i in range(len(wpd)): - textlabel = str(i) - if wpd[i].location != "": - textlabel = f"{wpd[i].location:}" - t = self.ax.text( - x[i] + label_offset, y[i] + label_offset, textlabel, - bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.6, "edgecolor": "none"}, - fontweight="bold", zorder=4, animated=True, clip_on=True, - visible=self.showverts and self.label_waypoints) - self.wp_labels.append(t) - - # Redraw the artists. - if self.background: - self.canvas.restore_region(self.background) - try: - self.ax.draw_artist(self.pathpatch) - except ValueError as error: - logging.debug("ValueError Exception '%s'", error) - self.ax.draw_artist(self.line) - self.ax.draw_artist(self.wp_scatter) - - for t in self.wp_labels: - self.ax.draw_artist(t) - if self.show_tangent_points: - self.ax.draw_artist(self.tangent_lines) - if self.show_solar_angle is not None: - self.ax.draw_artist(self.solar_lines) - self.canvas.blit(self.ax.bbox) + self.plotter.redraw_path(wp_vertices=wp_vertices, waypoints_model_data=self.waypoints_model.all_waypoint_data()) # Link redraw_figure() to redraw_path(). redraw_figure = redraw_path @@ -1167,12 +1386,7 @@ def draw_callback(self, event): """Extends PathInteractor.draw_callback() by drawing the scatter instance. """ - PathInteractor.draw_callback(self, event) - self.ax.draw_artist(self.wp_scatter) - if self.show_solar_angle: - self.ax.draw_artist(self.solar_lines) - if self.show_tangent_points: - self.ax.draw_artist(self.tangent_lines) + self.plotter.draw_callback(self, event) def get_ind_under_point(self, event): """Get the index of the waypoint vertex under the point @@ -1181,10 +1395,12 @@ def get_ind_under_point(self, event): Uses display coordinates. If no waypoint vertex is found, None is returned. """ - xy = np.asarray(self.pathpatch.get_path().wp_vertices) - if self.map.projection == "cyl": # hack for wraparound - xy[:, 0] = normalize_longitude(xy[:, 0], self.map.llcrnrlon, self.map.urcrnrlon) - xyt = self.pathpatch.get_transform().transform(xy) + xy = np.asarray(self.plotter.pathpatch.get_path().wp_vertices) + if self.plotter.map.projection == "cyl": # hack for wraparound + lon_min, lon_max = self.plotter.map.llcrnrlon, self.plotter.map.urcrnrlon + xy[xy[:, 0] < lon_min, 0] += 360 + xy[xy[:, 0] > lon_max, 0] -= 360 + xyt = self.plotter.pathpatch.get_transform().transform(xy) xt, yt = xyt[:, 0], xyt[:, 1] d = np.hypot(xt - event.x, yt - event.y) ind = d.argmin() @@ -1194,31 +1410,19 @@ def get_ind_under_point(self, event): def set_path_color(self, line_color=None, marker_facecolor=None, patch_facecolor=None): - """Set the color of the path patch elements. - - Arguments (options): - line_color -- color of the path line - marker_facecolor -- color of the waypoints - patch_facecolor -- color of the patch covering the path area - """ - PathInteractor.set_path_color(self, line_color, marker_facecolor, - patch_facecolor) - if marker_facecolor is not None: - self.wp_scatter.set_facecolor(marker_facecolor) - self.wp_scatter.set_edgecolor(marker_facecolor) - self.markerfacecolor = marker_facecolor + self.plotter.set_path_color(self, line_color, marker_facecolor, + patch_facecolor) def set_vertices_visible(self, showverts=True): """Set the visibility of path vertices (the line plot). """ - self.wp_scatter.set_visible(self.show_marker) - PathInteractor.set_vertices_visible(self, showverts) + self.plotter.set_vertices_visible(showverts) def set_tangent_visible(self, visible): - self.show_tangent_points = visible + self.plotter.set_tangent_visible(visible) def set_solar_angle_visible(self, visible): - self.show_solar_angle = visible + self.plotter.set_solar_angle_visible(visible) def set_remote_sensing(self, ref): - self.remote_sensing = ref + self.plotter.set_remote_sensing(ref) diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index 2b5a478ed..d2b40c5d4 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -57,21 +57,694 @@ matplotlib.rcParams['savefig.directory'] = LAST_SAVE_DIRECTORY +class MyFigure(object): + def __init__(self, fig=None, ax=None): + # setup Matplotlib Figure and Axis + self.fig, self.ax = fig, ax + if self.fig is None: + assert ax is None + self.fig = figure.Figure(facecolor="w") # 0.75 + if self.ax is None: + self.ax = self.fig.add_subplot(111, zorder=99) + + def draw_metadata(self, title="", init_time=None, valid_time=None, + level=None, style=None): + if style: + title += f" ({style})" + if level: + title += f" at {level}" + if isinstance(valid_time, datetime) and isinstance(init_time, datetime): + time_step = valid_time - init_time + else: + time_step = None + if isinstance(valid_time, datetime): + valid_time = valid_time.strftime('%a %Y-%m-%d %H:%M UTC') + if isinstance(init_time, datetime): + init_time = init_time.strftime('%a %Y-%m-%d %H:%M UTC') + + # Add valid time / init time information to the title. + if valid_time: + if init_time: + if time_step is not None: + title += f"\nValid: {valid_time} (step {((time_step.days * 86400 + time_step.seconds) // 3600):d}" \ + f" hrs from {init_time})" + else: + title += f"\nValid: {valid_time} (initialisation: {init_time})" + else: + title += f"\nValid: {valid_time}" + + # Set title. + self.ax.set_title(title, horizontalalignment='left', x=0) + + def get_plot_size_in_px(self): + """Determines the size of the current figure in pixels. + Returns the tuple width, height. + """ + # (bounds = left, bottom, width, height) + ax_bounds = self.ax.bbox.bounds + width = int(round(ax_bounds[2])) + height = int(round(ax_bounds[3])) + return width, height + + +class MyTopViewFigure(MyFigure): + def __init__(self, fig=None, ax=None): + super().__init__(fig, ax) + self.map = None + self.legimg = None + self.legax = None + # stores the topview plot title size(tov_pts) and topview axes label size(tov_als),initially as None. + self.tov_pts = None + self.tov_als = None + # Sets the default fontsize parameters' values for topview from MSSDefaultConfig. + self.topview_size_settings = config_loader(dataset="topview") + # logging.debug("applying map appearance settings %s." % settings) + self.settings = {"draw_graticule": True, + "draw_coastlines": True, + "fill_waterbodies": True, + "fill_continents": True, + "draw_flighttrack": True, + "draw_marker": True, + "label_flighttrack": True, + "tov_plot_title_size": "default", + "tov_axes_label_size": "default", + "colour_water": ((153 / 255.), (255 / 255.), (255 / 255.), (255 / 255.)), + "colour_land": ((204 / 255.), (153 / 255.), (102 / 255.), (255 / 255.)), + "colour_ft_vertices": (0, 0, 1, 1), + "colour_ft_waypoints": (1, 0, 0, 1)} + self.appearance_settings = self.settings + self.ax.figure.canvas.draw() + + def get_map_appearance(self): + """ + """ + return self.appearance_settings + + def init_map(self, **kwargs): + self.map = mpl_map.MapCanvas(appearance=self.get_map_appearance(), + resolution="l", area_thresh=1000., ax=self.ax, + **kwargs) + + # Sets the selected fontsize only if draw_graticule box from topview options is checked in. + if self.appearance_settings["draw_graticule"]: + try: + self.map._draw_auto_graticule(self.tov_als) + except Exception as ex: + logging.error("ERROR: cannot plot graticule (message: %s - '%s')", type(ex), ex) + else: + self.map.set_graticule_visible(self.appearance_settings["draw_graticule"]) + self.ax.set_autoscale_on(False) + self.ax.set_title("Top view", fontsize=self.tov_pts, horizontalalignment="left", x=0) + + def getBBOX(self, bbox_units=None): + axis = self.ax.axis() + if bbox_units is not None: + self.map.bbox_units = bbox_units + if self.map.bbox_units == "degree": + # Convert the current axis corners to lat/lon coordinates. + axis0, axis2 = self.map(axis[0], axis[2], inverse=True) + axis1, axis3 = self.map(axis[1], axis[3], inverse=True) + bbox = (axis0, axis2, axis1, axis3) + + elif self.map.bbox_units.startswith("meter"): + center_x, center_y = self.map( + *(float(_x) for _x in self.map.bbox_units[6:-1].split(","))) + bbox = (axis[0] - center_x, axis[2] - center_y, axis[1] - center_x, axis[3] - center_y) + + else: + bbox = axis[0], axis[2], axis[1], axis[3] + + return bbox + + def clear_figure(self): + logging.debug("Removing image") + if self.map.image is not None: + self.map.image.remove() + self.map.image = None + self.ax.set_title("Top view", horizontalalignment="left", x=0) + self.ax.figure.canvas.draw() + + def set_map(self): + self.set_map_appearance(self.settings) + + def set_map_appearance(self, settings_dict): + """Apply settings from dictionary 'settings_dict' to the view. + If settings is None, apply default settings. + """ + if settings_dict is not None: + self.settings.update(settings_dict) + + # Stores the exact value of fontsize for topview plot title size(tov_pts) + self.tov_pts = (self.topview_size_settings["plot_title_size"] + if self.settings["tov_plot_title_size"] == "default" + else int(self.settings["tov_plot_title_size"])) + # Stores the exact value of fontsize for topview axes label size(tov_als) + self.tov_als = (self.topview_size_settings["axes_label_size"] + if self.settings["tov_axes_label_size"] == "default" + else int(self.settings["tov_axes_label_size"])) + + ax = self.ax + + if self.map is not None: + self.map.set_coastlines_visible(self.settings["draw_coastlines"]) + self.map.set_fillcontinents_visible(visible=self.settings["fill_continents"], + land_color=self.settings["colour_land"], + lake_color=self.settings["colour_water"]) + self.map.set_mapboundary_visible(visible=self.settings["fill_waterbodies"], + bg_color=self.settings["colour_water"]) + # self.waypoints_interactor.set_path_color(line_color=self.settings["colour_ft_vertices"], + # marker_facecolor=self.settings["colour_ft_waypoints"]) + # self.waypoints_interactor.show_marker = self.settings["draw_marker"] + # self.waypoints_interactor.set_vertices_visible(self.settings["draw_flighttrack"]) + # self.waypoints_interactor.set_labels_visible(self.settings["label_flighttrack"]) + + # Updates plot title size as selected from combobox labelled plot title size. + ax.set_autoscale_on(False) + ax.set_title("Top view", fontsize=self.tov_pts, horizontalalignment="left", x=0) + + # Updates graticule ticklabels/labels fontsize if draw_graticule is True. + if self.settings["draw_graticule"]: + self.map.set_graticule_visible(False) + self.map._draw_auto_graticule(self.tov_als) + else: + self.map.set_graticule_visible(self.settings["draw_graticule"]) + + def redraw_map(self, kwargs_update=None): + """Redraw map canvas. + Executed on clicked() of btMapRedraw. + See MapCanvas.update_with_coordinate_change(). After the map redraw, + coordinates of all objects overlain on the map have to be updated. + """ + + # 2) UPDATE MAP. + self.map.update_with_coordinate_change(kwargs_update) + + # Sets the graticule ticklabels/labels fontsize for topview when map is redrawn. + if self.appearance_settings["draw_graticule"]: + self.map.set_graticule_visible(False) + self.map._draw_auto_graticule(self.tov_als) + else: + self.myfig.map.set_graticule_visible(self.appearance_settings["draw_graticule"]) + self.ax.figure.canvas.draw() # this one is required to trigger a + # drawevent to update the background + + # self.draw_metadata() ; It is not needed here, since below here already plot title is being set. + + # Setting fontsize for topview plot title when map is redrawn. + self.ax.set_title("Top view", fontsize=self.tov_pts, horizontalalignment='left', x=0) + self.ax.figure.canvas.draw() + + def draw_image(self, img): + """Draw the image img on the current plot. + """ + logging.debug("plotting image..") + self.wms_image = self.map.imshow(img, interpolation="nearest", origin=PIL_IMAGE_ORIGIN) + # NOTE: imshow always draws the images to the lowest z-level of the + # plot. + # See these mailing list entries: + # http://www.mail-archive.com/matplotlib-devel@lists.sourceforge.net/msg05955.html + # http://old.nabble.com/Re%3A--Matplotlib-users--imshow-zorder-tt19047314.html#a19047314 + # + # Question: Is this an issue for us or do we always want the images in the back + # anyhow? At least we need to remove filled continents here. + # self.map.set_fillcontinents_visible(False) + # ** UPDATE 2011/01/14 ** seems to work with version 1.0! + logging.debug("done.") + + def draw_legend(self, img): + """Draw the legend graphics img on the current plot. + Adds new axes to the plot that accomodate the legend. + """ + # If the method is called with a "None" image, the current legend + # graphic should be removed (if one exists). + if self.legimg is not None: + logging.debug("removing image %s", self.legimg) + self.legimg.remove() + self.legimg = None + + if img is not None: + # The size of the legend axes needs to be given in relative figure + # coordinates. To determine those from the legend graphics size in + # pixels, we need to determine the size of the currently displayed + # figure in pixels. + figsize_px = self.fig.get_size_inches() * self.fig.get_dpi() + ax_extent_x = float(img.size[0]) / figsize_px[0] + ax_extent_y = float(img.size[1]) / figsize_px[1] + + # If no legend axes have been created, do so now. + if self.legax is None: + # Main axes instance of mplwidget has zorder 99. + self.legax = self.fig.add_axes([1 - ax_extent_x, 0.01, ax_extent_x, ax_extent_y], + frameon=False, + xticks=[], yticks=[], + label="ax2", zorder=0) + self.legax.patch.set_facecolor("None") + # self.legax.set_yticklabels("ax2", fontsize=32, minor=False) + # print("Legax=if" + self.legax) + + # If axes exist, adjust their position. + else: + self.legax.set_position([1 - ax_extent_x, 0.01, ax_extent_x, ax_extent_y]) + # self.legax.set_yticklabels("ax2", fontsize=32, minor=False) + # print("Legax=else" + self.legax) + # Plot the new legimg in the legax axes. + self.legimg = self.legax.imshow(img, origin=PIL_IMAGE_ORIGIN, aspect="equal", interpolation="nearest") + # print("Legimg" + self.legimg) + self.ax.figure.canvas.draw() + + +class MySideViewFigure(MyFigure): + _pres_maj = np.concatenate([np.arange(top * 10, top, -top) for top in (10000, 1000, 100, 10)] + [[10]]) + _pres_min = np.concatenate([np.arange(top * 10, top, -top // 10) for top in (10000, 1000, 100, 10)] + [[10]]) + + _pres_maj = np.concatenate([np.arange(top * 10, top, -top) for top in (10000, 1000, 100, 10)] + [[10]]) + _pres_min = np.concatenate([np.arange(top * 10, top, -top // 10) for top in (10000, 1000, 100, 10)] + [[10]]) + + def __init__(self, fig=None, ax=None, settings=None, numlabels=None, num_interpolation_points=None): + """ + Arguments: + model -- WaypointsTableModel defining the vertical section. + """ + if numlabels is None: + numlabels = config_loader(dataset='num_labels') + if num_interpolation_points is None: + num_interpolation_points = config_loader(dataset='num_interpolation_points') + super().__init__(fig, ax) + + # Default settings. + self.settings_dict = {"vertical_extent": (1050, 180), + "vertical_axis": "pressure", + "secondary_axis": "no secondary axis", + "plot_title_size": "default", + "axes_label_size": "default", + "flightlevels": [], + "draw_flightlevels": True, + "draw_flighttrack": True, + "fill_flighttrack": True, + "label_flighttrack": True, + "draw_verticals": True, + "draw_marker": True, + "draw_ceiling": True, + "colour_ft_vertices": (0, 0, 1, 1), + "colour_ft_waypoints": (1, 0, 0, 1), + "colour_ft_fill": (0, 0, 1, 0.15), + "colour_ceiling": (0, 0, 1, 0.15)} + if settings is not None: + self.settings_dict.update(settings) + + self.numlabels = numlabels + self.num_interpolation_points = num_interpolation_points + self.ax2 = self.ax.twinx() + self.ax.patch.set_facecolor("None") + self.ax2.patch.set_facecolor("None") + # Main axes instance of mplwidget has zorder 99. + self.imgax = self.fig.add_axes( + self.ax.get_position(), frameon=True, xticks=[], yticks=[], label="imgax", zorder=0) + self.vertical_lines = [] + + # Sets the default value of sideview fontsize settings from MSSDefaultConfig. + self.sideview_size_settings = config_loader(dataset="sideview") + # Draw a number of flight level lines. + self.flightlevels = [] + self.fl_label_list = [] + self.image = None + self.update_vertical_extent_from_settings(init=True) + + def _determine_ticks_labels(self, typ): + if typ == "no secondary axis": + major_ticks = [] * units.pascal + minor_ticks = [] * units.pascal + labels = [] + ylabel = "" + elif typ == "pressure": + # Compute the position of major and minor ticks. Major ticks are labelled. + major_ticks = self._pres_maj[(self._pres_maj <= self.p_bot) & (self._pres_maj >= self.p_top)] + minor_ticks = self._pres_min[(self._pres_min <= self.p_bot) & (self._pres_min >= self.p_top)] + labels = [f"{int(_x / 100)}" + if (_x / 100) - int(_x / 100) == 0 else f"{float(_x / 100)}" for _x in major_ticks] + if len(labels) > 20: + labels = ["" if x.split(".")[-1][0] in "975" else x for x in labels] + elif len(labels) > 10: + labels = ["" if x.split(".")[-1][0] in "9" else x for x in labels] + ylabel = "pressure (hPa)" + elif typ == "pressure altitude": + bot_km = thermolib.pressure2flightlevel(self.p_bot * units.Pa).to(units.km).magnitude + top_km = thermolib.pressure2flightlevel(self.p_top * units.Pa).to(units.km).magnitude + ma_dist, mi_dist = 4, 1.0 + if (top_km - bot_km) <= 20: + ma_dist, mi_dist = 1, 0.5 + elif (top_km - bot_km) <= 40: + ma_dist, mi_dist = 2, 0.5 + major_heights = np.arange(0, top_km + 1, ma_dist) + minor_heights = np.arange(0, top_km + 1, mi_dist) + major_ticks = thermolib.flightlevel2pressure(major_heights * units.km).magnitude + minor_ticks = thermolib.flightlevel2pressure(minor_heights * units.km).magnitude + labels = major_heights + ylabel = "pressure altitude (km)" + elif typ == "flight level": + bot_km = thermolib.pressure2flightlevel(self.p_bot * units.Pa).to(units.km).magnitude + top_km = thermolib.pressure2flightlevel(self.p_top * units.Pa).to(units.km).magnitude + ma_dist, mi_dist = 50, 10 + if (top_km - bot_km) <= 10: + ma_dist, mi_dist = 20, 10 + elif (top_km - bot_km) <= 40: + ma_dist, mi_dist = 40, 10 + major_fl = np.arange(0, 2132, ma_dist) + minor_fl = np.arange(0, 2132, mi_dist) + major_ticks = thermolib.flightlevel2pressure(major_fl * units.hft).magnitude + minor_ticks = thermolib.flightlevel2pressure(minor_fl * units.hft).magnitude + labels = major_fl + ylabel = "flight level (hft)" + else: + raise RuntimeError(f"Unsupported vertical axis type: '{typ}'") + return ylabel, major_ticks, minor_ticks, labels + + def setup_side_view(self): + """Set up a vertical section view. + + Vertical cross section code (log-p axis etc.) taken from + mss_batch_production/visualisation/mpl_vsec.py. + """ + + self.ax.set_title("vertical flight profile", horizontalalignment="left", x=0) + self.ax.grid(b=True) + + self.ax.set_xlabel("lat/lon") + + for ax in (self.ax, self.ax2): + ax.set_yscale("log") + ax.set_ylim(self.p_bot, self.p_top) + + self.redraw_yaxis() + + def redraw_yaxis(self): + """ Redraws the y-axis on map after setting the values from sideview options dialog box + and also updates the sizes for map title and x and y axes labels and ticklabels""" + + vaxis = self.settings_dict["vertical_axis"] + vaxis2 = self.settings_dict["secondary_axis"] + + # Sets fontsize value for x axis ticklabel. + axes_label_size = (self.sideview_size_settings["axes_label_size"] + if self.settings_dict["axes_label_size"] == "default" + else int(self.settings_dict["axes_label_size"])) + # Sets fontsize value for plot title and axes title/label + plot_title_size = (self.sideview_size_settings["plot_title_size"] + if self.settings_dict["plot_title_size"] == "default" + else int(self.settings_dict["plot_title_size"])) + # Updates the fontsize of the x-axis ticklabels of sideview. + self.ax.tick_params(axis='x', labelsize=axes_label_size) + # Updates the fontsize of plot title and x-axis title of sideview. + self.ax.set_title("vertical flight profile", fontsize=plot_title_size, horizontalalignment="left", x=0) + self.ax.set_xlabel("lat/lon", fontsize=plot_title_size) + + for ax, typ in zip((self.ax, self.ax2), (vaxis, vaxis2)): + ylabel, major_ticks, minor_ticks, labels = self._determine_ticks_labels(typ) + + ax.set_ylabel(ylabel, fontsize=plot_title_size) + ax.set_yticks(minor_ticks, minor=True) + ax.set_yticks(major_ticks, minor=False) + ax.set_yticklabels([], minor=True) + ax.set_yticklabels(labels, minor=False, fontsize=axes_label_size) + ax.set_ylim(self.p_bot, self.p_top) + + if vaxis2 == "no secondary axis": + self.fig.subplots_adjust(left=0.08, right=0.96, top=0.9, bottom=0.14) + self.imgax.set_position(self.ax.get_position()) + else: + self.fig.subplots_adjust(left=0.08, right=0.92, top=0.9, bottom=0.14) + self.imgax.set_position(self.ax.get_position()) + + def redraw_xaxis(self, lats, lons, times, times_visible): + """Redraw the x-axis of the side view on path changes. Also remove + a vertical section image if one exists, as it is invalid after + a path change. + """ + logging.debug("redrawing x-axis") + + # Re-label x-axis. + self.ax.set_xlim(0, len(lats) - 1) + # Set xticks so that they display lat/lon. Plot "numlabels" labels. + lat_inds = np.arange(len(lats)) + tick_index_step = len(lat_inds) // self.numlabels + self.ax.set_xticks(lat_inds[::tick_index_step]) + + if times_visible: + self.ax.set_xticklabels([f'{d[0]:2.1f}, {d[1]:2.1f}\n{d[2].strftime("%H:%M")}Z' + for d in zip(lats[::tick_index_step], + lons[::tick_index_step], + times[::tick_index_step])], + rotation=25, horizontalalignment="right") + else: + self.ax.set_xticklabels([f"{d[0]:2.1f}, {d[1]:2.1f}" + for d in zip(lats[::tick_index_step], + lons[::tick_index_step])], + rotation=25, horizontalalignment="right") + + self.ax.figure.canvas.draw() + + def draw_vertical_lines(self, highlight, lats, lons): + # Remove all vertical lines + for line in self.vertical_lines[:]: + try: + self.ax.lines.remove(line) + except ValueError as e: + logging.debug(f"Vertical line was somehow already removed:\n{e}") + self.vertical_lines.remove(line) + + # Add vertical lines + if self.settings_dict["draw_verticals"]: + ipoint = 0 + for i, (lat, lon) in enumerate(zip(lats, lons)): + if (ipoint < len(highlight) and + np.hypot(lat - highlight[ipoint][0], + lon - highlight[ipoint][1]) < 2E-10): + self.vertical_lines.append( + self.ax.axvline(i, color='k', linewidth=2, linestyle='--', alpha=0.5)) + ipoint += 1 + self.fig.canvas.draw() + + def getBBOX(self): + """Get the bounding box of the view (returns a 4-tuple + x1, y1(p_bot[hPa]), x2, y2(p_top[hPa])). + """ + # Get the bounding box of the current view + # (bbox = llcrnrlon, llcrnrlat, urcrnrlon, urcrnrlat; i.e. for the side + # view bbox = x1, y1(p_bot), x2, y2(p_top)). + axis = self.ax.axis() + + num_interpolation_points = self.num_interpolation_points + num_labels = self.numlabels + + # Return a tuple (num_interpolation_points, p_bot[hPa], + # num_labels, p_top[hPa]) as BBOX. + bbox = (num_interpolation_points, (axis[2] / 100), + num_labels, (axis[3] / 100)) + return bbox + + def clear_figure(self): + logging.debug("path of side view has changed.. removing invalidated " + "image (if existent) and redrawing.") + if self.image is not None: + self.image.remove() + self.image = None + self.ax.set_title("vertical flight profile", horizontalalignment="left", x=0) + self.ax.figure.canvas.draw() + + def draw_image(self, img): + """Draw the image img on the current plot. + + NOTE: The image is plotted in a separate axes object that is located + below the axes that display the flight profile. This is necessary + because imshow() does not work with logarithmic axes. + """ + logging.debug("plotting vertical section image..") + ix, iy = img.size + logging.debug(" image size is %dx%d px, format is '%s'", ix, iy, img.format) + + # If an image is currently displayed, remove it from the plot. + if self.image is not None: + self.image.remove() + + # Plot the new image in the image axes and adjust the axes limits. + self.image = self.imgax.imshow( + img, interpolation="nearest", aspect="auto", origin=PIL_IMAGE_ORIGIN) + self.imgax.set_xlim(0, ix - 1) + self.imgax.set_ylim(iy - 1, 0) + self.ax.figure.canvas.draw() + logging.debug("done.") + + def update_vertical_extent_from_settings(self, init=False): + """ Checks for current units of axis and convert the upper and lower limit + to pa(pascals) for the internal computation by code """ + + if not init: + p_bot_old = self.p_bot + p_top_old = self.p_top + + if self.settings_dict["vertical_axis"] == "pressure altitude": + self.p_bot = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][0] * units.km).magnitude + self.p_top = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][1] * units.km).magnitude + elif self.settings_dict["vertical_axis"] == "flight level": + self.p_bot = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][0] * units.hft).magnitude + self.p_top = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][1] * units.hft).magnitude + else: + self.p_bot = self.settings_dict["vertical_extent"][0] * 100 + self.p_top = self.settings_dict["vertical_extent"][1] * 100 + + if not init: + if (p_bot_old != self.p_bot) or (p_top_old != self.p_top): + if self.image is not None: + self.image.remove() + self.image = None + self.setup_side_view() + else: + self.redraw_yaxis() + + +class MyLinearViewFigure(MyFigure): + """Specialised MplCanvas that draws a linear view of a + flight track / list of waypoints. + """ + + def __init__(self, model=None, numlabels=None): + """ + Arguments: + model -- WaypointsTableModel defining the linear section. + """ + if numlabels is None: + numlabels = config_loader(dataset='num_labels') + super().__init__() + + self.settings_dict = {"plot_title_size": "default", + "axes_label_size": "default"} + + # Setup the plot. + self.numlabels = numlabels + self.setup_linear_view() + # If a waypoints model has been passed, create an interactor on it. + self.waypoints_interactor = None + self.waypoints_model = None + self.vertical_lines = [] + self.basename = "linearview" + # Sets the default values of plot sizes from MissionSupportDefaultConfig. + self.linearview_size_settings = config_loader(dataset="linearview") + + def setup_linear_view(self): + """Set up a linear section view. + """ + self.fig.subplots_adjust(left=0.08, right=0.96, top=0.9, bottom=0.14) + + def clear_figure(self): + logging.debug("path of linear view has changed.. removing invalidated plots") + self.ax.figure.clf() + self.ax = self.fig.add_subplot(111, zorder=99) + self.ax.figure.patch.set_visible(False) + + def redraw_xaxis(self, lats, lons): + # Re-label x-axis. + self.ax.set_xlim(0, len(lats) - 1) + # Set xticks so that they display lat/lon. Plot "numlabels" labels. + lat_inds = np.arange(len(lats)) + tick_index_step = len(lat_inds) // self.numlabels + self.ax.set_xticks(lat_inds[::tick_index_step]) + self.ax.set_xticklabels([f'{d[0]:2.1f}, {d[1]:2.1f}' + for d in zip(lats[::tick_index_step], + lons[::tick_index_step])], + rotation=25, horizontalalignment="right") + + # Remove all vertical lines + for line in self.vertical_lines[:]: + try: + self.ax.lines.remove(line) + except ValueError as e: + logging.debug(f"Vertical line was somehow already removed:\n{e}") + self.vertical_lines.remove(line) + + def draw_vertical_lines(self, highlight, lats, lons): + # draw vertical lines + vertical_lines = [] + ipoint = 0 + for i, (lat, lon) in enumerate(zip(lats, lons)): + if (ipoint < len(highlight) and np.hypot(lat - highlight[ipoint][0], + lon - highlight[ipoint][1]) < 2E-10): + vertical_lines.append(self.ax.axvline(i, color='k', linewidth=2, linestyle='--', alpha=0.5)) + ipoint += 1 + self.fig.tight_layout() + self.fig.subplots_adjust(top=0.85, bottom=0.20) + self.fig.canvas.draw() + + def draw_legend(self, img): + if img is not None: + logging.error("Legends not supported in LinearView mode!") + raise NotImplementedError + + def draw_image(self, xmls, colors=None, scales=None): + self.clear_figure() + offset = 40 + self.ax.patch.set_visible(False) + + for i, xml in enumerate(xmls): + data = xml.find("Data") + values = [float(value) for value in data.text.split(",")] + unit = data.attrib["unit"] + numpoints = int(data.attrib["num_waypoints"]) + + if colors: + color = colors[i] if len(colors) > i else colors[-1] + else: + color = "#00AAFF" + + if scales: + scale = scales[i] if len(scales) > i else scales[-1] + else: + scale = "linear" + + par = self.ax.twinx() if i > 0 else self.ax + par.set_yscale(scale) + + par.plot(range(numpoints), values, color) + if i > 0: + par.spines["right"].set_position(("outward", (i - 1) * offset)) + if unit: + par.set_ylabel(unit) + + par.yaxis.label.set_color(color.replace("0x", "#")) + + def get_settings(self): + """Returns a dictionary containing settings regarding the linear view + appearance. + """ + return self.settings_dict + + def set_settings(self, settings): + """ + Apply settings from options ui to the linear view + """ + + if settings is not None: + self.settings_dict.update(settings) + + pts = (self.linearview_size_settings["plot_title_size"] if self.settings_dict["plot_title_size"] == "default" + else int(self.settings_dict["plot_title_size"])) + als = (self.linearview_size_settings["axes_label_size"] if self.settings_dict["axes_label_size"] == "default" + else int(self.settings_dict["axes_label_size"])) + self.ax.tick_params(axis='both', labelsize=als) + self.ax.set_title("Linear flight profile", fontsize=pts, horizontalalignment='left', x=0) + self.ax.figure.canvas.draw() + + class MplCanvas(FigureCanvasQTAgg): """Class to represent the FigureCanvasQTAgg widget. - Main axes instance has zorder 99 (important when additional axes are added). """ - def __init__(self): - # setup Matplotlib Figure and Axis - self.fig = figure.Figure(facecolor="w") # 0.75 - self.ax = self.fig.add_subplot(111, zorder=99) + def __init__(self, myfig): self.default_filename = "_image" - + self.myfig = myfig # initialization of the canvas - super(MplCanvas, self).__init__(self.fig) + super(MplCanvas, self).__init__(self.myfig.fig) # we define the widget as expandable super(MplCanvas, self).setSizePolicy( @@ -83,7 +756,6 @@ def __init__(self): def get_default_filename(self): """ defines the image file name for storing from a view - return: default png filename of a view """ result = self.basename + self.default_filename @@ -99,67 +771,19 @@ def draw_metadata(self, title="", init_time=None, valid_time=None, self.default_filename = "" if title: self.default_filename += f"_{title.split()[0]:>5}" - if style: - title += f" ({style})" if level: - title += f" at {level}" self.default_filename += f"_{level.split()[0]}" - if isinstance(valid_time, datetime) and isinstance(init_time, datetime): - time_step = valid_time - init_time - else: - time_step = None - if isinstance(valid_time, datetime): - valid_time = valid_time.strftime('%a %Y-%m-%d %H:%M UTC') - if isinstance(init_time, datetime): - init_time = init_time.strftime('%a %Y-%m-%d %H:%M UTC') - - # Add valid time / init time information to the title. - if valid_time: - if init_time: - if time_step is not None: - title += f"\nValid: {valid_time} (step {((time_step.days * 86400 + time_step.seconds) // 3600):d}" \ - f" hrs from {init_time})" - else: - title += f"\nValid: {valid_time} (initialisation: {init_time})" - else: - title += f"\nValid: {valid_time}" - # Set title. - self.ax.set_title(title, horizontalalignment='left', x=0) + self.myfig.draw_metadata(title, init_time, valid_time, level, style) self.draw() # without the repaint the title is not properly updated self.repaint() def get_plot_size_in_px(self): """Determines the size of the current figure in pixels. - Returns the tuple width, height. """ - # (bounds = left, bottom, width, height) - ax_bounds = self.ax.bbox.bounds - width = int(round(ax_bounds[2])) - height = int(round(ax_bounds[3])) - return width, height - - -class MplWidget(QtWidgets.QWidget): - """Matplotlib canvas widget defined in Qt Designer""" - - def __init__(self, parent=None): - # initialization of Qt MainWindow widget - super(MplWidget, self).__init__(parent) - - # set the canvas to the Matplotlib widget - self.canvas = MplCanvas() - - # create a vertical box layout - self.vbl = QtWidgets.QVBoxLayout() - - # add mpl widget to vertical box - self.vbl.addWidget(self.canvas) - - # set the layout to th vertical box - self.setLayout(self.vbl) + return self.myfig.get_plot_size_in_px() def _getSaveFileName(parent, title="Choose a filename to save to", filename="test.png", @@ -247,16 +871,15 @@ def _navigate_mode(self): class NavigationToolbar(NavigationToolbar2QT): """ parts of this class have been copied from the NavigationToolbar2QT class. - According to https://matplotlib.org/users/license.html we shall summarise our changes to matplotlib code: - We copied small parts of the given implementation of the navigation toolbar class to allow for our custom waypoint buttons. Our code extends the matplotlib toolbar to allow for less or additional buttons and properly update all plots and elements in case the pan or zoom elements were triggered by the user. """ + def __init__(self, canvas, parent, sideview=False, coordinates=True): self.sideview = sideview @@ -442,11 +1065,11 @@ def mouse_move(self, event): else: (lat, lon), _ = self.canvas.waypoints_interactor.get_lat_lon(event) y_value = convert_pressure_to_vertical_axis_measure( - self.canvas.settings_dict["vertical_axis"], event.ydata) + self.canvas.myfig.settings_dict["vertical_axis"], event.ydata) units = { "pressure altitude": "km", "flight level": "hft", - "pressure": "hPa"}[self.canvas.settings_dict["vertical_axis"]] + "pressure": "hPa"}[self.canvas.myfig.settings_dict["vertical_axis"]] self.set_message(f"{self.mode} lat={lat:6.2f} lon={lon:7.2f} altitude={y_value:.2f}{units}") def _update_buttons_checked(self): @@ -490,12 +1113,6 @@ class MplSideViewCanvas(MplCanvas): """Specialised MplCanvas that draws a side view (vertical section) of a flight track / list of waypoints. """ - _pres_maj = np.concatenate([np.arange(top * 10, top, -top) for top in (10000, 1000, 100, 10)] + [[10]]) - _pres_min = np.concatenate([np.arange(top * 10, top, -top // 10) for top in (10000, 1000, 100, 10)] + [[10]]) - - _pres_maj = np.concatenate([np.arange(top * 10, top, -top) for top in (10000, 1000, 100, 10)] + [[10]]) - _pres_min = np.concatenate([np.arange(top * 10, top, -top // 10) for top in (10000, 1000, 100, 10)] + [[10]]) - def __init__(self, model=None, settings=None, numlabels=None): """ Arguments: @@ -503,44 +1120,23 @@ def __init__(self, model=None, settings=None, numlabels=None): """ if numlabels is None: numlabels = config_loader(dataset='num_labels') - super(MplSideViewCanvas, self).__init__() + self.myfig = MySideViewFigure() + super(MplSideViewCanvas, self).__init__(self.myfig) - # Default settings. - self.settings_dict = {"vertical_extent": (1050, 180), - "vertical_axis": "pressure", - "secondary_axis": "no secondary axis", - "plot_title_size": "default", - "axes_label_size": "default", - "flightlevels": [], - "draw_flightlevels": True, - "draw_flighttrack": True, - "fill_flighttrack": True, - "label_flighttrack": True, - "draw_verticals": True, - "draw_marker": True, - "draw_ceiling": True, - "colour_ft_vertices": (0, 0, 1, 1), - "colour_ft_waypoints": (1, 0, 0, 1), - "colour_ft_fill": (0, 0, 1, 0.15), - "colour_ceiling": (0, 0, 1, 0.15)} if settings is not None: - self.settings_dict.update(settings) + self.myfig.settings_dict.update(settings) # Setup the plot. self.update_vertical_extent_from_settings(init=True) self.numlabels = numlabels - self.ax2 = self.ax.twinx() - self.ax.patch.set_facecolor("None") - self.ax2.patch.set_facecolor("None") + self.myfig.ax.patch.set_facecolor("None") # Main axes instance of mplwidget has zorder 99. - self.imgax = self.fig.add_axes( - self.ax.get_position(), frameon=True, xticks=[], yticks=[], label="imgax", zorder=0) self.vertical_lines = [] # Sets the default value of sideview fontsize settings from MSSDefaultConfig. self.sideview_size_settings = config_loader(dataset="sideview") - self.setup_side_view() + self.myfig.setup_side_view() # Draw a number of flight level lines. self.flightlevels = [] self.fl_label_list = [] @@ -554,7 +1150,7 @@ def __init__(self, model=None, settings=None, numlabels=None): if model: self.set_waypoints_model(model) - self.set_settings(self.settings_dict) + self.set_settings(self.myfig.settings_dict) def set_waypoints_model(self, model): """Set the WaypointsTableModel defining the vertical section. @@ -568,154 +1164,20 @@ def set_waypoints_model(self, model): # Create a path interactor object. The interactor object connects # itself to the change() signals of the flight track data model. self.waypoints_interactor = mpl_pi.VPathInteractor( - self.ax, self.waypoints_model, + self.myfig.ax, self.waypoints_model, numintpoints=config_loader(dataset="num_interpolation_points"), - redraw_xaxis=self.redraw_xaxis, clear_figure=self.clear_figure + redraw_xaxis=self.redraw_xaxis, clear_figure=self.myfig.clear_figure ) - def _determine_ticks_labels(self, typ): - if typ == "no secondary axis": - major_ticks = [] * units.pascal - minor_ticks = [] * units.pascal - labels = [] - ylabel = "" - elif typ == "pressure": - # Compute the position of major and minor ticks. Major ticks are labelled. - major_ticks = self._pres_maj[(self._pres_maj <= self.p_bot) & (self._pres_maj >= self.p_top)] - minor_ticks = self._pres_min[(self._pres_min <= self.p_bot) & (self._pres_min >= self.p_top)] - labels = [f"{int(_x / 100)}" - if (_x / 100) - int(_x / 100) == 0 else f"{float(_x / 100)}" for _x in major_ticks] - if len(labels) > 20: - labels = ["" if x.split(".")[-1][0] in "975" else x for x in labels] - elif len(labels) > 10: - labels = ["" if x.split(".")[-1][0] in "9" else x for x in labels] - ylabel = "pressure (hPa)" - elif typ == "pressure altitude": - bot_km = thermolib.pressure2flightlevel(self.p_bot * units.Pa).to(units.km).magnitude - top_km = thermolib.pressure2flightlevel(self.p_top * units.Pa).to(units.km).magnitude - ma_dist, mi_dist = 4, 1.0 - if (top_km - bot_km) <= 20: - ma_dist, mi_dist = 1, 0.5 - elif (top_km - bot_km) <= 40: - ma_dist, mi_dist = 2, 0.5 - major_heights = np.arange(0, top_km + 1, ma_dist) - minor_heights = np.arange(0, top_km + 1, mi_dist) - major_ticks = thermolib.flightlevel2pressure(major_heights * units.km).magnitude - minor_ticks = thermolib.flightlevel2pressure(minor_heights * units.km).magnitude - labels = major_heights - ylabel = "pressure altitude (km)" - elif typ == "flight level": - bot_km = thermolib.pressure2flightlevel(self.p_bot * units.Pa).to(units.km).magnitude - top_km = thermolib.pressure2flightlevel(self.p_top * units.Pa).to(units.km).magnitude - ma_dist, mi_dist = 50, 10 - if (top_km - bot_km) <= 10: - ma_dist, mi_dist = 20, 10 - elif (top_km - bot_km) <= 40: - ma_dist, mi_dist = 40, 10 - major_fl = np.arange(0, 2132, ma_dist) - minor_fl = np.arange(0, 2132, mi_dist) - major_ticks = thermolib.flightlevel2pressure(major_fl * units.hft).magnitude - minor_ticks = thermolib.flightlevel2pressure(minor_fl * units.hft).magnitude - labels = major_fl - ylabel = "flight level (hft)" - else: - raise RuntimeError(f"Unsupported vertical axis type: '{typ}'") - return ylabel, major_ticks, minor_ticks, labels - - def redraw_yaxis(self): - """ Redraws the y-axis on map after setting the values from sideview options dialog box - and also updates the sizes for map title and x and y axes labels and ticklabels""" - - vaxis = self.settings_dict["vertical_axis"] - vaxis2 = self.settings_dict["secondary_axis"] - - # Sets fontsize value for x axis ticklabel. - axes_label_size = (self.sideview_size_settings["axes_label_size"] - if self.settings_dict["axes_label_size"] == "default" - else int(self.settings_dict["axes_label_size"])) - # Sets fontsize value for plot title and axes title/label - plot_title_size = (self.sideview_size_settings["plot_title_size"] - if self.settings_dict["plot_title_size"] == "default" - else int(self.settings_dict["plot_title_size"])) - # Updates the fontsize of the x-axis ticklabels of sideview. - self.ax.tick_params(axis='x', labelsize=axes_label_size) - # Updates the fontsize of plot title and x-axis title of sideview. - title = self.ax.get_title() - if title == "": - title = "vertical flight profile" - self.ax.set_title(title, fontsize=plot_title_size, horizontalalignment="left", x=0) - self.ax.set_xlabel("lat/lon", fontsize=plot_title_size) - - for ax, typ in zip((self.ax, self.ax2), (vaxis, vaxis2)): - ylabel, major_ticks, minor_ticks, labels = self._determine_ticks_labels(typ) - - ax.set_ylabel(ylabel, fontsize=plot_title_size) - ax.set_yticks(minor_ticks, minor=True) - ax.set_yticks(major_ticks, minor=False) - ax.set_yticklabels([], minor=True) - ax.set_yticklabels(labels, minor=False, fontsize=axes_label_size) - ax.set_ylim(self.p_bot, self.p_top) - - if vaxis2 == "no secondary axis": - self.fig.subplots_adjust(left=0.08, right=0.96, top=0.9, bottom=0.14) - self.imgax.set_position(self.ax.get_position()) - else: - self.fig.subplots_adjust(left=0.08, right=0.92, top=0.9, bottom=0.14) - self.imgax.set_position(self.ax.get_position()) - - def setup_side_view(self): - """Set up a vertical section view. - - Vertical cross section code (log-p axis etc.) taken from - mss_batch_production/visualisation/mpl_vsec.py. - """ - - self.ax.set_title("vertical flight profile", horizontalalignment="left", x=0) - self.ax.grid(b=True) - - self.ax.set_xlabel("lat/lon") - - for ax in (self.ax, self.ax2): - ax.set_yscale("log") - ax.set_ylim(self.p_bot, self.p_top) - - self.redraw_yaxis() - - def clear_figure(self): - logging.debug("path of side view has changed.. removing invalidated " - "image (if existent) and redrawing.") - if self.image is not None: - self.image.remove() - self.image = None - self.ax.set_title("vertical flight profile", horizontalalignment="left", x=0) - self.ax.figure.canvas.draw() - def redraw_xaxis(self, lats, lons, times): """Redraw the x-axis of the side view on path changes. Also remove a vertical section image if one exists, as it is invalid after a path change. """ - logging.debug("redrawing x-axis") - - # Re-label x-axis. - self.ax.set_xlim(0, len(lats) - 1) - # Set xticks so that they display lat/lon. Plot "numlabels" labels. - lat_inds = np.arange(len(lats)) - tick_index_step = len(lat_inds) // self.numlabels - self.ax.set_xticks(lat_inds[::tick_index_step]) - - if self.waypoints_model is not None and self.waypoints_model.performance_settings["visible"]: - self.ax.set_xticklabels([f'{d[0]:2.1f}, {d[1]:2.1f}\n{d[2].strftime("%H:%M")}Z' - for d in zip(lats[::tick_index_step], - lons[::tick_index_step], - times[::tick_index_step])], - rotation=25, horizontalalignment="right") - else: - self.ax.set_xticklabels([f"{d[0]:2.1f}, {d[1]:2.1f}" - for d in zip(lats[::tick_index_step], - lons[::tick_index_step], - times[::tick_index_step])], - rotation=25, horizontalalignment="right") + times_visible = False + if self.waypoints_model is not None: + times_visible = self.waypoints_model.performance_settings["visible"] + self.myfig.redraw_xaxis(lats, lons, times, times_visible) for _line in self.ceiling_alt: _line.remove() @@ -738,31 +1200,11 @@ def redraw_xaxis(self, lats, lons, times): self.ceiling_alt = self.ax.plot( xs, thermolib.flightlevel2pressure(np.asarray(ys) * units.hft).magnitude, color="k", ls="--") - self.update_ceiling( - self.settings_dict["draw_ceiling"] and self.waypoints_model.performance_settings["visible"], - self.settings_dict["colour_ceiling"]) - - # Remove all vertical lines - for line in self.vertical_lines[:]: - try: - self.ax.lines.remove(line) - except ValueError as e: - logging.debug(f"Vertical line was somehow already removed:\n{e}") - self.vertical_lines.remove(line) - - # Add vertical lines - if self.settings_dict["draw_verticals"]: - ipoint = 0 + self.update_ceiling( + self.myfig.settings_dict["draw_ceiling"] and self.waypoints_model.performance_settings["visible"], + self.myfig.settings_dict["colour_ceiling"]) highlight = [[wp.lat, wp.lon] for wp in self.waypoints_model.waypoints] - for i, (lat, lon) in enumerate(zip(lats, lons)): - if (ipoint < len(highlight) and - np.hypot(lat - highlight[ipoint][0], - lon - highlight[ipoint][1]) < 2E-10): - self.vertical_lines.append( - self.ax.axvline(i, color='k', linewidth=2, linestyle='--', alpha=0.5)) - ipoint += 1 - - self.draw() + self.myfig.draw_vertical_lines(highlight, lats, lons) def get_vertical_extent(self): """Returns the bottom and top pressure (hPa) of the plot. @@ -777,7 +1219,7 @@ def draw_flight_levels(self): artist.remove() self.fl_label_list = [] # Plot lines indicating flight level altitude. - ax = self.ax + ax = self.myfig.ax for level in self.flightlevels: pressure = thermolib.flightlevel2pressure(level * units.hft).magnitude self.fl_label_list.append(ax.axhline(pressure, color='k')) @@ -814,15 +1256,15 @@ def get_settings(self): """Returns a dictionary containing settings regarding the side view appearance. """ - return self.settings_dict + return self.myfig.settings_dict def set_settings(self, settings): """Apply settings to view. """ - vertical_lines = self.settings_dict["draw_verticals"] + vertical_lines = self.myfig.settings_dict["draw_verticals"] if settings is not None: - self.settings_dict.update(settings) - settings = self.settings_dict + self.myfig.settings_dict.update(settings) + settings = self.myfig.settings_dict self.set_flight_levels(settings["flightlevels"]) self.set_flight_levels_visible(settings["draw_flightlevels"]) self.update_ceiling( @@ -850,7 +1292,7 @@ def set_settings(self, settings): self.redraw_xaxis(self.waypoints_interactor.path.ilats, self.waypoints_interactor.path.ilons, self.waypoints_interactor.path.itimes) - self.settings_dict = settings + self.myfig.settings_dict = settings def getBBOX(self): """Get the bounding box of the view (returns a 4-tuple @@ -859,7 +1301,7 @@ def getBBOX(self): # Get the bounding box of the current view # (bbox = llcrnrlon, llcrnrlat, urcrnrlon, urcrnrlat; i.e. for the side # view bbox = x1, y1(p_bot), x2, y2(p_top)). - axis = self.ax.axis() + axis = self.myfig.ax.axis() # Get the number of (great circle) interpolation points and the # number of labels along the x-axis. @@ -868,11 +1310,13 @@ def getBBOX(self): self.waypoints_interactor.get_num_interpolation_points() num_labels = self.numlabels - # Return a tuple (num_interpolation_points, p_bot[hPa], - # num_labels, p_top[hPa]) as BBOX. - bbox = (num_interpolation_points, (axis[2] / 100), - num_labels, (axis[3] / 100)) - return bbox + # Return a tuple (num_interpolation_points, p_bot[hPa], + # num_labels, p_top[hPa]) as BBOX. + bbox = (num_interpolation_points, (axis[2] / 100), + num_labels, (axis[3] / 100)) + return bbox + else: + self.myfig.getBBOX() def draw_legend(self, img): if img is not None: @@ -886,49 +1330,13 @@ def draw_image(self, img): below the axes that display the flight profile. This is necessary because imshow() does not work with logarithmic axes. """ - logging.debug("plotting vertical section image..") - ix, iy = img.size - logging.debug(" image size is %dx%d px, format is '%s'", ix, iy, img.format) - - # If an image is currently displayed, remove it from the plot. - if self.image is not None: - self.image.remove() - - # Plot the new image in the image axes and adjust the axes limits. - self.image = self.imgax.imshow( - img, interpolation="nearest", aspect="auto", origin=PIL_IMAGE_ORIGIN) - self.imgax.set_xlim(0, ix - 1) - self.imgax.set_ylim(iy - 1, 0) - self.draw() - logging.debug("done.") + self.myfig.draw_image(img) def update_vertical_extent_from_settings(self, init=False): """ Checks for current units of axis and convert the upper and lower limit to pa(pascals) for the internal computation by code """ - if not init: - p_bot_old = self.p_bot - p_top_old = self.p_top - - if self.settings_dict["vertical_axis"] == "pressure altitude": - self.p_bot = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][0] * units.km).magnitude - self.p_top = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][1] * units.km).magnitude - elif self.settings_dict["vertical_axis"] == "flight level": - self.p_bot = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][0] * units.hft).magnitude - self.p_top = thermolib.flightlevel2pressure(self.settings_dict["vertical_extent"][1] * units.hft).magnitude - else: - self.p_bot = self.settings_dict["vertical_extent"][0] * 100 - self.p_top = self.settings_dict["vertical_extent"][1] * 100 - - if not init: - if (p_bot_old != self.p_bot) or (p_top_old != self.p_top): - if self.image is not None: - self.image.remove() - self.image = None - self.setup_side_view() - self.waypoints_interactor.redraw_figure() - else: - self.redraw_yaxis() + self.myfig.update_vertical_extent_from_settings(init) class MplSideViewWidget(MplNavBarWidget): @@ -961,14 +1369,15 @@ def __init__(self, model=None, numlabels=None): """ if numlabels is None: numlabels = config_loader(dataset='num_labels') - super(MplLinearViewCanvas, self).__init__() + self.myfig = MyLinearViewFigure() + super(MplLinearViewCanvas, self).__init__(self.myfig) self.settings_dict = {"plot_title_size": "default", "axes_label_size": "default"} # Setup the plot. self.numlabels = numlabels - self.setup_linear_view() + self.myfig.setup_linear_view() # If a waypoints model has been passed, create an interactor on it. self.waypoints_interactor = None self.waypoints_model = None @@ -994,9 +1403,9 @@ def set_waypoints_model(self, model): # Create a path interactor object. The interactor object connects # itself to the change() signals of the flight track data model. self.waypoints_interactor = mpl_pi.LPathInteractor( - self.ax, self.waypoints_model, + self.myfig.ax, self.waypoints_model, numintpoints=config_loader(dataset="num_interpolation_points"), - clear_figure=self.clear_figure, + clear_figure=self.myfig.clear_figure, redraw_xaxis=self.redraw_xaxis ) self.redraw_xaxis() @@ -1007,12 +1416,6 @@ def setup_linear_view(self): """ self.fig.subplots_adjust(left=0.08, right=0.96, top=0.9, bottom=0.14) - def clear_figure(self): - logging.debug("path of linear view has changed.. removing invalidated plots") - self.ax.figure.clf() - self.ax = self.fig.add_subplot(111, zorder=99) - self.ax.figure.patch.set_visible(False) - def getBBOX(self): """Get the bounding box of the view. """ @@ -1031,6 +1434,10 @@ def draw_legend(self, img): logging.error("Legends not supported in LinearView mode!") raise NotImplementedError + def draw_image(self, xmls, colors=None, scales=None): + self.myfig.draw_image(xmls, colors, scales) + self.redraw_xaxis() + def redraw_xaxis(self): """Redraw the x-axis of the linear view on path changes. """ @@ -1039,96 +1446,16 @@ def redraw_xaxis(self): lons = self.waypoints_interactor.path.ilons logging.debug("redrawing x-axis") - # Re-label x-axis. - self.ax.set_xlim(0, len(lats) - 1) - # Set xticks so that they display lat/lon. Plot "numlabels" labels. - lat_inds = np.arange(len(lats)) - tick_index_step = len(lat_inds) // self.numlabels - self.ax.set_xticks(lat_inds[::tick_index_step]) - self.ax.set_xticklabels([f'{d[0]:2.1f}, {d[1]:2.1f}' - for d in zip(lats[::tick_index_step], - lons[::tick_index_step])], - rotation=25, horizontalalignment="right") - - # Remove all vertical lines - for line in self.vertical_lines[:]: - try: - self.ax.lines.remove(line) - except ValueError as e: - logging.debug(f"Vertical line was somehow already removed:\n{e}") - self.vertical_lines.remove(line) + self.myfig.redraw_xaxis(lats, lons) - ipoint = 0 highlight = [[wp.lat, wp.lon] for wp in self.waypoints_model.waypoints] - for i, (lat, lon) in enumerate(zip(lats, lons)): - if (ipoint < len(highlight) and - np.hypot(lat - highlight[ipoint][0], - lon - highlight[ipoint][1]) < 2E-10): - self.vertical_lines.append(self.ax.axvline(i, color='k', linewidth=2, linestyle='--', alpha=0.5)) - ipoint += 1 - self.draw() - - def draw_image(self, xmls, colors=None, scales=None): - self.clear_figure() - offset = 40 - self.ax.patch.set_visible(False) - - for i, xml in enumerate(xmls): - data = xml.find("Data") - values = [float(value) for value in data.text.split(",")] - unit = data.attrib["unit"] - numpoints = int(data.attrib["num_waypoints"]) - - if colors: - color = colors[i] if len(colors) > i else colors[-1] - else: - color = "#00AAFF" - - if scales: - scale = scales[i] if len(scales) > i else scales[-1] - else: - scale = "linear" - - par = self.ax.twinx() if i > 0 else self.ax - par.set_yscale(scale) - - par.plot(range(numpoints), values, color) - if i > 0: - par.spines["right"].set_position(("outward", (i - 1) * offset)) - if unit: - par.set_ylabel(unit) - - par.yaxis.label.set_color(color.replace("0x", "#")) - self.redraw_xaxis() - self.fig.tight_layout() - self.fig.subplots_adjust(top=0.85, bottom=0.20) - # self.set_settings(self.settings_dict) - self.draw() - - def get_settings(self): - """Returns a dictionary containing settings regarding the linear view - appearance. - """ - return self.settings_dict + self.myfig.draw_vertical_lines(highlight, lats, lons) def set_settings(self, settings): """ Apply settings from options ui to the linear view """ - - if settings is not None: - self.settings_dict.update(settings) - - pts = (self.linearview_size_settings["plot_title_size"] if self.settings_dict["plot_title_size"] == "default" - else int(self.settings_dict["plot_title_size"])) - als = (self.linearview_size_settings["axes_label_size"] if self.settings_dict["axes_label_size"] == "default" - else int(self.settings_dict["axes_label_size"])) - self.ax.tick_params(axis='both', labelsize=als) - title = self.ax.get_title() - if title == "": - title = "Linear flight profile" - self.ax.set_title(title, fontsize=pts, horizontalalignment='left', x=0) - self.draw() + self.myfig.set_settings(settings) class MplLinearViewWidget(MplNavBarWidget): @@ -1159,24 +1486,18 @@ class MplTopViewCanvas(MplCanvas): def __init__(self, settings=None): """ """ - super(MplTopViewCanvas, self).__init__() + self.myfig = MyTopViewFigure() + super(MplTopViewCanvas, self).__init__(self.myfig) self.waypoints_interactor = None self.satoverpasspatch = [] self.kmloverlay = None self.multiple_flightpath = None - self.map = None self.basename = "topview" # Axes and image object to display the legend graphic, if available. self.legax = None self.legimg = None - # stores the topview plot title size(tov_pts) and topview axes label size(tov_als),initially as None. - self.tov_pts = None - self.tov_als = None - # Sets the default fontsize parameters' values for topview from MSSDefaultConfig. - self.topview_size_settings = config_loader(dataset="topview") - # Set map appearance from parameter or, if not specified, to default # values. self.set_map_appearance(settings) @@ -1185,26 +1506,14 @@ def __init__(self, settings=None): self.pdlg = QtWidgets.QProgressDialog("redrawing map...", "Cancel", 0, 10, self) self.pdlg.close() + @property + def map(self): + return self.myfig.map + def init_map(self, model=None, **kwargs): """Set up the map view. """ - ax = self.ax - self.map = mpl_map.MapCanvas(appearance=self.get_map_appearance(), - resolution="l", area_thresh=1000., ax=ax, - **kwargs) - - # Sets the selected fontsize only if draw_graticule box from topview options is checked in. - if self.appearance_settings["draw_graticule"]: - try: - self.map._draw_auto_graticule(self.tov_als) - except Exception as ex: - logging.error("ERROR: cannot plot graticule (message: %s - '%s')", type(ex), ex) - else: - self.map.set_graticule_visible(self.appearance_settings["draw_graticule"]) - - ax.set_autoscale_on(False) - ax.set_title("Top view", fontsize=self.tov_pts, horizontalalignment="left", x=0) - self.draw() # necessary? + self.myfig.init_map(**kwargs) if model: self.set_waypoints_model(model) @@ -1223,7 +1532,7 @@ def set_waypoints_model(self, model): appearance = self.get_map_appearance() try: self.waypoints_interactor = mpl_pi.HPathInteractor( - self.map, self.waypoints_model, + self.myfig.map, self.waypoints_model, linecolor=appearance["colour_ft_vertices"], markerfacecolor=appearance["colour_ft_waypoints"], show_marker=appearance["draw_marker"]) @@ -1255,18 +1564,7 @@ def redraw_map(self, kwargs_update=None): self.pdlg.setValue(1) QtWidgets.QApplication.processEvents() - # 2) UPDATE MAP. - self.map.update_with_coordinate_change(kwargs_update) - - # Sets the graticule ticklabels/labels fontsize for topview when map is redrawn. - if self.appearance_settings["draw_graticule"]: - self.map.set_graticule_visible(False) - self.map._draw_auto_graticule(self.tov_als) - else: - self.map.set_graticule_visible(self.appearance_settings["draw_graticule"]) - self.draw() # this one is required to trigger a - # drawevent to update the background - # in waypoints_interactor() + self.myfig.redraw_map(kwargs_update) self.pdlg.setValue(5) QtWidgets.QApplication.processEvents() @@ -1306,98 +1604,27 @@ def redraw_map(self, kwargs_update=None): def get_crs(self): """Get the coordinate reference system of the displayed map. """ - return self.map.crs + return self.myfig.map.crs def getBBOX(self): """ Get the bounding box of the map (returns a 4-tuple llx, lly, urx, ury) in degree or meters. """ - - axis = self.ax.axis() - - if self.map.bbox_units == "degree": - # Convert the current axis corners to lat/lon coordinates. - axis0, axis2 = self.map(axis[0], axis[2], inverse=True) - axis1, axis3 = self.map(axis[1], axis[3], inverse=True) - bbox = (axis0, axis2, axis1, axis3) - - elif self.map.bbox_units.startswith("meter"): - center_x, center_y = self.map( - *(float(_x) for _x in self.map.bbox_units[6:-1].split(","))) - bbox = (axis[0] - center_x, axis[2] - center_y, axis[1] - center_x, axis[3] - center_y) - - else: - bbox = axis[0], axis[2], axis[1], axis[3] - - return bbox + return self.myfig.getBBOX() def clear_figure(self): logging.debug("Removing image") - if self.map.image is not None: - self.map.image.remove() - self.map.image = None - self.ax.set_title("Top view", horizontalalignment="left", x=0) - self.ax.figure.canvas.draw() + self.myfig.clear_figure() def draw_image(self, img): - """Draw the image img on the current plot. - """ - logging.debug("plotting image..") - self.wms_image = self.map.imshow(img, interpolation="nearest", origin=PIL_IMAGE_ORIGIN) - # NOTE: imshow always draws the images to the lowest z-level of the - # plot. - # See these mailing list entries: - # http://www.mail-archive.com/matplotlib-devel@lists.sourceforge.net/msg05955.html - # http://old.nabble.com/Re%3A--Matplotlib-users--imshow-zorder-tt19047314.html#a19047314 - # - # Question: Is this an issue for us or do we always want the images in the back - # anyhow? At least we need to remove filled continents here. - # self.map.set_fillcontinents_visible(False) - # ** UPDATE 2011/01/14 ** seems to work with version 1.0! - logging.debug("done.") + self.myfig.draw_image(img) def draw_legend(self, img): """Draw the legend graphics img on the current plot. - Adds new axes to the plot that accomodate the legend. """ - # If the method is called with a "None" image, the current legend - # graphic should be removed (if one exists). - if self.legimg is not None: - logging.debug("removing image %s", self.legimg) - self.legimg.remove() - self.legimg = None - - if img is not None: - # The size of the legend axes needs to be given in relative figure - # coordinates. To determine those from the legend graphics size in - # pixels, we need to determine the size of the currently displayed - # figure in pixels. - figsize_px = self.fig.get_size_inches() * self.fig.get_dpi() - ax_extent_x = float(img.size[0]) / figsize_px[0] - ax_extent_y = float(img.size[1]) / figsize_px[1] - - # If no legend axes have been created, do so now. - if self.legax is None: - # Main axes instance of mplwidget has zorder 99. - self.legax = self.fig.add_axes([1 - ax_extent_x, 0.01, ax_extent_x, ax_extent_y], - frameon=False, - xticks=[], yticks=[], - label="ax2", zorder=0) - self.legax.patch.set_facecolor("None") - # self.legax.set_yticklabels("ax2", fontsize=32, minor=False) - # print("Legax=if" + self.legax) - - # If axes exist, adjust their position. - else: - self.legax.set_position([1 - ax_extent_x, 0.01, ax_extent_x, ax_extent_y]) - # self.legax.set_yticklabels("ax2", fontsize=32, minor=False) - # print("Legax=else" + self.legax) - # Plot the new legimg in the legax axes. - self.legimg = self.legax.imshow(img, origin=PIL_IMAGE_ORIGIN, aspect="equal", interpolation="nearest") - # print("Legimg" + self.legimg) - self.draw() + self.myfig.draw_legend(img) # required so that it is actually drawn... QtWidgets.QApplication.processEvents() @@ -1426,65 +1653,23 @@ def plot_multiple_flightpath(self, multipleflightpath): """ self.multiple_flightpath = multipleflightpath + def get_map_appearance(self): + return self.myfig.get_map_appearance() + def set_map_appearance(self, settings_dict): """Apply settings from dictionary 'settings_dict' to the view. If settings is None, apply default settings. """ - # logging.debug("applying map appearance settings %s." % settings) - settings = {"draw_graticule": True, - "draw_coastlines": True, - "fill_waterbodies": True, - "fill_continents": True, - "draw_flighttrack": True, - "draw_marker": True, - "label_flighttrack": True, - "tov_plot_title_size": "default", - "tov_axes_label_size": "default", - "colour_water": ((153 / 255.), (255 / 255.), (255 / 255.), (255 / 255.)), - "colour_land": ((204 / 255.), (153 / 255.), (102 / 255.), (255 / 255.)), - "colour_ft_vertices": (0, 0, 1, 1), - "colour_ft_waypoints": (1, 0, 0, 1)} - if settings_dict is not None: - settings.update(settings_dict) - - # Stores the exact value of fontsize for topview plot title size(tov_pts) - self.tov_pts = (self.topview_size_settings["plot_title_size"] if settings["tov_plot_title_size"] == "default" - else int(settings["tov_plot_title_size"])) - # Stores the exact value of fontsize for topview axes label size(tov_als) - self.tov_als = (self.topview_size_settings["axes_label_size"] if settings["tov_axes_label_size"] == "default" - else int(settings["tov_axes_label_size"])) - - self.appearance_settings = settings - ax = self.ax - - if self.map is not None: - self.map.set_coastlines_visible(settings["draw_coastlines"]) - self.map.set_fillcontinents_visible(visible=settings["fill_continents"], - land_color=settings["colour_land"], - lake_color=settings["colour_water"]) - self.map.set_mapboundary_visible(visible=settings["fill_waterbodies"], - bg_color=settings["colour_water"]) - self.waypoints_interactor.set_path_color(line_color=settings["colour_ft_vertices"], - marker_facecolor=settings["colour_ft_waypoints"]) - self.waypoints_interactor.show_marker = settings["draw_marker"] - self.waypoints_interactor.set_vertices_visible(settings["draw_flighttrack"]) - self.waypoints_interactor.set_labels_visible(settings["label_flighttrack"]) - - # Updates plot title size as selected from combobox labelled plot title size. - ax.set_autoscale_on(False) - title = self.ax.get_title() - if title == "": - title = "Top View" - ax.set_title(title, fontsize=self.tov_pts, horizontalalignment="left", x=0) + self.myfig.set_map_appearance(settings_dict) + if self.waypoints_interactor is not None: + self.waypoints_interactor.set_path_color(line_color=settings_dict["colour_ft_vertices"], + marker_facecolor=settings_dict["colour_ft_waypoints"]) + self.waypoints_interactor.show_marker = settings_dict["draw_marker"] + self.waypoints_interactor.set_vertices_visible(settings_dict["draw_flighttrack"]) + self.waypoints_interactor.set_labels_visible(settings_dict["label_flighttrack"]) - # Updates graticule ticklabels/labels fontsize if draw_graticule is True. - if settings["draw_graticule"]: - self.map.set_graticule_visible(False) - self.map._draw_auto_graticule(self.tov_als) - else: - self.map.set_graticule_visible(settings["draw_graticule"]) - self.draw() + self.draw() def set_remote_sensing_appearance(self, settings): self.waypoints_interactor.set_remote_sensing(settings["reference"]) @@ -1493,11 +1678,6 @@ def set_remote_sensing_appearance(self, settings): self.waypoints_interactor.redraw_path() - def get_map_appearance(self): - """ - """ - return self.appearance_settings - class MplTopViewWidget(MplNavBarWidget): """MplNavBarWidget using an MplSideViewCanvas as the Matplotlib diff --git a/mslib/plot.py b/mslib/plot.py new file mode 100644 index 000000000..7d52b2821 --- /dev/null +++ b/mslib/plot.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +""" + + mslib.plot + ~~~~~~~~~~ + + An automated plotting module used for plotting in top, side and linear views. + + This file is part of MSS. + + :copyright: Copyright 2022 Joern Ungermann + :copyright: Copyright 2022 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 datetime +import io +import os +import xml +from fs import open_fs +import PIL.Image +import matplotlib + +import mslib +import mslib.utils +import mslib.msui +import mslib.msui.mpl_map +import mslib.utils.qt +import mslib.utils.thermolib +from mslib.utils.config import config_loader, read_config_file +from mslib.utils.units import units +from mslib.msui.wms_control import MSUIWebMapService +import matplotlib.pyplot as plt +import defusedxml.ElementTree as etree +import hashlib +from mslib.msui import constants +from mslib.msui import wms_control +from mslib.msui import mpl_qtwidget as qt +from mslib.msui import mpl_pathinteractor as mpath +from mslib.msui import flighttrack as ft + + +TEXT_CONFIG = { + "bbox": dict(boxstyle="round", facecolor="white", alpha=0.5, edgecolor="none"), + "fontweight": "bold", "zorder": 4, "fontsize": 6, "clip_on": True} + + +def load_from_ftml(filename): + """Load a flight track from an XML file at . + """ + _dirname, _name = os.path.split(filename) + _fs = open_fs(_dirname) + datasource = _fs.open(_name) + wp_list = load_from_xml_data(datasource) + return wp_list + + +def load_from_xml_data(datasource): + try: + doc = xml.dom.minidom.parse(datasource) + except xml.parsers.expat.ExpatError as ex: + raise SyntaxError(str(ex)) + + ft_el = doc.getElementsByTagName("FlightTrack")[0] + + waypoints_list1 = [] + waypoints_list2 = [] + for wp_el in ft_el.getElementsByTagName("Waypoint"): + + location = wp_el.getAttribute("location") + lat = float(wp_el.getAttribute("lat")) + lon = float(wp_el.getAttribute("lon")) + flightlevel = float(wp_el.getAttribute("flightlevel")) + comments = wp_el.getElementsByTagName("Comments")[0] + # If num of comments is 0(null comment), then return '' + if len(comments.childNodes): + comments = comments.childNodes[0].data.strip() + else: + comments = '' + + waypoints_list1.append((lat, lon, flightlevel, location, comments)) + waypoints_list2.append(ft.Waypoint(lat, lon, flightlevel, location, comments)) + waypoints_list2[-1].utc_time = datetime.datetime.now() + return waypoints_list1, waypoints_list2 + + +class Plotting(): + def __init__(self, cpath): + read_config_file(cpath) + self.config = config_loader() + self.num_interpolation_points = self.config["num_interpolation_points"] + self.num_labels = self.config["num_labels"] + self.tick_index_step = self.num_interpolation_points // self.num_labels + self.wps = None + self.bbox = None + self.wms_cache = config_loader(dataset="wms_cache") + self.fig = plt.figure() + section = self.config["automated_plotting_flights"][0][1] + filename = self.config["automated_plotting_flights"][0][3] + self.params = mslib.utils.coordinate.get_projection_params( + self.config["predefined_map_sections"][section]["CRS"].lower()) + self.params["basemap"].update(self.config["predefined_map_sections"][section]["map"]) + print(self.params) + self.bbox_units = self.params["bbox"] + self.wpslist = [] + self.wpslist = load_from_ftml(filename) + self.wps = self.wpslist[0] + self.wp_model_data = self.wpslist[1] + self.wp_lats, self.wp_lons, self.wp_locs = [[x[i] for x in self.wps] for i in [0, 1, 3]] + self.wp_press = [mslib.utils.thermolib.flightlevel2pressure(wp[2] * units.hft).to("Pa").m for wp in self.wps] + self.fig.clear() + self.ax = self.fig.add_subplot(111, zorder=99) + self.path = [(wp[0], wp[1], datetime.datetime.now()) for wp in self.wps] + self.vertices = [list(a) for a in (zip(self.wp_lons, self.wp_lats))] + self.lats, self.lons = mslib.utils.coordinate.path_points([_x[0] for _x in self.path], + [_x[1] for _x in self.path], + numpoints=self.num_interpolation_points + 1, + connection="greatcircle") + + +class TopViewPlotting(Plotting): + def __init__(self, cpath): + super(TopViewPlotting, self).__init__(cpath) + self.myfig = qt.MyTopViewFigure() + self.myfig.fig.canvas.draw() + self.line = None + matplotlib.backends.backend_agg.FigureCanvasAgg(self.myfig.fig) + self.ax = self.myfig.ax + self.fig = self.myfig.fig + self.myfig.init_map(**(self.params["basemap"])) + self.myfig.set_map() + self.plotter = mpath.PathH_GCPlotter(self.myfig.map) + + def TopViewPath(self): + # plot path and label + self.fig.canvas.draw() + wp_lats, wp_lons, wp_locs = [[x[i] for x in self.wps] for i in [0, 1, 3]] + self.plotter.update_from_waypoints(self.wp_model_data) + self.plotter.redraw_path(waypoints_model_data=self.wp_model_data) + + def TopViewDraw(self): + for flight, section, vertical, filename, init_time, time in \ + self.config["automated_plotting_flights"]: + for url, layer, style, elevation in self.config["automated_plotting_hsecs"]: + self.draw(flight, section, vertical, filename, init_time, + time, url, layer, style, elevation, no_of_plots=1) + + def draw(self, flight, section, vertical, filename, init_time, time, url, layer, style, elevation, no_of_plots): + width, height = self.myfig.get_plot_size_in_px() + self.bbox = self.params['basemap'] + if not init_time: + init_time = None + + kwargs = {"layers": [layer], + "styles": [style], + "time": time, + "init_time": init_time, + "exceptions": 'application/vnd.ogc.se_xml', + "level": elevation, + "srs": self.config["predefined_map_sections"][section]["CRS"], + "bbox": (self.bbox['llcrnrlon'], self.bbox['llcrnrlat'], + self.bbox['urcrnrlon'], self.bbox['urcrnrlat'] + ), + "format": "image/png", + "size": (width, height) + } + + wms = wms_control.MSUIWebMapService(url, + username=self.config["WMS_login"][url][0], + password=self.config["WMS_login"][url][1], + version='1.3.0' + ) + + img = wms.getmap(**kwargs) + image_io = io.BytesIO(img.read()) + img = PIL.Image.open(image_io) + self.myfig.draw_image(img) + self.myfig.fig.savefig(f"{flight}_{layer}_{no_of_plots}.png") + + +class SideViewPlotting(Plotting): + def __init__(self): + super(SideViewPlotting, self).__init__() + self.myfig = qt.MySideViewFigure() + self.ax = self.myfig.ax + self.fig = self.myfig.fig + self.tick_index_step = self.num_interpolation_points // self.num_labels + self.fig.canvas.draw() + matplotlib.backends.backend_agg.FigureCanvasAgg(self.myfig.fig) + + def setup(self): + self.intermediate_indexes = [] + ipoint = 0 + for i, (lat, lon) in enumerate(zip(self.lats, self.lons)): + if abs(lat - self.wps[ipoint][0]) < 1E-10 and abs(lon - self.wps[ipoint][1]) < 1E-10: + self.intermediate_indexes.append(i) + ipoint += 1 + if ipoint >= len(self.wps): + break + self.myfig.setup_side_view() + times = None + times_visible = False + self.myfig.redraw_xaxis(self.lats, self.lons, times, times_visible) + + def SideViewPath(self): + self.fig.canvas.draw() + self.plotter = mpath.PathV_Plotter(self.myfig.ax) + self.plotter.update_from_waypoints(self.wp_model_data) + indices = list(zip(self.intermediate_indexes, self.wp_press)) + self.plotter.redraw_path(vertices=indices, + waypoints_model_data=self.wp_model_data) + highlight = [[wp[0], wp[1]] for wp in self.wps] + self.myfig.draw_vertical_lines(highlight, self.lats, self.lons) + + def SideViewDraw(self): + for flight, section, vertical, filename, init_time, time in \ + self.config["automated_plotting_flights"]: + for url, layer, style, vsec_type in self.config["automated_plotting_vsecs"]: + width, height = self.myfig.get_plot_size_in_px() + p_bot, p_top = [float(x) * 100 for x in vertical.split(",")] + self.bbox = tuple([x for x in (self.num_interpolation_points, + p_bot / 100, self.num_labels, p_top / 100)] + ) + + if not init_time: + init_time = None + + kwargs = {"layers": [layer], + "styles": [style], + "time": time, + "init_time": init_time, + "exceptions": 'application/vnd.ogc.se_xml', + "srs": "VERT:LOGP", + "path_str": ",".join(f"{wp[0]:.2f},{wp[1]:.2f}" for wp in self.wps), + "bbox": self.bbox, + "format": "image/png", + "size": (width, height) + } + wms = MSUIWebMapService(url, + username=self.config["WMS_login"][url][0], + password=self.config["WMS_login"][url][1], + version='1.3.0' + ) + + img = wms.getmap(**kwargs) + + image_io = io.BytesIO(img.read()) + img = PIL.Image.open(image_io) + self.myfig.draw_image(img) + self.myfig.fig.savefig(f"{flight}_{layer}.png", bbox_inches='tight') + + +class LinearViewPlotting(Plotting): + def __init__(self): + super(LinearViewPlotting, self).__init__() + self.myfig = qt.MyLinearViewFigure() + self.ax = self.myfig.ax + matplotlib.backends.backend_agg.FigureCanvasAgg(self.myfig.fig) + self.fig = self.myfig.fig + + def setup(self): + self.bbox = (self.num_interpolation_points,) + linearview_size_settings = config_loader(dataset="linearview") + settings_dict = {"plot_title_size": linearview_size_settings["plot_title_size"], + "axes_label_size": linearview_size_settings["axes_label_size"]} + self.myfig.set_settings(settings_dict) + self.myfig.setup_linear_view() + + def LinearViewDraw(self): + for flight, section, vertical, filename, init_time, time in \ + self.config["automated_plotting_flights"]: + for url, layer, style in self.config["automated_plotting_lsecs"]: + width, height = self.myfig.get_plot_size_in_px() + + if not init_time: + init_time = None + + wms = MSUIWebMapService(url, + username=self.config["WMS_login"][url][0], + password=self.config["WMS_login"][url][1], + version='1.3.0') + + path_string = "" + for i, wp in enumerate(self.wps): + path_string += f"{wp[0]:.2f},{wp[1]:.2f},{self.wp_press[i]}," + path_string = path_string[:-1] + + # retrieve and draw image + kwargs = {"layers": [layer], + "styles": [style], + "time": time, + "init_time": init_time, + "exceptions": 'application/vnd.ogc.se_xml', + "srs": "LINE:1", + "path_str": path_string, + "bbox": self.bbox, + "format": "text/xml", + "size": (width, height) + } + + xmls = wms.getmap(**kwargs) + urlstr = wms.getmap(return_only_url=True, **kwargs) + ending = ".xml" + + if not os.path.exists(self.wms_cache): + os.makedirs(self.wms_cache) + md5_filename = os.path.join(self.wms_cache, hashlib.md5(urlstr.encode('utf-8')).hexdigest() + ending) + with open(md5_filename, "w") as cache: + cache.write(str(xmls.read(), encoding="utf8")) + if not type(xmls) == 'list': + xmls = [xmls] + + xml_objects = [] + for xml_ in xmls: + xml_data = etree.fromstring(xml_.read()) + xml_objects.append(xml_data) + + self.myfig.draw_image(xml_objects, colors=None, scales=None) + self.myfig.redraw_xaxis(self.lats, self.lons) + highlight = [[wp[0], wp[1]] for wp in self.wps] + self.myfig.draw_vertical_lines(highlight, self.lats, self.lons) + self.myfig.fig.savefig(f"{flight}_{layer}.png", bbox_inches='tight') + + +def main(): + cpath = constants.MSUI_SETTINGS + h = TopViewPlotting(cpath) + h.TopViewPath() + h.TopViewDraw() + v = SideViewPlotting() + v.setup() + v.SideViewPath() + v.SideViewDraw() + ls = LinearViewPlotting() + ls.setup() + ls.LinearViewDraw() + + +if __name__ == "__main__": + main() diff --git a/mslib/utils/config.py b/mslib/utils/config.py index b83cc677d..4d6b2d8d1 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -225,6 +225,11 @@ class MSUIDefaultConfig(object): linearview = {"plot_title_size": 10, "axes_label_size": 10} + automated_plotting_flights = [[]] + automated_plotting_hsecs = [[]] + automated_plotting_vsecs = [[]] + automated_plotting_lsecs = [[]] + # Dictionary options with fixed key/value pairs fixed_dict_options = ["layout", "wms_prefetch", "topview", "sideview", "linearview"] @@ -289,6 +294,10 @@ class MSUIDefaultConfig(object): "new_flighttrack_template": ["new-location"], "gravatar_ids": ["example@email.com"], "WMS_preload": ["https://wms-preload-url.com"], + "automated_plotting_flights": [["", "", "", "", "", ""]], + "automated_plotting_hsecs": [["http://www.your-wms-server.de", "", "", ""]], + "automated_plotting_vsecs": [["http://www.your-wms-server.de", "", "", ""]], + "automated_plotting_lsecs": [["http://www.your-wms-server.de", "", ""]] } config_descriptions = { diff --git a/mslib/utils/mssautoplot.py b/mslib/utils/mssautoplot.py new file mode 100644 index 000000000..9aae87e8a --- /dev/null +++ b/mslib/utils/mssautoplot.py @@ -0,0 +1,91 @@ +""" + + mslib.utils.mssautoplot + ~~~~~~~~~~~~~~~~~~~~~~~ + + A CLI tool to create for instance a number of the same plots + for several flights or several forecast steps + + This file is part of MSS. + + :copyright: Copyright 2022 Sreelakshmi Jayarajan + :copyright: Copyright 2022 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 click +import logging +from mslib import plot +from datetime import datetime, timedelta +from mslib.utils import config as conf +from mslib.msui import constants + + +@click.command() +@click.option('--cpath', default=constants.MSS_AUTOPLOT, help='Path of the configuration file.') +@click.option('--ftrack', default="", help='Flight track.') +@click.option('--itime', default="", help='Initial time.') +@click.option('--vtime', default="", help='Valid time.') +@click.option('--intv', default=0, help='Time interval.') +@click.option('--stime', default="", help='Starting time for downloading multiple plots with a fixed interval.') +@click.option('--etime', default="", help='Ending time for downloading multiple plots with a fixed interval.') +def main(cpath, ftrack, itime, vtime, intv, stime, etime): + conf.read_config_file(path=cpath) + config = conf.config_loader() + a = plot.TopViewPlotting(cpath) + for flight, section, vertical, filename, init_time, time in \ + config["automated_plotting_flights"]: + for url, layer, style, elevation in config["automated_plotting_hsecs"]: + if vtime == "" and stime == "": + a.TopViewPath() + a.draw(flight, section, vertical, filename, init_time, + time, url, layer, style, elevation, no_of_plots=1) + click.echo("Plot downloaded!\n") + elif intv == 0: + if itime != "": + inittime = datetime.strptime(itime, "%Y-%m-%dT" "%H:%M:%S") + else: + inittime = itime + time = datetime.strptime(vtime, "%Y-%m-%dT" "%H:%M:%S") + if(ftrack != ""): + flight = ftrack + a.TopViewPath() + a.draw(flight, section, vertical, filename, init_time, + time, url, layer, style, elevation, no_of_plots=1) + click.echo("Plot downloaded!\n") + elif intv > 0: + if itime != "": + inittime = datetime.strptime(itime, "%Y-%m-%dT" "%H:%M:%S") + else: + inittime = itime + starttime = datetime.strptime(stime, "%Y-%m-%dT" "%H:%M:%S") + endtime = datetime.strptime(etime, "%Y-%m-%dT" "%H:%M:%S") + i = 1 + time = starttime + while time <= endtime: + logging.debug(time) + if ftrack != "": + flight = ftrack + a.TopViewPath() + a.draw(flight, section, vertical, filename, inittime, + time, url, layer, style, elevation, no_of_plots=i) + time = time + timedelta(hours=intv) + i = i + 1 + click.echo("Plots downloaded!\n") + else: + raise Exception("Invalid interval") + + +if __name__ == '__main__': + main()