Source code for hynet.visual.capability.visualizer

#pylint: disable=logging-fstring-interpolation
"""
Visualization of a capability region.
"""

import logging
import gc
from collections import deque

import tkinter as tk
from tkinter import ttk

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from matplotlib.patches import Polygon

from hynet.scenario.capability import CapRegion, HalfSpace
from hynet.visual.capability.utilities import (Axis,
                                               Bound,
                                               LinearFunction,
                                               Orientation,
                                               Point)
from hynet.visual.capability.settings import SettingsView

_log = logging.getLogger(__name__)

MESSAGE_DISAPPEAR_WAIT_TIME = 2500

SAVE_FILETYPES = (
    ("png files", "*.png"), ("pdf files", "*.pdf"), ("ps files", "*.ps"),
    ("eps files", "*.eps"), ("svg files", "*.svg"), ("all files", "*.*")
)


[docs]class Window(ttk.Frame): # pylint: disable=too-many-ancestors """This class creates a window that visualizes a capability region.""" SCALING_FACTOR = 1 CAPABILITY_REGION_COLOR = '#BBBBBB' HALFSPACES_COLOR = '#777777' POLYGON_EDGE_COLOR = '#777777' POLYGON_FILL_COLOR = '#BAEBAC' # this map stores the matplotlib lines as Axis x Bound -> line associations _limit_lines = {} # this map stores the matplotlib lines as # {LEFT, RIGHT} x {TOP, BOTTOM} -> line associations _halfspace_lines = {} _polygon = [] _callback_queue = deque() def __init__(self, master=None, capability_region=CapRegion(), edit=True): super().__init__(master, padding='3 3 12 12') self._master = master self._cap_region = capability_region self.grid(row=0, column=0, sticky=tk.N + tk.S + tk.E + tk.W) self.fig = Figure(tight_layout=True) self._ax = self.fig.add_subplot(1, 1, 1) self._configure_canvas() self._canvas = FigureCanvasTkAgg(self.fig, master=master) self._canvas.show() self._canvas.get_tk_widget().grid(row=0, column=0, padx=0, pady=0, sticky=[tk.N, tk.S, tk.E, tk.W]) self._limit_lines = {} self._halfspace_lines = {} self._polygon = [] self._callback_queue = deque() if edit: self._settings_view = SettingsView(master=master, cap_region=capability_region) self._settings_view.container.grid(row=0, column=1, padx=(20, 0), pady=0, sticky=[tk.W, tk.E, tk.S, tk.N]) self._setup_capabilityregion_callbacks() self._setup_halfspace_callbacks() self._settings_view.power_factor.set_callback( self._update_powerfactor) self._settings_view.error_callback = self._show_message self._message_label = tk.Label(master=self._master, padx=0, pady=0, anchor=tk.S, height=2, fg="red") self._message_label.grid(row=1, column=0, padx=10, pady=(0, 10), sticky=[tk.N, tk.S, tk.E, tk.W]) self.columnconfigure(0, weight=1) self.columnconfigure(1, weight=1) self.rowconfigure(0, weight=1) self._update_view() def _update_powerfactor(self, value): try: self._cap_region.add_power_factor_limit(value) # update the values from the capability region self._settings_view.update_from_capability_region() except ValueError as error: self._show_message(str(error)) self._update_view() def _show_message(self, message, undo_callback=None): def _callback_now(): self._message_label.config(text=message) _log.warning(message) _log.debug( f"_callback_now: Length of queue: {len(self._callback_queue)}") if self._callback_queue: next_callback = self._callback_queue.popleft() self.after(MESSAGE_DISAPPEAR_WAIT_TIME, next_callback) def _delayed_callback(): _log.debug( f"Delayed_call: Length of queue: {len(self._callback_queue)} " f"With callback?: {not (undo_callback is None)}") if undo_callback: undo_callback() self._message_label.config(text='') if self._callback_queue: next_callback = self._callback_queue.popleft() self.after(MESSAGE_DISAPPEAR_WAIT_TIME, next_callback) _log.debug(f"Length of queue: {len(self._callback_queue)}") if self._callback_queue: self._callback_queue.append(_callback_now) self._callback_queue.append(_delayed_callback) else: # the order is important! self._callback_queue.append(_delayed_callback) _callback_now() def _update_view(self): self._update_polygon() self._draw_capability_region() # self.draw_halfspaces() self._ax.relim(visible_only=True) self._ax.autoscale() self._canvas.draw() def _configure_canvas(self): self._ax.autoscale() self._ax.grid(True, which='both') self._ax.set_xlabel('P', horizontalalignment="center", x=1.0) y_label = self._ax.set_ylabel('Q', horizontalalignment="center", y=1.0) y_label.set_rotation(0) # turn off the right spine/ticks self._ax.spines['right'].set_color('none') # turn off the top spine/ticks self._ax.spines['top'].set_color('none') # draw axes at (0,0) self._ax.spines['left'].set_position(('data', 0.0)) self._ax.spines['bottom'].set_position(('data', 0.0)) # equally distant axes self._ax.axis('equal') def _setup_capabilityregion_callbacks(self): # arrow heads: http://felix11h.github.io/blog/arrowheads-matplotlib def _create_value_change_callback(capability_region, axis, bound): def _callback(value): # pylint: disable=too-many-branches axis_tuple = (axis, bound) if axis_tuple == (Axis.REACTIVE_POWER, Bound.MIN): if capability_region.q_max < value: raise ValueError( f"Please choose a smaller value.\n{value} " f"exceeds the upper bound on reactive power.") else: capability_region.q_min = value elif axis_tuple == (Axis.REACTIVE_POWER, Bound.MAX): if capability_region.q_min > value: raise ValueError( f"Please choose a larger value.\n{value} " f"is below the lower bound on reactive power.") else: capability_region.q_max = value elif axis_tuple == (Axis.ACTIVE_POWER, Bound.MIN): if capability_region.p_max < value: raise ValueError( f"Please choose a smaller value.\n{value} " f"exceeds the upper bound on active power.") else: capability_region.p_min = value elif axis_tuple == (Axis.ACTIVE_POWER, Bound.MAX): if capability_region.q_min > value: raise ValueError( f"Please choose a larger value.\n{value} " f"is below the lower bound on active power.") else: capability_region.p_max = value else: ValueError("No implementation for axis " + str(axis) + " and bound " + str(bound)) p_same = self._cap_region.p_min == self._cap_region.p_max q_same = self._cap_region.q_min == self._cap_region.q_max if p_same or q_same: self._settings_view.deactivate_all_halfspaces() capability_region.rt = None capability_region.lt = None capability_region.rb = None capability_region.lb = None self._update_view() return _callback self._settings_view.active_power.left_entry.set_callback( _create_value_change_callback( capability_region=self._cap_region, axis=Axis.ACTIVE_POWER, bound=Bound.MIN)) self._settings_view.active_power.right_entry.set_callback( _create_value_change_callback( capability_region=self._cap_region, axis=Axis.ACTIVE_POWER, bound=Bound.MAX)) self._settings_view.reactive_power.left_entry.set_callback( _create_value_change_callback( capability_region=self._cap_region, axis=Axis.REACTIVE_POWER, bound=Bound.MIN)) self._settings_view.reactive_power.right_entry.set_callback( _create_value_change_callback( capability_region=self._cap_region, axis=Axis.REACTIVE_POWER, bound=Bound.MAX)) self._settings_view.setup_halfspace_callbacks(self._update_view) def _draw_capability_region(self): def _parallel_to_axis(parallel_to, ends, orientation): if not len(ends) == 2: raise ValueError("A line must have 2 ends.") if orientation is Axis.REACTIVE_POWER: return ([Window.SCALING_FACTOR * ends[0], Window.SCALING_FACTOR * ends[1]], [parallel_to, parallel_to]) if orientation is Axis.ACTIVE_POWER: return ([parallel_to, parallel_to], [Window.SCALING_FACTOR * ends[0], Window.SCALING_FACTOR * ends[1]]) raise ValueError("Unknown orientation: " + orientation) def _draw_region_part(capability_region, axis, bound): """ Parameters ---------- capability_region: CapRegion The capability region specification. axis: Axis Either Axis.POWER or Axis.REACTIVE_POWER for the active and reactive power, respectively. bound: Bound Bound.MIN or Bound.MAX to specify the bound. Returns ------- The line that was added to the plot. """ limits = [] bound_value = None if axis is Axis.ACTIVE_POWER: limits = [self._cap_region.q_min, self._cap_region.q_max] if bound is Bound.MAX: bound_value = capability_region.p_max elif bound is Bound.MIN: bound_value = capability_region.p_min else: raise ValueError("Unsupported Bound: " + bound) elif axis is Axis.REACTIVE_POWER: limits = [self._cap_region.p_min, self._cap_region.p_max] if bound is Bound.MAX: bound_value = capability_region.q_max elif bound is Bound.MIN: bound_value = capability_region.q_min else: raise ValueError("Unsupported Bound: " + bound) points = _parallel_to_axis(bound_value, limits, axis) return self._ax.plot(*points, color=Window.CAPABILITY_REGION_COLOR, zorder=0)[0] # remove all limit lines / points for axis, bound in [(a, b) for a in Axis for b in Bound]: if (axis, bound) in self._limit_lines: line = self._limit_lines[(axis, bound)] line.remove() del line del self._limit_lines[(axis, bound)] if (self._cap_region.p_min == self._cap_region.p_max) and \ (self._cap_region.q_min == self._cap_region.q_max): point = self._ax.plot([self._cap_region.p_min], [self._cap_region.q_min], marker='o', linestyle='--', color='r')[0] self._limit_lines[(Axis.ACTIVE_POWER, Bound.MIN)] = point elif self._cap_region.p_min == self._cap_region.p_max: # draw only the line of p line = _draw_region_part(axis=Axis.ACTIVE_POWER, bound=Bound.MIN, capability_region=self._cap_region) self._limit_lines[(Axis.REACTIVE_POWER, Bound.MIN)] = line elif self._cap_region.q_min == self._cap_region.q_max: line = _draw_region_part(axis=Axis.REACTIVE_POWER, bound=Bound.MIN, capability_region=self._cap_region) self._limit_lines[(Axis.REACTIVE_POWER, Bound.MIN)] = line else: # create the rectangular capability region lines for axis, bound in [(a, b) for a in Axis for b in Bound]: line = _draw_region_part(axis=axis, bound=bound, capability_region=self._cap_region) self._limit_lines[(axis, bound)] = line def _draw_halfspaces(self): halfspace_options = { (Orientation.RIGHT, Orientation.TOP): self._cap_region.rt, (Orientation.LEFT, Orientation.TOP): self._cap_region.lt, ( Orientation.RIGHT, Orientation.BOTTOM): self._cap_region.rb, (Orientation.LEFT, Orientation.BOTTOM): self._cap_region.lb} for (p, q), halfspace in halfspace_options.items(): line_tuple = (p, q) line = self._halfspace_lines.get(line_tuple) if not halfspace and line is None: continue # now we remove any lines we need to remove as halfspace is None # but a line for this halfspace still exists # but during initialising the line is still None if line: del self._halfspace_lines[line_tuple] line.remove() del line # and add a line back, if the halfspace exists if halfspace: points = self._halfspace_points(p_orientation=p, q_orientation=q) line = self._ax.plot(*points, color=Window.HALFSPACES_COLOR, zorder=0)[0] self._halfspace_lines[line_tuple] = line def _halfspace_points(self, p_orientation, q_orientation): # l/t ^ q_max r/t # | # p_min | p_max # p --------|--------> # | # | # l/b q q_min r/b if q_orientation is Orientation.TOP: q = self._cap_region.q_max possible_halfspaces = ( self._cap_region.lt, self._cap_region.rt) elif q_orientation is Orientation.BOTTOM: q = self._cap_region.q_min possible_halfspaces = ( self._cap_region.lb, self._cap_region.rb) else: raise ValueError(str(q_orientation) + " is not allowed for q_orientation.") if p_orientation is Orientation.RIGHT: p = self._cap_region.p_max halfspace = possible_halfspaces[1] elif p_orientation is Orientation.LEFT: p = self._cap_region.p_min halfspace = possible_halfspaces[0] else: raise ValueError(str(p_orientation) + " is not allowed for p_orientation.") if halfspace is None: return None # now we can be sure that the halfspace exists and we have # the right values limits = Point(p, q) line_functions = LinearFunction(halfspace, limits) x_points = [limits.p * Window.SCALING_FACTOR, limits.p, line_functions.f_1(limits.q), line_functions.f_1(limits.q * Window.SCALING_FACTOR)] y_points = [line_functions.f(limits.p * Window.SCALING_FACTOR), line_functions.f(limits.p), limits.q, limits.q * Window.SCALING_FACTOR] return x_points, y_points def _setup_halfspace_callbacks(self): halfspace_options = {(Orientation.RIGHT, Orientation.TOP): self._settings_view.rt_half_space, (Orientation.LEFT, Orientation.TOP): self._settings_view.lt_half_space, (Orientation.RIGHT, Orientation.BOTTOM): self._settings_view.rb_half_space, (Orientation.LEFT, Orientation.BOTTOM): self._settings_view.lb_half_space} def _create_callback(p_orientation, q_orientation, slope): def _callback(value): # update the value on the capability region def _get_new_halfspace(old): if old: if slope: return HalfSpace(slope=value, offset=old.offset) return HalfSpace(slope=old.slope, offset=value) return None line_tuple = (p_orientation, q_orientation) try: if line_tuple == (Orientation.RIGHT, Orientation.TOP): self._cap_region.rt = _get_new_halfspace( self._cap_region.rt) elif line_tuple == (Orientation.RIGHT, Orientation.BOTTOM): self._cap_region.rb = _get_new_halfspace( self._cap_region.rb) elif line_tuple == (Orientation.LEFT, Orientation.TOP): self._cap_region.lt = _get_new_halfspace( self._cap_region.lt) elif line_tuple == (Orientation.LEFT, Orientation.BOTTOM): self._cap_region.lb = _get_new_halfspace( self._cap_region.lb) else: raise ValueError( str(line_tuple) + " is not a valid configuration!") except ValueError as error: half_plane = halfspace_options[line_tuple] halfspace = None if line_tuple == (Orientation.RIGHT, Orientation.TOP): halfspace = self._cap_region.rt elif line_tuple == (Orientation.RIGHT, Orientation.BOTTOM): halfspace = self._cap_region.rb elif line_tuple == (Orientation.LEFT, Orientation.TOP): halfspace = self._cap_region.lt elif line_tuple == (Orientation.LEFT, Orientation.BOTTOM): halfspace = self._cap_region.lb if slope: undo = half_plane.right_entry.error_state( old_value=halfspace.slope) else: undo = half_plane.left_entry.error_state( old_value=halfspace.offset) self._show_message(str(error), undo) self._update_view() return _callback for (p, q), descriptive_entry in halfspace_options.items(): left_callback = _create_callback(p_orientation=p, q_orientation=q, slope=False) right_callback = _create_callback(p_orientation=p, q_orientation=q, slope=True) descriptive_entry.left_entry.set_callback(left_callback) descriptive_entry.right_entry.set_callback(right_callback) def _update_polygon(self): try: polygon_vertices = self.compute_polygon_vertices(self._cap_region) except ValueError as error: self._show_message(str(error)) # update the polygon if self._polygon: for polygon in self._polygon: polygon.remove() self._polygon = [] if polygon_vertices: polygon = Polygon(np.array(polygon_vertices), closed=True, facecolor=Window.POLYGON_FILL_COLOR, zorder=0) self._ax.add_patch(polygon) self._polygon.append(polygon) polygon_outline = Polygon(np.array(polygon_vertices), closed=True, edgecolor=Window.POLYGON_EDGE_COLOR, facecolor='none', zorder=4) self._ax.add_patch(polygon_outline) self._polygon.append(polygon_outline)
[docs] def display_operating_point(self, point): """ Display an operating point in the P/Q-plane of the capability region. Parameters ---------- point: tuple(p,q) This tuple represents the operating point as a (p,q)-tuple of floats. """ self._ax.plot([point[0]], [point[1]], label='Operating Point', marker='+', linestyle='--', color='r') self._update_view()
[docs] @staticmethod def compute_polygon_vertices(cap_region): """ Compute the vertices of the capability region polygon. This function computes the intersections of the four lines that delimit the capability region. The algorithm starts with the left-top half space and continues clock-wise with the other half-spaces. The algorithm assumes that ``p_max != p_min`` and ``q_max != q_min``. Furthermore, the assumptions on the slopes and offsets must be satisfied as well. If the preconditions are met, the function returns a list of all vertices that specify the polygon, in a clock-wise ordering. Parameters ---------- cap_region: CapRegion The capability region that shall be drawn. Returns ------- list[tuple(p,q)] List of polygon vertices. """ # pylint: disable=too-many-locals,too-many-statements,too-many-branches # l/t ^ q_max r/t # | # p_min | p_max # p --------|--------> # | # | # l/b q q_min r/b # start with lt and compute the q value with p_min # then continue with the intersection / the top points of lt and rt. # followed by the p_max point of rt, then do the same for the bottom # part # Remember that the halfspaces could be empty! # The offset is required to be positive! all_top_halfspaces_present = cap_region.lt is not None and \ cap_region.rt is not None all_bottom_halfspaces_present = cap_region.lb is not None and \ cap_region.rb is not None polygon_vertices = [] limits_lt = Point(cap_region.p_min, cap_region.q_max) limits_rt = Point(cap_region.p_max, cap_region.q_max) limits_rb = Point(cap_region.p_max, cap_region.q_min) limits_lb = Point(cap_region.p_min, cap_region.q_min) def _point_in_bounds(point): q_in_bounds = cap_region.q_min <= point.q <= cap_region.q_max p_in_bounds = cap_region.p_min <= point.p <= cap_region.p_max return q_in_bounds and p_in_bounds def _compute_and_limit(linear_function, limits, compute_coordinate): if compute_coordinate is Axis.ACTIVE_POWER: point = Point(linear_function.f_1(limits.q), limits.q) if not _point_in_bounds(point): point = Point(limits.p, linear_function.f(limits.p)) elif compute_coordinate is Axis.REACTIVE_POWER: point = Point(limits.p, linear_function.f(limits.p)) if not _point_in_bounds(point): point = Point(linear_function.f_1(limits.q), limits.q) else: raise ValueError(f"{compute_coordinate} is not implemented!") if not _point_in_bounds(point): _log.warning(f"Point {point} for halfspace with offset " f"{linear_function.offset} and " f"{linear_function.slope} slope is not in " f"the bounds of the capability region. " f"We will limit it to the limits of the " f"capability region.") return limits return point if all_top_halfspaces_present: plf_lt = LinearFunction(cap_region.lt, limits_lt) plf_rt = LinearFunction(cap_region.rt, limits_rt) polygon_vertices.append( _compute_and_limit(plf_lt, limits_lt, Axis.REACTIVE_POWER)) intersection = plf_lt.intersect_with(plf_rt) if intersection: # should be the case in all cases were the halfspace is present if _point_in_bounds(intersection): polygon_vertices.append(intersection) else: # compute the capped intersection with q_max (as the # intersection of the two halfspace function is out of bounds lt_top_point = _compute_and_limit(plf_lt, limits_lt, Axis.ACTIVE_POWER) rt_top_point = _compute_and_limit(plf_rt, limits_rt, Axis.ACTIVE_POWER) polygon_vertices.append(lt_top_point) polygon_vertices.append(rt_top_point) else: message = "The top halfspaces are parallel, " \ "please check your input values!" raise ValueError(message) polygon_vertices.append( _compute_and_limit(plf_rt, limits_rt, Axis.REACTIVE_POWER)) else: if cap_region.lt: plf = LinearFunction(cap_region.lt, limits_lt) lt_top_point = _compute_and_limit(plf, limits_lt, Axis.REACTIVE_POWER) lt_bottom_point = _compute_and_limit(plf, limits_lt, Axis.ACTIVE_POWER) rt_top_point = _compute_and_limit(plf, limits_rt, Axis.REACTIVE_POWER) polygon_vertices.append(lt_top_point) polygon_vertices.append(lt_bottom_point) # append replacement point for rt if rt_top_point == lt_bottom_point: polygon_vertices.append(limits_rt) else: polygon_vertices.append(rt_top_point) elif cap_region.rt: plf = LinearFunction(cap_region.rt, limits_rt) # append replacement point for lt lt_top_point = _compute_and_limit(plf, limits_lt, Axis.REACTIVE_POWER) rt_top_point = _compute_and_limit(plf, limits_rt, Axis.ACTIVE_POWER) rt_bottom_point = _compute_and_limit(plf, limits_rt, Axis.REACTIVE_POWER) if lt_top_point == rt_top_point: polygon_vertices.append(limits_lt) else: polygon_vertices.append(lt_top_point) polygon_vertices.append(rt_top_point) polygon_vertices.append(rt_bottom_point) else: polygon_vertices.append(limits_lt) polygon_vertices.append(limits_rt) if all_bottom_halfspaces_present: # p_min | p_max # p --------|--------> # | # | # l/b q q_min r/b plf_rb = LinearFunction(cap_region.rb, limits_rb) plf_lb = LinearFunction(cap_region.lb, limits_lb) polygon_vertices.append( _compute_and_limit(plf_rb, limits_rb, Axis.REACTIVE_POWER)) # compute the intersection of the two functions and determine if it # is still in bounds (i.e., the q is smaller than q_min intersection = plf_rb.intersect_with(plf_lb) if intersection: if _point_in_bounds(intersection): polygon_vertices.append(intersection) else: # first intersect rb with q_min and then lb point_limited_rb_intersection = \ _compute_and_limit(plf_rb, limits_rb, Axis.ACTIVE_POWER) polygon_vertices.append(point_limited_rb_intersection) point_limited_lb_intersection = \ _compute_and_limit(plf_lb, limits_lb, Axis.ACTIVE_POWER) polygon_vertices.append(point_limited_lb_intersection) else: message = "The bottom halfspaces are parallel, " \ "please check your input values!" raise ValueError(message) lb_end = _compute_and_limit(plf_lb, limits_lb, Axis.REACTIVE_POWER) polygon_vertices.append(lb_end) else: if cap_region.rb: plf_rb = LinearFunction(cap_region.rb, limits_rb) point_rb_top = _compute_and_limit(plf_rb, limits_rb, Axis.REACTIVE_POWER) point_rb_bottom = _compute_and_limit(plf_rb, limits_rb, Axis.ACTIVE_POWER) # compute replacement lb points point_lb_bottom = _compute_and_limit(plf_rb, limits_lb, Axis.REACTIVE_POWER) polygon_vertices.append(point_rb_top) polygon_vertices.append(point_rb_bottom) if point_rb_bottom == point_lb_bottom: polygon_vertices.append(limits_lb) else: polygon_vertices.append(point_lb_bottom) elif cap_region.lb: plf_lb = LinearFunction(cap_region.lb, limits_lb) # limit value to bounds # compute replacement rb points point_rb_bottom = _compute_and_limit(plf_lb, limits_rb, Axis.REACTIVE_POWER) point_lb_bottom = _compute_and_limit(plf_lb, limits_lb, Axis.ACTIVE_POWER) point_lb_top = _compute_and_limit(plf_lb, limits_lb, Axis.REACTIVE_POWER) if point_rb_bottom == point_lb_bottom: polygon_vertices.append(limits_rb) else: polygon_vertices.append(point_rb_bottom) polygon_vertices.append(point_lb_bottom) polygon_vertices.append(point_lb_top) else: polygon_vertices.append(limits_rb) polygon_vertices.append(limits_lb) return polygon_vertices
[docs] @staticmethod def show(capability_region, edit=True, operating_point=None): """ Create and show a capability region visualizer window. Parameters ---------- capability_region: CapRegion edit: bool, optional If True (default), editing of the specified capability region is enabled. operating_point: tuple(p,q), optional If provided, this operating point is shown by a marker in the P/Q-plane. """ # If we do not close the open figures, the settings view does not work # properly, strangely...maybe we try to find another fix sometime :) plt.close('all') root = tk.Tk() root.title("Capability Region Visualizer - Press [ESC] to exit.") tk.Grid.rowconfigure(root, 0, weight=1) tk.Grid.columnconfigure(root, 0, weight=1) app = Window(root, capability_region, edit) if operating_point: app.display_operating_point(operating_point) # Bind the escape key to exit the application def _quit(event): # pylint: disable=unused-argument root.quit() # Stop the main loop root.destroy() # Close the window root.bind("<Escape>", _quit) # Set up the "Export as image" button button_frame = tk.Frame(root, padx=0, pady=0, bg="white") def _save_figure(): from pathlib import Path from tkinter import filedialog file_path = filedialog.asksaveasfilename(initialdir=Path.home(), filetypes=SAVE_FILETYPES, title="Export as image...") if file_path: app.fig.savefig(file_path) save_fig = tk.Button(button_frame, text='Export as image...', command=_save_figure, padx=10, bg="white") save_fig.grid(row=0, column=0, sticky=[tk.N, tk.S, tk.E, tk.W]) if edit: button_frame.grid(row=1, column=1, padx=(20, 0), pady=(15, 10), sticky=[tk.N, tk.S, tk.E, tk.W]) root.update() root.minsize(width=750, height=610) else: button_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky=tk.E) root.update() root.minsize(width=500, height=350) while True: try: root.mainloop() break except UnicodeDecodeError: # See https://stackoverflow.com/questions/16995969/ # inertial-scrolling-in-mac-os-x-with-tkinter-and-python pass # REMARK: After closing the window, we received runtime errors that # stated "RuntimeError: main thread is not in main loop". This error # appears when tk is not run in the main thread, see # https://stackoverflow.com/questions/14694408/runtimeerror-main-thread-is-not-in-main-loop # However, GUI was run in the main thread, but the garbage collection # of the window by one of the threads spawn during the OPF computation, # see also # https://stackoverflow.com/questions/44781806/tkinter-objects-being-garbage-collected-from-the-wrong-thread # To avoid this issue, we enforce the garbage collection in the main # thread here: del root gc.collect()