Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • software_public/passoft/sohstationviewer
1 result
Show changes
Showing
with 873 additions and 695 deletions
from typing import Union, Dict, List, Set, Tuple
from sohstationviewer.controller.plotting_data import format_time
from sohstationviewer.model.data_type_model import DataTypeModel
from sohstationviewer.model.mseed.mseed import MSeed
from sohstationviewer.model.general_data.general_data import GeneralData
from sohstationviewer.model.mseed_data.mseed import MSeed
from sohstationviewer.model.reftek.reftek import RT130
from sohstationviewer.view.util.functions import extract_netcodes
def extract_data_set_info(data_obj: Union[DataTypeModel, RT130, MSeed],
def extract_data_set_info(data_obj: Union[GeneralData, RT130, MSeed],
date_format: str
) -> Dict[str, Union[str, List[str]]]:
"""
......@@ -45,7 +45,7 @@ def extract_data_set_info(data_obj: Union[DataTypeModel, RT130, MSeed],
f"\n\t\tTo: {end_time_str}")
data_set_info['Time ranges'] = '\n\t'.join(time_range_info_list)
key_sets = data_obj.stream_header_by_key_chan.keys()
key_sets = data_obj.keys
if data_type == 'RT130':
das_serials = list({key[0] for key in key_sets})
experiment_numbers = list({key[1] for key in key_sets})
......
......@@ -10,9 +10,9 @@ from PySide2.QtCore import QSize
from PySide2.QtGui import QFont, QPalette, QColor
from PySide2.QtWidgets import QFrame, QListWidgetItem, QMessageBox
from sohstationviewer.conf import constants
from sohstationviewer.model.data_loader import DataLoader
from sohstationviewer.model.data_type_model import DataTypeModel
from sohstationviewer.model.general_data.general_data import \
GeneralData
from sohstationviewer.view.calendar.calendar_dialog import CalendarDialog
from sohstationviewer.view.db_config.channel_dialog import ChannelDialog
......@@ -41,8 +41,7 @@ from sohstationviewer.view.channel_prefer_dialog import ChannelPreferDialog
from sohstationviewer.controller.processing import detect_data_type
from sohstationviewer.controller.util import (
display_tracking_info, rt130_find_cf_dass, check_data_sdata,
get_dir_size
display_tracking_info, rt130_find_cf_dass, check_data_sdata
)
from sohstationviewer.database.process_db import execute_db_dict, execute_db
......@@ -63,9 +62,17 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
"""
self.dir_names: List[Path] = []
"""
current_dir: str - the current main data directory
current_dir: the current main data directory
"""
self.current_dir = ''
self.current_dir: str = ''
"""
save_plot_dir: directory to save plot
"""
self.save_plot_dir: str = ''
"""
save_plot_format: format to save plot
"""
self.save_plot_format: str = 'SVG'
"""
rt130_das_dict: dict by rt130 for data paths, so user can choose
dasses to assign list of data paths to selected_rt130_paths
......@@ -81,6 +88,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
"""
self.data_type: str = 'Unknown'
"""
is_multiplex: flag showing if data_set is multiplex (more than one
channels in a file)
"""
self.is_multiplex = None
"""
color_mode: str - the current color mode of the plot; can be either 'B'
or 'W'
"""
......@@ -117,11 +129,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
"""
data_object: Object that keep data read from data set for plotting
"""
self.data_object: Union[DataTypeModel, None] = None
self.data_object: Union[GeneralData, None] = None
"""
min_gap: minimum minutes of gap length to be display on gap bar
gap_minimum: minimum minutes of gap length to be display on gap bar
"""
self.min_gap: Union[float, None] = None
self.gap_minimum: Union[float, None] = None
"""
pref_soh_list_name: name of selected preferred channels list
"""
......@@ -185,6 +197,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
self.validate_config()
self.apply_config()
@QtCore.Slot()
def save_plot(self):
self.plotting_widget.save_plot('SOH-Plot')
@QtCore.Slot()
def open_data_type(self) -> None:
"""
......@@ -386,9 +402,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
:rtype: List[str, int]
"""
req_wf_chans = []
if ((self.all_wf_chans_check_box.isChecked()
or [ds for ds in self.ds_check_boxes if ds.isChecked()] != []
or self.mseed_wildcard_edit.text().strip() != "")
if (self.data_type != 'RT130' and
(self.all_wf_chans_check_box.isChecked()
or self.mseed_wildcard_edit.text().strip() != "")
and not self.tps_check_box.isChecked()
and not self.raw_check_box.isChecked()):
raise Exception(
......@@ -492,23 +508,28 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
raise Exception(msg)
if self.rt130_das_dict == {}:
try:
self.data_type = detect_data_type(self.dir_names)
except Exception as e:
raise e
self.data_type, self.is_multiplex = detect_data_type(
self.dir_names)
def clear_plots(self):
self.plotting_widget.clear()
self.waveform_dlg.plotting_widget.clear()
self.tps_dlg.plotting_widget.clear()
def cancel_loading(self):
display_tracking_info(self.tracking_info_text_browser,
"Loading cancelled",
LogType.WARNING)
@QtCore.Slot()
def read_selected_files(self):
"""
Read data from selected files/directories, process and plot channels
read from those according to current options set on the GUI
"""
display_tracking_info(self.tracking_info_text_browser,
"Loading started",
LogType.INFO)
self.clear_plots()
start_tm_str = self.time_from_date_edit.date().toString(
QtCore.Qt.ISODate)
......@@ -518,6 +539,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if self.end_tm <= self.start_tm:
msg = "To Date must be greater than From Date."
QtWidgets.QMessageBox.warning(self, "Wrong Date Given", msg)
self.cancel_loading()
return
self.info_list_widget.clear()
is_working = (self.is_loading_data or self.is_plotting_soh or
......@@ -530,15 +552,23 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if self.gap_len_line_edit.text().strip() != '':
try:
self.min_gap = float(
self.gap_len_line_edit.text())
# convert from minute to second
self.gap_minimum = float(
self.gap_len_line_edit.text()) * 60
except ValueError:
msg = "Minimum Gap must be a number."
QtWidgets.QMessageBox.warning(
self, "Invalid Minimum Gap request", msg)
self.cancel_loading()
return
if self.gap_minimum < 0.1:
msg = "Minimum Gap must be greater than 0.1 minute to be " \
"detected."
QtWidgets.QMessageBox.warning(
self, "Invalid Minimum Gap request", msg)
return
else:
self.min_gap = None
self.gap_minimum = None
if self.mseed_wildcard_edit.text().strip() != '':
try:
......@@ -546,6 +576,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
except Exception as e:
QtWidgets.QMessageBox.warning(
self, "Incorrect Wildcard", str(e))
self.cancel_loading()
return
try:
......@@ -555,16 +586,29 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
except AttributeError:
pass
self.req_soh_chans = (self.pref_soh_list
if not self.all_soh_chans_check_box.isChecked()
else [])
try:
self.read_from_file_list()
except Exception as e:
QtWidgets.QMessageBox.warning(self, "Select directory", str(e))
return
if 'no known data detected' in str(e):
msgbox = QtWidgets.QMessageBox()
msgbox.setWindowTitle('Do you want to continue?')
msgbox.setText(str(e))
msgbox.addButton(QtWidgets.QMessageBox.Cancel)
msgbox.addButton('Continue', QtWidgets.QMessageBox.YesRole)
result = msgbox.exec_()
if result == QtWidgets.QMessageBox.Cancel:
self.cancel_loading()
return
self.data_type == 'Unknown'
else:
fmt = traceback.format_exc()
QtWidgets.QMessageBox.warning(
self, "Select directory", str(fmt))
self.cancel_loading()
return
"""
temporary skip check_size for it take too long.
dir_size = sum(get_dir_size(str(dir))[0] for dir in self.dir_names)
if dir_size > constants.BIG_FILE_SIZE:
data_too_big_dialog = QMessageBox()
......@@ -578,13 +622,15 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
data_too_big_dialog.setIcon(QMessageBox.Question)
ret = data_too_big_dialog.exec_()
if ret == QMessageBox.Abort:
self.cancel_loading()
return
"""
self.req_soh_chans = self.get_requested_soh_chan()
try:
self.req_wf_chans = self.get_requested_wf_chans()
except Exception as e:
QMessageBox.information(self, "Waveform Selection", str(e))
self.cancel_loading()
return
start_tm_str = self.time_from_date_edit.date().toString(
......@@ -601,10 +647,12 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
self.data_loader.init_loader(
self.data_type,
self.tracking_info_text_browser,
self.is_multiplex,
self.dir_names,
self.selected_rt130_paths,
req_wf_chans=self.req_wf_chans,
req_soh_chans=self.req_soh_chans,
gap_minimum=self.gap_minimum,
read_start=self.start_tm,
read_end=self.end_tm,
include_mp123=self.mass_pos_123zne_check_box.isChecked(),
......@@ -679,13 +727,19 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
self.is_stopping = False
@QtCore.Slot()
def data_loaded(self, data_obj: DataTypeModel):
def data_loaded(self, data_obj: GeneralData):
"""
Process the loaded data.
:param data_obj: the data object that contains the loaded data.
"""
self.is_loading_data = False
self.data_object = data_obj
if (self.data_type == 'Q330' and
'LOG' not in data_obj.log_data[data_obj.selected_key]):
log_message = ("Channel 'LOG' is required to get file info and "
"gps info for Q330", LogType.WARNING)
self.processing_log.append(log_message)
return
try:
self.gps_dialog.gps_points = extract_gps_data(data_obj)
except ValueError as e:
......@@ -721,6 +775,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if self.has_problem:
return
self.is_plotting_soh = True
self.plotting_widget.set_colors(self.color_mode)
self.waveform_dlg.plotting_widget.set_colors(self.color_mode)
self.tps_dlg.plotting_widget.set_colors(self.color_mode)
self.gps_dialog.set_colors(self.color_mode)
d_obj = self.data_object
......@@ -728,7 +786,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
sel_key = d_obj.selected_key
d_obj.reset_all_selected_data()
d_obj.reset_need_process_for_mass_pos()
try:
check_masspos(d_obj.mass_pos_data[sel_key], sel_key,
self.mass_pos_123zne_check_box.isChecked(),
......@@ -843,6 +900,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
# current directory
self.current_directory_changed.emit(path)
self.current_dir = path
self.save_plot_dir = path
execute_db(f'UPDATE PersistentData SET FieldValue="{path}" WHERE '
'FieldName="currentDirectory"')
self.set_open_files_list_texts()
......@@ -1061,10 +1119,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if not checked:
return
self.color_mode = color_mode
self.plotting_widget.set_colors(color_mode)
self.waveform_dlg.plotting_widget.set_colors(color_mode)
self.tps_dlg.plotting_widget.set_colors(color_mode)
self.gps_dialog.set_colors(color_mode)
@QtCore.Slot()
def clear_file_search(self):
......
......@@ -6,8 +6,7 @@ from typing import List, Optional, Dict, NoReturn
import numpy as np
from obspy import UTCDateTime
from sohstationviewer.controller.processing import detect_data_type
from sohstationviewer.model.mseed.mseed import MSeed
from sohstationviewer.model.mseed_data.mseed import MSeed
from sohstationviewer.model.reftek.reftek import RT130
from sohstationviewer.view.plotting.gps_plot.gps_point import GPSPoint
from sohstationviewer.view.util.enums import LogType
......@@ -184,9 +183,10 @@ def get_gps_channel_prefix(data_obj: MSeed, data_type: str) -> Optional[str]:
# Determine the GPS channels by checking if the current data set
# has all the GPS channels of a data type.
if pegasus_gps_channels & data_obj.channels == pegasus_gps_channels:
channels = set(data_obj.soh_data[data_obj.selected_key].keys())
if pegasus_gps_channels & channels == pegasus_gps_channels:
gps_prefix = 'V'
elif centaur_gps_channels & data_obj.channels == centaur_gps_channels:
elif centaur_gps_channels & channels == centaur_gps_channels:
gps_prefix = 'G'
else:
msg = "Can't detect GPS channels."
......@@ -234,7 +234,9 @@ def extract_gps_data_pegasus_centaur(data_obj: MSeed, data_type: str
gps_prefix = get_gps_channel_prefix(data_obj, data_type)
gps_chans = {gps_prefix + 'NS', gps_prefix + 'LA', gps_prefix + 'LO',
gps_prefix + 'EL'}
channels = data_obj.stream_header_by_key_chan[data_obj.selected_key].keys()
if data_obj.selected_key is None:
return []
channels = data_obj.soh_data[data_obj.selected_key].keys()
if not gps_chans.issubset(channels):
missing_gps_chans = gps_chans - channels
missing_gps_chans_string = ', '.join(missing_gps_chans)
......@@ -434,8 +436,23 @@ def gps_data_rt130(data_obj: RT130) -> List[GPSPoint]:
@extract_gps_data.register(MSeed)
def gps_data_mseed(data_obj: MSeed) -> List[GPSPoint]:
data_type = detect_data_type([data_obj.dir])
try:
data_type = data_obj.data_type
except Exception:
data_type = 'Unknown'
if data_type == 'Q330':
return extract_gps_data_q330(data_obj)
elif data_type == 'Centaur' or data_type == 'Pegasus':
return extract_gps_data_pegasus_centaur(data_obj, data_type)
else:
# data_type = "Unknown"
try:
gps_data = extract_gps_data_q330(data_obj)
except KeyError:
try:
gps_data = extract_gps_data_pegasus_centaur(
data_obj, data_type)
except AttributeError:
return []
return gps_data
# Define functions to call processor
from typing import Tuple, Union, Dict, Callable, List, Optional
from typing import Tuple, Union, Dict, List
from PySide2 import QtCore
......@@ -105,20 +105,17 @@ class MultiThreadedPlottingWidget(PlottingWidget):
return True
def create_plotting_channel_processors(
self, plotting_data: Dict,
get_plot_info: Optional[Callable[[str, Dict, str], Dict]]) -> None:
self, plotting_data: Dict, need_db_info: bool = False) -> None:
"""
Create a data processor for each channel data.
:param plotting_data: dict of data by chan_id
:param get_plot_info: function to get plotting info from database
:param need_db_info: flag to get db info
"""
for chan_id in plotting_data:
if get_plot_info is not None:
chan_db_info = get_plot_info(chan_id,
plotting_data[chan_id],
self.parent.data_type,
self.c_mode)
if need_db_info:
chan_db_info = get_chan_plot_info(
chan_id, self.parent.data_type, self.c_mode)
if chan_db_info['height'] == 0:
# not draw
continue
......@@ -196,16 +193,10 @@ class MultiThreadedPlottingWidget(PlottingWidget):
self.clean_up()
self.finished.emit()
return
self.create_plotting_channel_processors(
self.plotting_data1, self.get_plot_info)
self.create_plotting_channel_processors(
self.plotting_data2, get_chan_plot_info)
self.create_plotting_channel_processors(self.plotting_data1, True)
self.create_plotting_channel_processors(self.plotting_data2, True)
self.process_channel()
def get_plot_info(self, *args, **kwargs):
# function to get database info for channels in self.plotting_data1
pass
@QtCore.Slot()
def process_channel(self, channel_data=None, channel_id=None):
"""
......@@ -347,6 +338,6 @@ class MultiThreadedPlottingWidget(PlottingWidget):
self.is_working = True
start_msg = 'Zooming in...'
display_tracking_info(self.tracking_box, start_msg, 'info')
self.create_plotting_channel_processors(self.plotting_data1, None)
self.create_plotting_channel_processors(self.plotting_data2, None)
self.create_plotting_channel_processors(self.plotting_data1)
self.create_plotting_channel_processors(self.plotting_data2)
self.process_channel()
# class with all plotting functions
import numpy as np
from sohstationviewer.controller.util import get_val
from sohstationviewer.controller.plotting_data import get_masspos_value_colors
......@@ -75,8 +77,10 @@ class Plotting:
if chan_db_info['valueColors'] in [None, 'None', '']:
chan_db_info['valueColors'] = '*:W'
value_colors = chan_db_info['valueColors'].split('|')
colors = []
for vc in value_colors:
v, c = vc.split(':')
colors.append(c)
val = get_val(v)
if c == '_':
prev_val = val
......@@ -104,9 +108,14 @@ class Plotting:
total_samples = len(x)
x = sorted(x)
if len(colors) != 1:
sample_no_colors = [clr['W']]
else:
sample_no_colors = [clr[colors[0]]]
self.plotting_axes.set_axes_info(
ax, [total_samples], chan_db_info=chan_db_info,
linked_ax=linked_ax)
ax, [total_samples], sample_no_colors=sample_no_colors,
chan_db_info=chan_db_info, linked_ax=linked_ax)
if linked_ax is None:
ax.x = x
else:
......@@ -168,6 +177,8 @@ class Plotting:
ax.set_ylim(-2, 2)
self.plotting_axes.set_axes_info(
ax, [len(points_list[0]), len(points_list[1])],
sample_no_colors=[clr[colors[0]], clr[colors[1]]],
sample_no_pos=[0.25, 0.75],
chan_db_info=chan_db_info, linked_ax=linked_ax)
if linked_ax is None:
ax.x = x
......@@ -203,7 +214,8 @@ class Plotting:
x_list = c_data['times']
total_x = sum([len(x) for x in x_list])
self.plotting_axes.set_axes_info(
ax, [total_x], chan_db_info=chan_db_info, linked_ax=linked_ax)
ax, [total_x], sample_no_colors=[clr[color]],
chan_db_info=chan_db_info, linked_ax=linked_ax)
for x in x_list:
ax.plot(x, [0] * len(x), marker='s', markersize=1.5,
......@@ -250,10 +262,7 @@ class Plotting:
self.parent.plotting_bot, plot_h)
x_list, y_list = c_data['times'], c_data['data']
total_x = sum([len(x) for x in x_list])
self.plotting_axes.set_axes_info(
ax, [total_x], chan_db_info=chan_db_info,
info=info, y_list=y_list, linked_ax=linked_ax)
colors = {}
if chan_db_info['valueColors'] not in [None, 'None', '']:
color_parts = chan_db_info['valueColors'].split('|')
......@@ -261,12 +270,27 @@ class Plotting:
obj, c = cStr.split(':')
colors[obj] = c
l_color = 'G'
d_color = 'W'
has_dot = False
if 'L' in colors:
l_color = colors['L']
if 'D' in colors:
d_color = colors['D']
has_dot = True
if chan_id == 'GPS Lk/Unlk':
sample_no_list = []
sample_no_list.append(np.where(y_list[0] == -1)[0].size)
sample_no_list.append(np.where(y_list[0] == 1)[0].size)
sample_no_colors = [clr[d_color], clr[d_color]]
else:
sample_no_list = [sum([len(x) for x in x_list])]
sample_no_colors = [clr[d_color]]
self.plotting_axes.set_axes_info(
ax, sample_no_list, sample_no_colors=sample_no_colors,
chan_db_info=chan_db_info,
info=info, y_list=y_list, linked_ax=linked_ax)
for x, y in zip(x_list, y_list):
if not has_dot:
# set marker to be able to click point for info
......
from typing import List
from typing import List, Optional, Dict
import numpy as np
from matplotlib.axes import Axes
from matplotlib.patches import ConnectionPatch, Rectangle
from matplotlib.ticker import AutoMinorLocator
from matplotlib import pyplot as pl
......@@ -7,9 +9,10 @@ from matplotlib.backends.backend_qt5agg import (
FigureCanvasQTAgg as Canvas)
from sohstationviewer.controller.plotting_data import (
get_gaps, get_time_ticks, get_unit_bitweight)
get_time_ticks, get_unit_bitweight)
from sohstationviewer.conf import constants
from sohstationviewer.view.util.color import clr
class PlottingAxes:
......@@ -75,6 +78,7 @@ class PlottingAxes:
labelbottom = False
else:
labelbottom = True
self.parent.plotting_bot -= 0.007 # space for ticks
timestamp_bar.tick_params(which='major', length=7, width=2,
direction='inout',
colors=self.parent.display_color['basic'],
......@@ -87,7 +91,8 @@ class PlottingAxes:
fontweight='bold',
fontsize=self.parent.font_size,
rotation=0,
labelpad=constants.HOUR_TO_TMBAR_D,
labelpad=constants.HOUR_TO_TMBAR_D *
self.parent.ratio_w,
ha='left',
color=self.parent.display_color['basic'])
# not show any y ticks
......@@ -109,7 +114,8 @@ class PlottingAxes:
timestamp_bar.set_xticks(times, minor=True)
timestamp_bar.set_xticks(major_times)
timestamp_bar.set_xticklabels(major_time_labels,
fontsize=self.parent.font_size + 2)
fontsize=self.parent.font_size +
2 * self.parent.ratio_w)
timestamp_bar.set_xlim(self.parent.min_x, self.parent.max_x)
def create_axes(self, plot_b, plot_h, has_min_max_lines=True):
......@@ -148,24 +154,30 @@ class PlottingAxes:
ax.patch.set_alpha(0)
return ax
def set_axes_info(self, ax, sample_no_list,
label=None, info='', y_list=None, chan_db_info=None,
linked_ax=None):
def set_axes_info(self, ax: Axes,
sample_no_list: List[int],
sample_no_colors: List[str] = [clr['W'], clr['W']],
sample_no_pos: List[float] = [0.05, 0.95],
label: Optional[str] = None,
info: str = '',
y_list: Optional[np.ndarray] = None,
chan_db_info: Optional[Dict] = None,
linked_ax: Optional[Axes] = None):
"""
Draw plot's title, sub title, sample total label, center line, y labels
for a channel.
:param ax: matplotlib.axes.Axes - axes of a channel
:param sample_no_list: [int,] - list of totals of different sample
groups
:param label: str/None - title of the plot.
If None, show chan_db_info['label']
:param info: str - additional info to show in sub title which is
:param ax: axes of a channel
:param sample_no_list: list of totals of different sample groups
:param sample_no_colors: list of color to display sample numbers
:param sample_no_pos: list of position to display sample numbers
top/bottom
:param label: title of the plot. If None, show chan_db_info['label']
:param info: additional info to show in sub title which is
smaller and under title on the left side
:param y: numpy.array - y values of the channel, to show min/max labels
and min/max lines
:param chan_db_info: dict - info of channel from database
:param linked_ax: matplotlib.axes.Axes/None -
:param y: y values of the channel for min/max labels, min/max lines
:param chan_db_info: info of channel from database
:param linked_ax:
if linked_ax is None, this is a main channel, label of channel will
be displayed with title's format, on top right of plot.
if linked_ax is not None, this is a channel using main channel's
......@@ -181,6 +193,7 @@ class PlottingAxes:
if label is None:
label = chan_db_info['label']
title_ver_alignment = 'center'
# set info in subtitle under title
if linked_ax is not None:
......@@ -211,7 +224,7 @@ class PlottingAxes:
rotation='horizontal',
transform=ax.transAxes,
color=color,
size=self.parent.font_size + 2
size=self.parent.font_size + 2 * self.parent.ratio_w
)
# set samples' total on right side
......@@ -223,7 +236,7 @@ class PlottingAxes:
verticalalignment='center',
rotation='horizontal',
transform=ax.transAxes,
color=self.parent.display_color['basic'],
color=sample_no_colors[0],
size=self.parent.font_size
)
else:
......@@ -233,30 +246,31 @@ class PlottingAxes:
# on data created in trim_downsample_chan_with_spr_less_or_equal_1
# and won't be changed in set_lim, then don't need to assign a
# variable for it.
# bottom
ax.text(
1.005, 0.25,
1.005, sample_no_pos[0],
sample_no_list[0],
horizontalalignment='left',
verticalalignment='center',
rotation='horizontal',
transform=ax.transAxes,
color=self.parent.display_color['basic'],
color=sample_no_colors[0],
size=self.parent.font_size
)
# top
ax.text(
1.005, 0.75,
1.005, sample_no_pos[1],
sample_no_list[1],
horizontalalignment='left',
verticalalignment='center',
rotation='horizontal',
transform=ax.transAxes,
color=self.parent.display_color['basic'],
color=sample_no_colors[1],
size=self.parent.font_size
)
if linked_ax is not None:
ax.set_yticks([])
return
if y_list is None:
# draw center line
ax.plot([self.parent.min_x, self.parent.max_x],
......@@ -311,15 +325,15 @@ class PlottingAxes:
:param gaps: [[float, float], ] - list of [min, max] of gaps
"""
if self.main_window.min_gap is None:
if self.main_window.gap_minimum is None:
return
self.gaps = gaps = get_gaps(gaps, self.main_window.min_gap)
self.gaps = gaps
self.parent.plotting_bot -= 0.003
self.parent.gap_bar = self.create_axes(self.parent.plotting_bot,
0.001,
has_min_max_lines=False)
gap_label = f"GAP({self.main_window.min_gap}min)"
gap_label = f"GAP({self.main_window.gap_minimum}sec)"
h = 0.001 # height of rectangle represent gap
self.set_axes_info(self.parent.gap_bar, [len(gaps)],
label=gap_label)
......@@ -341,17 +355,23 @@ class PlottingAxes:
)
)
def get_height(self, ratio, bw_plots_distance=0.0015):
def get_height(self, ratio: float, bw_plots_distance: float = 0.0015,
pixel_height: float = 19) -> float:
"""
Calculate new plot's bottom position and return plot's height.
:param ratio: float - ratio of the plot height on the BASIC_HEIGHT
:param bw_plots_distance: float - distance between plots
:return plot_h: float - height of the plot
:param ratio: ratio of the plot height on the BASIC_HEIGHT
:param bw_plots_distance: distance between plots
:param pixel_height: height of plot in pixel (
for TPS/TPS legend, height of each day row)
:return plot_h: height of the plot
"""
plot_h = constants.BASIC_HEIGHT * ratio # ratio with figure height
self.parent.plotting_bot -= plot_h + bw_plots_distance
self.parent.plotting_bot_pixel += 19 * ratio
bw_plots_distance_pixel = 3000 * bw_plots_distance
self.parent.plotting_bot_pixel += (pixel_height * ratio +
bw_plots_distance_pixel)
return plot_h
def add_ruler(self, color):
......@@ -392,4 +412,4 @@ class PlottingAxes:
horizontalalignment='left',
transform=self.parent.timestamp_bar_top.transAxes,
color=self.parent.display_color['basic'],
size=self.parent.font_size)
size=self.parent.font_size + 2 * self.parent.ratio_w)
from typing import List, Dict
from PySide2 import QtCore
from obspy import UTCDateTime
from obspy.core import Trace
from sohstationviewer.conf import constants as const
import numpy as np
# from sohstationviewer.model.decimator import Decimator
from sohstationviewer.model.downsampler import Downsampler
from sohstationviewer.model.handling_data import \
trim_downsample_chan_with_spr_less_or_equal_1
from sohstationviewer.view.plotting.plotting_widget.plotting_processor_helper\
import downsample
class PlottingChannelProcessorSignals(QtCore.QObject):
......@@ -33,10 +25,6 @@ class PlottingChannelProcessor(QtCore.QRunnable):
self.stop_requested = False
self.downsampler = Downsampler()
# self.downsampler = Decimator()
self.decimator = self.downsampler
self.channel_data: dict = channel_data
self.channel_id = channel_id
......@@ -44,288 +32,27 @@ class PlottingChannelProcessor(QtCore.QRunnable):
self.end_time = end_time
self.first_time = first_time
self.trimmed_trace_list = None
self.downsampled_times_list = []
self.downsampled_data_list = []
self.downsampled_list_lock = QtCore.QMutex()
def trim_plotting_data(self) -> List[Dict]:
"""
Trim off plotting traces whose times do not intersect the closed
interval [self.start_time, self.end_time]. Store the traces that are
not removed in self.trimmed_trace_list.
"""
data_start_time = self.channel_data['tracesInfo'][0]['startTmEpoch']
data_end_time = self.channel_data['tracesInfo'][-1]['endTmEpoch']
if (self.start_time > data_end_time
or self.end_time < data_start_time):
return []
good_start_indices = [index
for index, tr
in enumerate(self.channel_data['tracesInfo'])
if tr['startTmEpoch'] > self.start_time]
if good_start_indices:
start_idx = good_start_indices[0]
if start_idx > 0:
start_idx -= 1 # start_time in middle of trace
else:
start_idx = 0
good_end_indices = [idx
for idx, tr
in enumerate(self.channel_data['tracesInfo'])
if tr['endTmEpoch'] <= self.end_time]
if good_end_indices:
end_idx = good_end_indices[-1]
if end_idx < len(self.channel_data['tracesInfo']) - 1:
end_idx += 1 # end_time in middle of trace
else:
end_idx = 0
end_idx += 1 # a[x:y+1] = [a[x], ...a[y]]
good_indices = slice(start_idx, end_idx)
self.trimmed_trace_list = self.channel_data['tracesInfo'][good_indices]
def init_downsampler_(self):
"""
Initialize the downsampler by loading the memmapped traces' data into
Obsby Trace and creating a downsampler worker for each loaded trace
which use Obspy's decimate for downsampling
Currently using decimate from obspy is slower than using downsample.
Besides, decimate taking sample constantly while downsample which using
chunckminmax, taking min, max of each part, is better in detecting
spike of signal.
We decide to not use this function but leave this here as reference
to compare with the result of other method.
"""
decimate_factor = int(self.channel_size / const.CHAN_SIZE_LIMIT)
if decimate_factor > 16:
decimate_factor = 16
do_decimate = decimate_factor > 1
for tr in self.trimmed_trace_list:
if not self.stop_requested:
trace = Trace(data=np.memmap(tr['data_f'], dtype='int64',
mode='r', shape=tr['size']))
trace.stats.starttime = UTCDateTime(tr['startTmEpoch'])
trace.stats.sampling_rate = tr['samplerate']
worker = self.decimator.add_worker(
trace, decimate_factor, do_decimate
)
# We need these connections to run in the background thread.
# However, their owner (the channel processor) is in the main
# thread, so the default connection type would make them
# run in the main thread. Instead, we have to use a direct
# connection to make these slots run in the background thread.
worker.signals.finished.connect(
self.decimator_trace_processed,
type=QtCore.Qt.DirectConnection
)
worker.signals.stopped.connect(
self.stopped,
type=QtCore.Qt.DirectConnection
)
def init_downsampler(self):
"""
Initialize the downsampler by loading the memmapped traces' data and
creating a downsampler worker for each loaded trace.
"""
# Calculate the number of requested_points
total_size = sum([tr['size'] for tr in self.trimmed_trace_list])
requested_points = 0
if total_size > const.CHAN_SIZE_LIMIT:
requested_points = int(
const.CHAN_SIZE_LIMIT / len(self.trimmed_trace_list)
)
# Downsample the data
for tr_idx, tr in enumerate(self.trimmed_trace_list):
if not self.stop_requested:
times = np.linspace(tr['startTmEpoch'], tr['endTmEpoch'],
tr['size'])
data = np.memmap(tr['data_f'],
dtype='int64', mode='r',
shape=tr['size'])
indexes = np.where((self.start_time <= times) &
(times <= self.end_time))
times = times[indexes]
data = data[indexes]
do_downsample = (requested_points != 0)
worker = self.downsampler.add_worker(
times, data, rq_points=requested_points,
do_downsample=do_downsample
)
# We need these connections to run in the background thread.
# However, their owner (the channel processor) is in the main
# thread, so the default connection type would make them
# run in the main thread. Instead, we have to use a direct
# connection to make these slots run in the background thread.
worker.signals.finished.connect(
self.trace_processed, type=QtCore.Qt.DirectConnection
)
worker.signals.stopped.connect(
self.stopped, type=QtCore.Qt.DirectConnection
)
@QtCore.Slot()
def trace_processed(self, times, data):
"""
The slot called when the downsampler worker of a plotting trace
finishes its job. Add the downsampled data to the appropriate list.
If the worker that emitted the signal is the last one, combine and
store the processed data in self.channel_data but not combine when
there is an overlap and then emit the finished signal of this class.
:param times: the downsampled array of time data.
:param data: the downsampled array of plotting data.
"""
self.downsampled_list_lock.lock()
self.downsampled_times_list.append(times)
self.downsampled_data_list.append(data)
self.downsampled_list_lock.unlock()
if len(self.downsampled_times_list) == len(self.trimmed_trace_list):
times_list = []
data_list = []
last_end_time = 0
current_times = []
current_data = []
for idx, tr in enumerate(self.trimmed_trace_list):
# combine traces together but split at overlap
if tr['startTmEpoch'] > last_end_time:
current_times.append(self.downsampled_times_list[idx])
current_data.append(self.downsampled_data_list[idx])
else:
if len(current_times) > 0:
times_list.append(np.hstack(current_times))
data_list.append(np.hstack(current_data))
current_times = [self.downsampled_times_list[idx]]
current_data = [self.downsampled_data_list[idx]]
last_end_time = tr['endTmEpoch']
times_list.append(np.hstack(current_times))
data_list.append(np.hstack(current_data))
self.channel_data['times'] = times_list
self.channel_data['data'] = data_list
self.signals.finished.emit(self.channel_data, self.channel_id)
@QtCore.Slot()
def decimator_trace_processed(self, trace: Trace):
"""
The slot called when the decimator worker of a plotting trace
finishes its job. Add the decimated trace.data to the appropriate list,
construct time using np.linspace and add to the appropriate list.
If the worker that emitted the signal is the last one, combine and
store the processed data in self.channel_data but not combine when
there is an overlap and then emit the finished signal of this class.
:param trace: the decimated trace.
"""
self.downsampled_list_lock.lock()
self.downsampled_times_list.append(
np.linspace(trace.stats.starttime.timestamp,
trace.stats.endtime.timestamp,
trace.stats.npts)
)
self.downsampled_data_list.append(trace.data)
self.downsampled_list_lock.unlock()
if len(self.downsampled_times_list) == len(self.trimmed_trace_list):
times_list = []
data_list = []
last_end_time = 0
current_times = []
current_data = []
for idx, tr in enumerate(self.trimmed_trace_list):
# combine traces together but split at overlap
if tr['startTmEpoch'] > last_end_time:
current_times.append(self.downsampled_times_list[idx])
current_data.append(self.downsampled_data_list[idx])
else:
if len(current_times) > 0:
times_list.append(np.hstack(current_times))
data_list.append(np.hstack(current_data))
current_times = [self.downsampled_times_list[idx]]
current_data = [self.downsampled_data_list[idx]]
last_end_time = tr['endTmEpoch']
times_list.append(np.hstack(current_times))
data_list.append(np.hstack(current_data))
self.channel_data['times'] = times_list
self.channel_data['data'] = data_list
self.signals.finished.emit(self.channel_data, self.channel_id)
def run(self):
"""
The main method of this class. First check that the channel is not
already small enough after the first trim that there is no need for
further processing. Then, trim the plotting data based on
self.start_time and self.end_time. Afterwards, do some checks to
determine if there is a need to downsample the data. If yes, initialize
and start the downsampler.
Because of changes that read less data instead of all data in files,
now data has only one trace. We can assign the times and data in that
trace to times and data of the channel. Trimming won't be necessary
anymore.
"""
if 'needProcess' in self.channel_data:
# refer to DataTypeModel.reset_need_process_for_mass_pos
# for needProcess
if not self.channel_data['needProcess']:
self.finished.emit(self.channel_data, self.channel_id)
return
else:
# put needProcess flag down
self.channel_data['needProcess'] = False
if self.channel_data['fullData']:
# Data is small, already has full in the first trim.
self.finished.emit(self.channel_data, self.channel_id)
return
self.trim_plotting_data()
if not self.trimmed_trace_list:
self.channel_data['fullData'] = True
self.channel_data['times'] = np.array([])
self.channel_data['data'] = np.array([])
self.finished.emit(self.channel_data, self.channel_id)
return False
if self.channel_data['samplerate'] <= 1:
self.channel_data['needConvert'] = True
self.channel_data['times'] = [
tr['times'] for tr in self.trimmed_trace_list]
self.channel_data['data'] = [
tr['data'] for tr in self.trimmed_trace_list]
trim_downsample_chan_with_spr_less_or_equal_1(
self.channel_data, self.start_time, self.end_time)
self.finished.emit(self.channel_data, self.channel_id)
return
self.channel_size = sum(
[tr['size'] for tr in self.trimmed_trace_list])
total_size = sum([tr['size'] for tr in self.trimmed_trace_list])
if not self.first_time and total_size > const.RECAL_SIZE_LIMIT:
# The data is so big that processing it would not make it any
# easier to understand the result plot.
self.finished.emit(self.channel_data, self.channel_id)
return
if total_size <= const.CHAN_SIZE_LIMIT and self.first_time:
self.channel_data['fullData'] = True
try:
del self.channel_data['times']
del self.channel_data['data']
except Exception:
pass
tr = self.channel_data['tracesInfo'][0]
if 'logIdx' in tr.keys():
tr_times, tr_data, tr_logidx = downsample(
tr['times'], tr['data'], tr['logIdx'],
rq_points=const.CHAN_SIZE_LIMIT)
self.channel_data['logIdx'] = [tr_logidx]
else:
tr_times, tr_data, _ = downsample(
tr['times'], tr['data'], rq_points=const.CHAN_SIZE_LIMIT)
self.channel_data['times'] = [tr_times]
self.channel_data['data'] = [tr_data]
self.channel_data['needConvert'] = True
self.init_downsampler()
self.downsampler.start()
self.finished.emit(self.channel_data, self.channel_id)
def request_stop(self):
"""
......@@ -333,4 +60,3 @@ class PlottingChannelProcessor(QtCore.QRunnable):
running.
"""
self.stop_requested = True
self.downsampler.request_stop()
import numpy as np
import math
from sohstationviewer.conf import constants as const
def downsample(times, data, log_indexes=None, rq_points=0):
"""
Reduce sample rate of times and data so that times and data return has
the size around the rq_points.
Since the functions used for downsampling (chunk_minmax()/constant_rate)
are very slow, the values of data from mean to CUT_FROM_MEAN_FACTOR
will be removed first. If the size not meet the rq_points, then
continue to downsample.
:param times: numpy array - of a waveform channel's times
:param data: numpy array - of a waveform channel's data
:param log_indexes: numpy array - of a waveform channel's soh message line
index
:param rq_points: int - requested size to return.
:return np.array, np.array,(np.array) - new times and new data (and new
log_indexes) with the requested size
"""
# create a dummy array for log_indexes. However this way may slow down
# the performance of waveform downsample because waveform channel are large
# and have no log_indexes.
if times.size <= rq_points:
return times, data, log_indexes
if log_indexes is None:
log_indexes = np.empty_like(times)
data_max = max(abs(data.max()), abs(data.min()))
data_mean = abs(data.mean())
indexes = np.where(
abs(data - data.mean()) >
(data_max - data_mean) * const.CUT_FROM_MEAN_FACTOR)
times = times[indexes]
data = data[indexes]
log_indexes = log_indexes[indexes]
if times.size <= rq_points:
return times, data, log_indexes
return chunk_minmax(times, data, log_indexes, rq_points)
def chunk_minmax(times, data, log_indexes, rq_points):
"""
Split data into different chunks, take the min, max of each chunk to add
to the data return
:param times: numpy array - of a channel's times
:param data: numpy array - of a channel's data
:param log_indexes: numpy array - of a channel's log_indexes
:param rq_points: int - requested size to return.
:return times, data: np.array, np.array - new times and new data with the
requested size
"""
final_points = 0
if times.size <= rq_points:
final_points += times.size
return times, data, log_indexes
if rq_points < 2:
return np.empty((1, 0)), np.empty((1, 0)), np.empty((1, 0))
# Since grabbing the min and max from each
# chunk, need to div the requested number of points
# by 2.
chunk_size = rq_points // 2
chunk_count = math.ceil(times.size / chunk_size)
if chunk_count * chunk_size > times.size:
chunk_count -= 1
# Length of the trace is not divisible by the number of requested
# points. So split into an array that is divisible by the requested
# size, and an array that contains the excess. Downsample both,
# and combine. This case gives slightly more samples than
# the requested sample size, but not by much.
times_0 = times[:chunk_count * chunk_size]
data_0 = data[:chunk_count * chunk_size]
log_indexes_0 = log_indexes[:chunk_count * chunk_size]
excess_times = times[chunk_count * chunk_size:]
excess_data = data[chunk_count * chunk_size:]
excess_log_indexes = data[chunk_count * chunk_size:]
new_times_0, new_data_0, new_log_indexes_0 = downsample(
times_0, data_0, log_indexes_0, rq_points=rq_points
)
# right-most subarray is always smaller than
# the initially requested number of points.
excess_times, excess_data, excess_log_indexes = downsample(
excess_times, excess_data, excess_log_indexes,
rq_points=chunk_count
)
new_times = np.zeros(new_times_0.size + excess_times.size)
new_data = np.zeros(new_data_0.size + excess_data.size)
new_log_indexes = np.zeros(
new_log_indexes_0.size + excess_log_indexes.size
)
new_times[:new_times_0.size] = new_times_0
new_data[:new_data_0.size] = new_data_0
new_log_indexes[:new_log_indexes_0.size] = new_log_indexes_0
new_times[new_times_0.size:] = excess_times
new_data[new_data_0.size:] = excess_data
new_log_indexes[new_log_indexes_0.size:] = excess_log_indexes
return new_times, new_data, new_log_indexes
new_times = times.reshape(chunk_size, chunk_count)
new_data = data.reshape(chunk_size, chunk_count)
new_log_indexes = log_indexes.reshape(chunk_size, chunk_count)
min_data_idx = np.argmin(new_data, axis=1)
max_data_idx = np.argmax(new_data, axis=1)
rows = np.arange(chunk_size)
mask = np.zeros(shape=(chunk_size, chunk_count), dtype=bool)
mask[rows, min_data_idx] = True
mask[rows, max_data_idx] = True
new_times = new_times[mask]
new_data = new_data[mask]
new_log_indexes = new_log_indexes[mask]
return new_times, new_data, new_log_indexes
......@@ -2,10 +2,10 @@
Class of which object is used to plot data
"""
from typing import List, Optional, Union
import matplotlib.text
from PySide2.QtCore import QTimer, Qt
from matplotlib import pyplot as pl
from matplotlib.transforms import Bbox
from PySide2.QtCore import QTimer, Qt
from PySide2 import QtCore, QtWidgets
from PySide2.QtWidgets import QWidget, QApplication, QTextBrowser
......@@ -18,6 +18,7 @@ from sohstationviewer.view.plotting.plotting_widget.plotting_axes import (
PlottingAxes
)
from sohstationviewer.view.plotting.plotting_widget.plotting import Plotting
from sohstationviewer.view.save_plot_dialog import SavePlotDialog
from sohstationviewer.controller.plotting_data import format_time
from sohstationviewer.controller.util import display_tracking_info
......@@ -110,6 +111,7 @@ class PlottingWidget(QtWidgets.QScrollArea):
font_size: float - font size on plot. With some require bigger font,
+2 to the font_size
"""
self.base_font_size = 7
self.font_size = 7
"""
bottom: float - y position of the bottom edge of all plots in self.axes
......@@ -243,6 +245,7 @@ class PlottingWidget(QtWidgets.QScrollArea):
# set view size fit with the scroll's view port size
self.main_widget.setFixedWidth(geo.width())
self.ratio_w = geo.width() / self.width_base_px
self.font_size = self.ratio_w * self.base_font_size
self.plotting_w = self.ratio_w * self.width_base
self.plotting_l = self.ratio_w * self.plotting_l_base
if self.plot_total == 0:
......@@ -366,12 +369,6 @@ class PlottingWidget(QtWidgets.QScrollArea):
# tps_t was assigned in TPS Widget
xdata = self.tps_t
else:
if (modifiers == QtCore.Qt.ShiftModifier and
self.zoom_marker1_shown):
# When start zooming, need to reset mass position for processor
# to decide to calculate mass position channel or not
self.data_object.reset_need_process_for_mass_pos()
xdata = self.get_timestamp(event)
# We only want to remove the text on the ruler when we start zooming in
......@@ -652,6 +649,57 @@ class PlottingWidget(QtWidgets.QScrollArea):
"""
self.peer_plotting_widgets = widgets
def save_plot(self, default_name='plot'):
if self.c_mode != self.main_window.color_mode:
main_color = constants.ALL_COLOR_MODES[self.main_window.color_mode]
curr_color = constants.ALL_COLOR_MODES[self.c_mode]
msg = (f"Main window's color mode is {main_color}"
f" but the mode haven't been applied to plotting.\n\n"
f"Do you want to cancel to apply {main_color} mode "
f"by clicking RePlot?\n"
f"Or continue with {curr_color}?")
msgbox = QtWidgets.QMessageBox()
msgbox.setWindowTitle("Color Mode Conflict")
msgbox.setText(msg)
msgbox.addButton(QtWidgets.QMessageBox.Cancel)
msgbox.addButton('Continue', QtWidgets.QMessageBox.YesRole)
result = msgbox.exec_()
if result == QtWidgets.QMessageBox.Cancel:
return
self.main_window.color_mode = self.c_mode
if self.c_mode == 'B':
self.main_window.background_black_radio_button.setChecked(True)
else:
self.main_window.background_white_radio_button.setChecked(True)
if self.c_mode == 'B':
msg = ("The current background mode is black.\n"
"Do you want to cancel to change the background mode "
"before saving the plots to file?")
msgbox = QtWidgets.QMessageBox()
msgbox.setWindowTitle("Background Mode Confirmation")
msgbox.setText(msg)
msgbox.addButton(QtWidgets.QMessageBox.Cancel)
msgbox.addButton('Continue', QtWidgets.QMessageBox.YesRole)
result = msgbox.exec_()
if result == QtWidgets.QMessageBox.Cancel:
return
save_plot_dlg = SavePlotDialog(
self.parent, self.main_window, default_name)
save_plot_dlg.exec_()
save_file_path = save_plot_dlg.save_file_path
if save_file_path is None:
return
dpi = save_plot_dlg.dpi
self.plotting_axes.fig.savefig(
save_file_path,
bbox_inches=Bbox([[0, self.plotting_bot*100],
[self.ratio_w*15.5, 100]]),
dpi=dpi
)
msg = f"Graph is saved at {save_file_path}"
display_tracking_info(self.tracking_box, msg)
def clear(self):
self.plotting_axes.fig.clear()
self.axes = []
......
......@@ -4,12 +4,8 @@ from typing import Tuple, Union, Dict
from sohstationviewer.view.util.plot_func_names import plot_functions
from sohstationviewer.controller.util import apply_convert_factor
from sohstationviewer.model.data_type_model import DataTypeModel
from sohstationviewer.database.extract_data import get_chan_plot_info
from sohstationviewer.view.util.enums import LogType
from sohstationviewer.view.plotting.plotting_widget.\
multi_threaded_plotting_widget import MultiThreadedPlottingWidget
......@@ -35,10 +31,10 @@ class SOHWidget(MultiThreadedPlottingWidget):
:param time_ticks_total: max number of tick to show on time bar
"""
self.data_object = d_obj
self.plotting_data1 = d_obj.soh_data[key]
self.plotting_data2 = d_obj.mass_pos_data[key]
channel_list = d_obj.soh_data[key].keys()
data_time = d_obj.data_time[key]
self.plotting_data1 = d_obj.soh_data[key] if key else {}
self.plotting_data2 = d_obj.mass_pos_data[key] if key else {}
channel_list = d_obj.soh_data[key].keys() if key else []
data_time = d_obj.data_time[key] if key else [0, 1]
ret = super().init_plot(d_obj, data_time, key, start_tm, end_tm,
time_ticks_total, is_waveform=False)
if not ret:
......@@ -52,10 +48,6 @@ class SOHWidget(MultiThreadedPlottingWidget):
self.processing_log.append((msg, LogType.WARNING))
return True
def get_plot_info(self, *args, **kwargs):
# function to get database info for soh channels in self.plotting_data1
return get_chan_plot_info(*args, **kwargs)
def plot_single_channel(self, c_data: Dict, chan_id: str):
"""
Plot the channel chan_id.
......@@ -70,7 +62,6 @@ class SOHWidget(MultiThreadedPlottingWidget):
return
chan_db_info = c_data['chan_db_info']
plot_type = chan_db_info['plotType']
apply_convert_factor(c_data, chan_db_info['convertFactor'])
linked_ax = None
if chan_db_info['linkedChan'] not in [None, 'None', '']:
......
......@@ -13,7 +13,7 @@ from sohstationviewer.controller.util import (
display_tracking_info, add_thousand_separator,
)
from sohstationviewer.database.extract_data import (
get_color_def, get_color_ranges, get_chan_label,
get_color_def, get_color_ranges, get_seismic_chan_label,
)
from sohstationviewer.model.data_type_model import DataTypeModel
from sohstationviewer.model.handling_data import (
......@@ -89,8 +89,10 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
self.is_working = True
self.set_key = key
self.plotting_data1 = d_obj.waveform_data[key]
self.plot_total = len(self.plotting_data1)
self.plotting_bot = const.BOTTOM
self.plotting_bot_pixel = const.BOTTOM_PX
self.processed_channels = []
self.channels = []
self.tps_processors = []
......@@ -111,9 +113,10 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
title = get_title(key, self.min_x, self.max_x, self.date_mode)
self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.)
self.plotting_axes.set_title(title, y=0, v_align='bottom')
self.plotting_axes.set_title(title, y=5, v_align='bottom')
if self.plotting_data1 == {}:
self.is_working = False
self.draw()
self.clean_up('NO DATA')
return
......@@ -219,11 +222,12 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
total_days = c_data['tps_data'].shape[0]
plot_h = self.plotting_axes.get_height(
1.5 * total_days, bw_plots_distance=0.003)
total_days/1.5, bw_plots_distance=0.003, pixel_height=12.1)
ax = self.create_axes(self.plotting_bot, plot_h)
ax.spines[['right', 'left', 'top', 'bottom']].set_visible(False)
ax.text(
-0.1, 1.2,
f"{get_chan_label(chan_id)} {c_data['samplerate']}",
-0.12, 1,
f"{get_seismic_chan_label(chan_id)} {c_data['samplerate']}sps",
horizontalalignment='left',
verticalalignment='top',
rotation='horizontal',
......@@ -233,17 +237,17 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
)
zoom_marker1 = ax.plot(
[], [], marker='|', markersize=10,
[], [], marker='|', markersize=5,
markeredgecolor=self.display_color['zoom_marker'])[0]
self.zoom_marker1s.append(zoom_marker1)
zoom_marker2 = ax.plot(
[], [], marker='|', markersize=10,
[], [], marker='|', markersize=5,
markeredgecolor=self.display_color['zoom_marker'])[0]
self.zoom_marker2s.append(zoom_marker2)
ruler = ax.plot(
[], [], marker='s', markersize=5,
[], [], marker='s', markersize=4,
markeredgecolor=self.display_color['time_ruler'],
markerfacecolor='None')[0]
self.rulers.append(ruler)
......@@ -257,8 +261,8 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
# not draw data out of day range
color_set = self.get_color_set(y, square_counts, color_codes)
# (- dayIdx): each day is a line, increase from top to bottom
ax.scatter(x, [- dayIdx] * len(x), marker='|',
c=color_set, s=7, alpha=0.8)
ax.scatter(x, [- dayIdx] * len(x), marker='s',
c=color_set, s=3)
# extra to show highlight square
ax.set_ylim(-(c_data['tps_data'].shape[0] + 1), 1)
......@@ -273,11 +277,13 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
ax.legend will create one label for each dot.
"""
# set height of legend and distance bw legend and upper ax
plot_h = self.plotting_axes.get_height(7, bw_plots_distance=0.003)
plot_h = self.plotting_axes.get_height(
21, bw_plots_distance=0.004, pixel_height=12)
ax = self.plotting_axes.canvas.figure.add_axes(
[self.plotting_l, self.plotting_bot, self.plotting_w, plot_h],
picker=True
)
ax.axis('off')
ax.patch.set_alpha(0)
c_labels = self.parent.sel_col_labels
clrs = self.parent.color_def # colordef
......@@ -465,6 +471,7 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
with new color range selected.
"""
self.clear()
self.set_colors(self.main_window.color_mode)
self.plotting_bot = const.BOTTOM
title = get_title(self.set_key, self.min_x, self.max_x, self.date_mode)
self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.)
......@@ -553,6 +560,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
"""
self.color_range_choice = QtWidgets.QComboBox(self)
self.color_range_choice.addItems(self.color_ranges)
self.color_range_choice.setCurrentText('High')
color_layout.addWidget(self.color_range_choice)
# ##################### Replot button ########################
......@@ -560,8 +568,8 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
buttons_layout.addWidget(self.replot_button)
# ##################### Save button ##########################
self.save_button = QtWidgets.QPushButton('Save', self)
buttons_layout.addWidget(self.save_button)
self.save_plot_button = QtWidgets.QPushButton('Save Plot', self)
buttons_layout.addWidget(self.save_plot_button)
self.info_text_browser.setFixedHeight(60)
bottom_layout.addWidget(self.info_text_browser)
......@@ -594,7 +602,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
"""
Connect functions to widgets
"""
self.save_button.clicked.connect(self.save)
self.save_plot_button.clicked.connect(self.save_plot)
self.replot_button.clicked.connect(self.plotting_widget.replot)
self.color_range_choice.currentTextChanged.connect(
self.color_range_changed)
......@@ -611,8 +619,8 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
self.sel_col_labels = self.color_label[cr_index]
@QtCore.Slot()
def save(self):
def save_plot(self):
"""
Save the plotting to a file
"""
print("save")
self.plotting_widget.save_plot('TPS-Plot')
import numpy as np
from typing import Dict, Tuple, List
from sohstationviewer.conf import constants as const
def get_start_5mins_of_diff_days(start_tm: float, end_tm: float) -> np.ndarray:
"""
FROM handling_data.get_start_5mins_of_diff_days()
Get the list of the start time of all five minutes for each day start from
the day of startTm and end at the day of endTm.
:param start_tm: float - start time
:param end_tm: float - end time
:return start_5mins_of_diff_days: [[288 of floats], ] - the list of
start of all five minutes of days specified by start_tm and end_tm in
which each day has 288 of 5 minutes.
"""
exact_day_tm = (start_tm // const.SEC_DAY) * const.SEC_DAY
exact_day_tm_list = []
if start_tm < exact_day_tm:
exact_day_tm_list = [exact_day_tm - const.SEC_DAY]
while exact_day_tm < end_tm:
exact_day_tm_list.append(exact_day_tm)
exact_day_tm += const.SEC_DAY
# list of start/end 5m in each day: start_5mins_of_diff_days
for idx, start_day_tm in enumerate(exact_day_tm_list):
start_5mins_of_day = np.arange(start_day_tm,
start_day_tm + const.SEC_DAY,
const.SEC_5M)
if idx == 0:
start_5mins_of_diff_days = np.array([start_5mins_of_day])
else:
start_5mins_of_diff_days = np.vstack(
(start_5mins_of_diff_days, start_5mins_of_day))
return start_5mins_of_diff_days
def find_tps_tm_idx(
given_tm: float, start_5mins_of_diff_days: List[List[float]]) \
-> Tuple[float, float]:
"""
FROM handling_data.find_tps_tm_idx()
Find the position of the given time (given_tm) in time-power-squared plot
:param given_tm: float - given time
:param start_5mins_of_diff_days: [[288 of floats], ] - the list of
start of all five minutes of some specific days in which each day has
288 of 5 minutes.
:return x_idx: int - index of 5m section
:return y_idx: int - index of the day the given time belong to in plotting
"""
x_idx = None
y_idx = None
for day_idx, a_day_5mins in enumerate(start_5mins_of_diff_days):
for start_5m_idx, start_5m in enumerate(a_day_5mins):
if start_5m > given_tm:
# index of day start from 0 to negative because day is plotted
# from top to bottom
y_idx = - day_idx
x_idx = start_5m_idx - 1
if start_5m_idx == 0:
# if the start_5m_idx == 0, the given time belong to the
# last 5m of the previous day
y_idx = -(day_idx - 1)
x_idx = const.NO_5M_DAY - 1
break
if x_idx is not None:
break
if x_idx is None:
# x_idx == None happens when the given time fall into the last 5m of
# the last day. Although the time 24:00 of the last day belongs
# to the next days of other cases, but since there is no more days to
# plot it, it is no harm to set it at the last 5m of the last day.
x_idx = const.NO_5M_DAY - 1
y_idx = - (len(start_5mins_of_diff_days) - 1)
return x_idx, y_idx
def get_tps_for_discontinuous_data(
channel_data: Dict,
start_5mins_of_diff_days: List[List[float]]) -> np.ndarray:
"""
First loop: look in times for indexes for each block of 5m of each day.
Because data is discontinuous, some block might have no data points.
Second loop: For each 5m block, calculate mean of all square of data in
that block (mean_square). For the blocks that have no data points,
use the mean of all square of data in the previous and next blocks if
they both have data or else the mean_square will be zero.
:param channel_data: dictionary that keeps data of a waveform channel
:param start_5mins_of_diff_days: the list of starts of all five minutes
of days in which each day has 288 of 5 minutes.
:return: array of mean square of five-minute data that are separated into
days
"""
times = channel_data['tracesInfo'][0]['times']
data = channel_data['tracesInfo'][0]['data']
# create list of indexes for data points of each block of 5m data separated
# into different days
tps_idxs = []
for start5m_of_a_day in start_5mins_of_diff_days:
tps_idxs.append([])
for start5m in start5m_of_a_day:
end5m = start5m + const.SEC_5M
indexes = np.where((start5m <= times) & (times < end5m))[0]
tps_idxs[-1].append(indexes)
# based on tps_idxs, calculated mean square for each 5m data separated into
# different days
tps_data = []
for day_idx in range(len(tps_idxs)):
tps_data.append([])
for idx_5m in range(len(tps_idxs[day_idx])):
try:
indexes = tps_idxs[day_idx][idx_5m]
if len(indexes) == 0:
# No data point, check both sides, if have data points then
# calculate mean square of both sides' data points
prev_indexes = tps_idxs[day_idx][idx_5m - 1]
if idx_5m < len(tps_idxs[day_idx]) - 1:
next_indexes = tps_idxs[day_idx][idx_5m + 1]
else:
# current 5m block is the last one, the right side
# is the first 5m block of the next day
next_indexes = tps_idxs[day_idx + 1][0]
if len(prev_indexes) != 0 and len(next_indexes) != 0:
indexes = np.hstack((prev_indexes, next_indexes))
if len(indexes) == 0:
mean_square = 0
else:
data5m = data[indexes]
mean_square = np.mean(np.square(data5m))
except IndexError:
mean_square = 0
tps_data[-1].append(mean_square)
return np.array(tps_data)
def get_tps_for_continuous_data(channel_data: Dict,
start_5mins_of_diff_days: List[List[float]],
start_time, end_time):
"""
Different from soh_data where times and data are each in one np.array,
in waveform_data, times and data are each kept in a list of np.memmap
files along with startTmEpoch and endTmEpoch.
self.channel_data['startIdx'] and self.channel_data['endIdx'] will be
used to exclude np.memmap files that aren't in the zoom time range
(startTm, endTm). Data in np.memmap will be trimmed according to times
then time-power-square value for each 5 minutes will be calculated and
saved in channel_data['tps-data']: np.mean(np.square(5m data))
"""
# preset all 0 for all 5 minutes for each day
tps_data = np.zeros((len(start_5mins_of_diff_days),
const.NO_5M_DAY))
spr = channel_data['samplerate']
channel_data['tps_data'] = []
start_tps_tm = 0
acc_data_list = []
for tr_idx, tr in enumerate(channel_data['tracesInfo']):
if 'data_f' in tr:
times = np.linspace(tr['startTmEpoch'], tr['endTmEpoch'],
tr['size'])
data = np.memmap(tr['data_f'],
dtype='int64', mode='r',
shape=tr['size'])
else:
times = tr['times']
data = tr['data']
start_index = 0
if tr_idx == 0:
# get index of times with closet value to startTm
start_index = np.abs(times - start_time).argmin()
start_tps_tm = times[start_index]
# identify index in case of overlaps or gaps
index = np.where(
(start_5mins_of_diff_days <= times[start_index]) &
(start_5mins_of_diff_days + const.SEC_5M > times[start_index])
# noqa: E501
)
curr_row = index[0][0]
curr_col = index[1][0]
next_tps_tm = start_tps_tm + const.SEC_5M
while end_time >= next_tps_tm:
next_index = int(start_index + spr * const.SEC_5M)
if next_index >= tr['size']:
acc_data_list.append(data[start_index:tr['size']])
break
else:
acc_data_list.append(
np.square(data[start_index:next_index]))
acc_data = np.hstack(acc_data_list)
if acc_data.size == 0:
tps_data[curr_row, curr_col] = 0
else:
tps_data[curr_row, curr_col] = np.mean(acc_data)
start_index = next_index
curr_col += 1
acc_data_list = []
if curr_col == const.NO_5M_DAY:
curr_col = 0
curr_row += 1
next_tps_tm += const.SEC_5M
return tps_data
......@@ -3,7 +3,8 @@ from typing import Dict, Optional, List
import numpy as np
from PySide2 import QtCore
from sohstationviewer.conf import constants as const
from sohstationviewer.view.plotting.time_power_squared_helper import \
get_tps_for_discontinuous_data
class TimePowerSquaredProcessorSignal(QtCore.QObject):
......@@ -76,75 +77,9 @@ class TimePowerSquaredProcessor(QtCore.QRunnable):
saved in channel_data['tps-data']: np.mean(np.square(5m data))
"""
trimmed_traces_list = self.trim_waveform_data()
self.channel_data['tps_data'] = get_tps_for_discontinuous_data(
self.channel_data, self.start_5mins_of_diff_days)
# preset all 0 for all 5 minutes for each day
tps_data = np.zeros((len(self.start_5mins_of_diff_days),
const.NO_5M_DAY))
spr = self.channel_data['samplerate']
self.channel_data['tps_data'] = []
start_tps_tm = 0
acc_data_list = []
for tr_idx, tr in enumerate(trimmed_traces_list):
self.stop_lock.lock()
if self.stop:
self.stop_lock.unlock()
return self.signals.stopped.emit('')
self.stop_lock.unlock()
if 'data_f' in tr:
times = np.linspace(tr['startTmEpoch'], tr['endTmEpoch'],
tr['size'])
data = np.memmap(tr['data_f'],
dtype='int64', mode='r',
shape=tr['size'])
else:
times = tr['times']
data = tr['data']
start_index = 0
if tr_idx == 0:
# get index of times with closet value to startTm
start_index = np.abs(times - self.start_time).argmin()
start_tps_tm = times[start_index]
# identify index in case of overlaps or gaps
index = np.where(
(self.start_5mins_of_diff_days <= times[start_index]) &
(self.start_5mins_of_diff_days + const.SEC_5M > times[start_index]) # noqa: E501
)
curr_row = index[0][0]
curr_col = index[1][0]
next_tps_tm = start_tps_tm + const.SEC_5M
while self.end_time >= next_tps_tm:
self.stop_lock.lock()
if self.stop:
self.stop_lock.unlock()
return self.signals.stopped.emit('')
self.stop_lock.unlock()
next_index = int(start_index + spr * const.SEC_5M)
if next_index >= tr['size']:
acc_data_list.append(data[start_index:tr['size']])
break
else:
acc_data_list.append(
np.square(data[start_index:next_index]))
acc_data = np.hstack(acc_data_list)
if acc_data.size == 0:
tps_data[curr_row, curr_col] = 0
else:
tps_data[curr_row, curr_col] = np.mean(acc_data)
start_index = next_index
curr_col += 1
acc_data_list = []
if curr_col == const.NO_5M_DAY:
curr_col = 0
curr_row += 1
next_tps_tm += const.SEC_5M
self.channel_data['tps_data'] = tps_data
self.signals.finished.emit(self.channel_id)
def request_stop(self):
......
......@@ -9,10 +9,6 @@ from sohstationviewer.view.util.plot_func_names import plot_functions
from sohstationviewer.view.plotting.plotting_widget.\
multi_threaded_plotting_widget import MultiThreadedPlottingWidget
from sohstationviewer.controller.util import apply_convert_factor
from sohstationviewer.database.extract_data import get_wf_plot_info
class WaveformWidget(MultiThreadedPlottingWidget):
"""
......@@ -33,16 +29,12 @@ class WaveformWidget(MultiThreadedPlottingWidget):
:param time_ticks_total: max number of tick to show on time bar
"""
self.data_object = d_obj
self.plotting_data1 = d_obj.waveform_data[key]
self.plotting_data2 = d_obj.mass_pos_data[key]
data_time = d_obj.data_time[key]
self.plotting_data1 = d_obj.waveform_data[key] if key else {}
self.plotting_data2 = d_obj.mass_pos_data[key] if key else {}
data_time = d_obj.data_time[key] if key else [0, 1]
return super().init_plot(d_obj, data_time, key, start_tm, end_tm,
time_ticks_total, is_waveform=True)
def get_plot_info(self, *args, **kwargs):
# function to get database info for wf channels in self.plotting_data1
return get_wf_plot_info(*args, **kwargs)
def plot_single_channel(self, c_data: Dict, chan_id: str):
"""
Plot the channel chan_id.
......@@ -57,7 +49,7 @@ class WaveformWidget(MultiThreadedPlottingWidget):
return
chan_db_info = c_data['chan_db_info']
plot_type = chan_db_info['plotType']
apply_convert_factor(c_data, chan_db_info['convertFactor'])
# refer to doc string for mass_pos_data to know the reason for 'ax_wf'
if 'ax_wf' not in c_data:
ax = getattr(self.plotting, plot_functions[plot_type][1])(
......@@ -93,7 +85,7 @@ class WaveformDialog(QtWidgets.QWidget):
data_type: str - type of data being plotted
"""
self.data_type = None
self.setGeometry(300, 300, 1200, 700)
self.setGeometry(50, 10, 1600, 700)
self.setWindowTitle("Raw Data Plot")
main_layout = QtWidgets.QVBoxLayout()
......@@ -118,11 +110,11 @@ class WaveformDialog(QtWidgets.QWidget):
bottom_layout = QtWidgets.QHBoxLayout()
main_layout.addLayout(bottom_layout)
"""
save_button: save plot in plotting_widget to file
save_plot_button: save plot in plotting_widget to file
"""
self.save_button = QtWidgets.QPushButton('Save', self)
self.save_button.clicked.connect(self.save)
bottom_layout.addWidget(self.save_button)
self.save_plot_button = QtWidgets.QPushButton('Save Plot', self)
self.save_plot_button.clicked.connect(self.save_plot)
bottom_layout.addWidget(self.save_plot_button)
self.info_text_browser.setFixedHeight(60)
bottom_layout.addWidget(self.info_text_browser)
......@@ -148,11 +140,11 @@ class WaveformDialog(QtWidgets.QWidget):
self.plotting_widget.init_size()
@QtCore.Slot()
def save(self):
def save_plot(self):
"""
Save the plotting to a file
"""
print("save")
self.plotting_widget.save_plot('Waveform-Plot')
def plot_finished(self):
self.parent.is_plotting_waveform = False
import sys
import platform
import os
from pathlib import Path
from typing import Union, Optional
from PySide2 import QtWidgets, QtCore, QtGui
from PySide2.QtWidgets import QApplication, QWidget, QDialog
from sohstationviewer.conf import constants
class SavePlotDialog(QDialog):
def __init__(self, parent: Union[QWidget, QApplication],
main_window: QApplication,
default_name: str):
"""
Dialog allow choosing file format and open file dialog to
save file as
:param parent: the parent widget
:param main_window: to keep path to save file
:param default_name: default name for graph file to be saved as
"""
super(SavePlotDialog, self).__init__(parent)
self.main_window = main_window
"""
save_file_path: path to save file
"""
self.save_file_path: Optional[Path] = None
"""
save_dir_path: path to save dir
"""
self.save_dir_path: Path = main_window.save_plot_dir
"""
dpi: resolution for png format
"""
self.dpi: int = 100
self.save_dir_btn = QtWidgets.QPushButton("Save Directory", self)
self.save_dir_textbox = QtWidgets.QLineEdit(self.save_dir_path)
self.save_filename_textbox = QtWidgets.QLineEdit(default_name)
self.dpi_line_edit = QtWidgets.QSpinBox(self)
self.format_radio_btns = {}
for fmt in constants.IMG_FORMAT:
self.format_radio_btns[fmt] = QtWidgets.QRadioButton(fmt, self)
if fmt == self.main_window.save_plot_format:
self.format_radio_btns[fmt].setChecked(True)
self.cancel_btn = QtWidgets.QPushButton('CANCEL', self)
self.continue_btn = QtWidgets.QPushButton('SAVE PLOT', self)
self.setup_ui()
self.connect_signals()
def setup_ui(self) -> None:
self.setWindowTitle("Save Plot")
main_layout = QtWidgets.QGridLayout()
self.setLayout(main_layout)
main_layout.addWidget(self.save_dir_btn, 0, 0, 1, 1)
self.save_dir_textbox.setFixedWidth(500)
main_layout.addWidget(self.save_dir_textbox, 0, 1, 1, 5)
main_layout.addWidget(QtWidgets.QLabel('Save Filename'),
1, 0, 1, 1)
main_layout.addWidget(self.save_filename_textbox, 1, 1, 1, 5)
main_layout.addWidget(QtWidgets.QLabel('DPI'),
2, 2, 1, 1, QtGui.Qt.AlignRight)
self.dpi_line_edit.setRange(50, 300)
self.dpi_line_edit.setValue(100)
main_layout.addWidget(self.dpi_line_edit, 2, 3, 1, 1)
rowidx = 2
for fmt in self.format_radio_btns:
main_layout.addWidget(self.format_radio_btns[fmt], rowidx, 1, 1, 1)
rowidx += 1
main_layout.addWidget(self.cancel_btn, rowidx, 1, 1, 1)
main_layout.addWidget(self.continue_btn, rowidx, 3, 1, 1)
def connect_signals(self) -> None:
self.save_dir_btn.clicked.connect(self.change_save_directory)
self.cancel_btn.clicked.connect(self.close)
self.continue_btn.clicked.connect(self.on_continue)
@QtCore.Slot()
def change_save_directory(self) -> None:
"""
Show a file selection window and change the GPS data save directory
based on the folder selected by the user.
"""
fd = QtWidgets.QFileDialog(self)
fd.setFileMode(QtWidgets.QFileDialog.Directory)
fd.setDirectory(self.save_dir_textbox.text())
fd.exec_()
new_path = fd.selectedFiles()[0]
self.save_dir_textbox.setText(new_path)
self.save_dir_path = new_path
self.main_window.save_plot_dir = new_path
@QtCore.Slot()
def on_continue(self):
if self.save_dir_textbox.text().strip() == '':
QtWidgets.QMessageBox.warning(
self, "Add Directory",
"A directory need to be given before continue.")
return
if self.save_filename_textbox.text().strip() == '':
QtWidgets.QMessageBox.warning(
self, "Add Filename",
"A file name need to be given before continue.")
return
for img_format in self.format_radio_btns:
if self.format_radio_btns[img_format].isChecked():
save_format = img_format
self.main_window.save_plot_format = img_format
break
self.save_file_path = Path(self.save_dir_path).joinpath(
f"{self.save_filename_textbox.text()}.{save_format}")
self.dpi = self.dpi_line_edit.value()
self.close()
if __name__ == '__main__':
os_name, version, *_ = platform.platform().split('-')
if os_name == 'macOS':
os.environ['QT_MAC_WANTS_LAYER'] = '1'
app = QtWidgets.QApplication(sys.argv)
save_path = '/Users/ldam/Documents/GIT/sohstationviewer/tests/test_data/Q330-sample' # noqa: E501
test = SavePlotDialog(None, 'test_plot')
test.set_save_directory(save_path)
test.exec_()
print("dpi:", test.dpi)
print("save file path:", test.save_file_path)
sys.exit(app.exec_())
......@@ -793,6 +793,8 @@ class UIMainWindow(object):
self.stop_button.clicked.connect(main_window.stop)
self.save_plot_button.clicked.connect(main_window.save_plot)
def read_config(self):
self.config = configparser.ConfigParser()
config_path = Path('sohstationviewer/conf/read_settings.ini')
......
......@@ -96,6 +96,9 @@ def create_table_of_content_file(base_path: Path) -> None:
"this software.\n\n"
"On the left-hand side you will find a list of currently available"
" help topics.\n\n"
"If the links of the Table of Contents are broken, click on Recreate "
"Table of Content <img src='recreate_table_contents.png' height=30 /> "
"to rebuild it.\n\n"
"The home button can be used to return to this page at any time.\n\n"
"# Table of Contents\n\n")
links = ""
......