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 typing import Union, Dict, List, Set, Tuple
from sohstationviewer.controller.plotting_data import format_time from sohstationviewer.controller.plotting_data import format_time
from sohstationviewer.model.data_type_model import DataTypeModel from sohstationviewer.model.general_data.general_data import GeneralData
from sohstationviewer.model.mseed.mseed import MSeed from sohstationviewer.model.mseed_data.mseed import MSeed
from sohstationviewer.model.reftek.reftek import RT130 from sohstationviewer.model.reftek.reftek import RT130
from sohstationviewer.view.util.functions import extract_netcodes 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 date_format: str
) -> Dict[str, Union[str, List[str]]]: ) -> Dict[str, Union[str, List[str]]]:
""" """
...@@ -45,7 +45,7 @@ def extract_data_set_info(data_obj: Union[DataTypeModel, RT130, MSeed], ...@@ -45,7 +45,7 @@ def extract_data_set_info(data_obj: Union[DataTypeModel, RT130, MSeed],
f"\n\t\tTo: {end_time_str}") f"\n\t\tTo: {end_time_str}")
data_set_info['Time ranges'] = '\n\t'.join(time_range_info_list) 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': if data_type == 'RT130':
das_serials = list({key[0] for key in key_sets}) das_serials = list({key[0] for key in key_sets})
experiment_numbers = list({key[1] for key in key_sets}) experiment_numbers = list({key[1] for key in key_sets})
......
...@@ -10,9 +10,9 @@ from PySide2.QtCore import QSize ...@@ -10,9 +10,9 @@ from PySide2.QtCore import QSize
from PySide2.QtGui import QFont, QPalette, QColor from PySide2.QtGui import QFont, QPalette, QColor
from PySide2.QtWidgets import QFrame, QListWidgetItem, QMessageBox from PySide2.QtWidgets import QFrame, QListWidgetItem, QMessageBox
from sohstationviewer.conf import constants
from sohstationviewer.model.data_loader import DataLoader 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.calendar.calendar_dialog import CalendarDialog
from sohstationviewer.view.db_config.channel_dialog import ChannelDialog from sohstationviewer.view.db_config.channel_dialog import ChannelDialog
...@@ -41,8 +41,7 @@ from sohstationviewer.view.channel_prefer_dialog import ChannelPreferDialog ...@@ -41,8 +41,7 @@ from sohstationviewer.view.channel_prefer_dialog import ChannelPreferDialog
from sohstationviewer.controller.processing import detect_data_type from sohstationviewer.controller.processing import detect_data_type
from sohstationviewer.controller.util import ( from sohstationviewer.controller.util import (
display_tracking_info, rt130_find_cf_dass, check_data_sdata, display_tracking_info, rt130_find_cf_dass, check_data_sdata
get_dir_size
) )
from sohstationviewer.database.process_db import execute_db_dict, execute_db from sohstationviewer.database.process_db import execute_db_dict, execute_db
...@@ -63,9 +62,17 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -63,9 +62,17 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
""" """
self.dir_names: List[Path] = [] 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 rt130_das_dict: dict by rt130 for data paths, so user can choose
dasses to assign list of data paths to selected_rt130_paths dasses to assign list of data paths to selected_rt130_paths
...@@ -81,6 +88,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -81,6 +88,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
""" """
self.data_type: str = 'Unknown' 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' color_mode: str - the current color mode of the plot; can be either 'B'
or 'W' or 'W'
""" """
...@@ -117,11 +129,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -117,11 +129,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
""" """
data_object: Object that keep data read from data set for plotting 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 pref_soh_list_name: name of selected preferred channels list
""" """
...@@ -185,6 +197,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -185,6 +197,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
self.validate_config() self.validate_config()
self.apply_config() self.apply_config()
@QtCore.Slot()
def save_plot(self):
self.plotting_widget.save_plot('SOH-Plot')
@QtCore.Slot() @QtCore.Slot()
def open_data_type(self) -> None: def open_data_type(self) -> None:
""" """
...@@ -386,9 +402,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -386,9 +402,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
:rtype: List[str, int] :rtype: List[str, int]
""" """
req_wf_chans = [] req_wf_chans = []
if ((self.all_wf_chans_check_box.isChecked() if (self.data_type != 'RT130' and
or [ds for ds in self.ds_check_boxes if ds.isChecked()] != [] (self.all_wf_chans_check_box.isChecked()
or self.mseed_wildcard_edit.text().strip() != "") or self.mseed_wildcard_edit.text().strip() != "")
and not self.tps_check_box.isChecked() and not self.tps_check_box.isChecked()
and not self.raw_check_box.isChecked()): and not self.raw_check_box.isChecked()):
raise Exception( raise Exception(
...@@ -492,23 +508,28 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -492,23 +508,28 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
raise Exception(msg) raise Exception(msg)
if self.rt130_das_dict == {}: if self.rt130_das_dict == {}:
try: self.data_type, self.is_multiplex = detect_data_type(
self.data_type = detect_data_type(self.dir_names) self.dir_names)
except Exception as e:
raise e
def clear_plots(self): def clear_plots(self):
self.plotting_widget.clear() self.plotting_widget.clear()
self.waveform_dlg.plotting_widget.clear() self.waveform_dlg.plotting_widget.clear()
self.tps_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() @QtCore.Slot()
def read_selected_files(self): def read_selected_files(self):
""" """
Read data from selected files/directories, process and plot channels Read data from selected files/directories, process and plot channels
read from those according to current options set on the GUI 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() self.clear_plots()
start_tm_str = self.time_from_date_edit.date().toString( start_tm_str = self.time_from_date_edit.date().toString(
QtCore.Qt.ISODate) QtCore.Qt.ISODate)
...@@ -518,6 +539,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -518,6 +539,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if self.end_tm <= self.start_tm: if self.end_tm <= self.start_tm:
msg = "To Date must be greater than From Date." msg = "To Date must be greater than From Date."
QtWidgets.QMessageBox.warning(self, "Wrong Date Given", msg) QtWidgets.QMessageBox.warning(self, "Wrong Date Given", msg)
self.cancel_loading()
return return
self.info_list_widget.clear() self.info_list_widget.clear()
is_working = (self.is_loading_data or self.is_plotting_soh or is_working = (self.is_loading_data or self.is_plotting_soh or
...@@ -530,15 +552,23 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -530,15 +552,23 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if self.gap_len_line_edit.text().strip() != '': if self.gap_len_line_edit.text().strip() != '':
try: try:
self.min_gap = float( # convert from minute to second
self.gap_len_line_edit.text()) self.gap_minimum = float(
self.gap_len_line_edit.text()) * 60
except ValueError: except ValueError:
msg = "Minimum Gap must be a number." 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( QtWidgets.QMessageBox.warning(
self, "Invalid Minimum Gap request", msg) self, "Invalid Minimum Gap request", msg)
return return
else: else:
self.min_gap = None self.gap_minimum = None
if self.mseed_wildcard_edit.text().strip() != '': if self.mseed_wildcard_edit.text().strip() != '':
try: try:
...@@ -546,6 +576,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -546,6 +576,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
except Exception as e: except Exception as e:
QtWidgets.QMessageBox.warning( QtWidgets.QMessageBox.warning(
self, "Incorrect Wildcard", str(e)) self, "Incorrect Wildcard", str(e))
self.cancel_loading()
return return
try: try:
...@@ -555,16 +586,29 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -555,16 +586,29 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
except AttributeError: except AttributeError:
pass pass
self.req_soh_chans = (self.pref_soh_list
if not self.all_soh_chans_check_box.isChecked()
else [])
try: try:
self.read_from_file_list() self.read_from_file_list()
except Exception as e: except Exception as e:
QtWidgets.QMessageBox.warning(self, "Select directory", str(e)) if 'no known data detected' in str(e):
return 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) dir_size = sum(get_dir_size(str(dir))[0] for dir in self.dir_names)
if dir_size > constants.BIG_FILE_SIZE: if dir_size > constants.BIG_FILE_SIZE:
data_too_big_dialog = QMessageBox() data_too_big_dialog = QMessageBox()
...@@ -578,13 +622,15 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -578,13 +622,15 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
data_too_big_dialog.setIcon(QMessageBox.Question) data_too_big_dialog.setIcon(QMessageBox.Question)
ret = data_too_big_dialog.exec_() ret = data_too_big_dialog.exec_()
if ret == QMessageBox.Abort: if ret == QMessageBox.Abort:
self.cancel_loading()
return return
"""
self.req_soh_chans = self.get_requested_soh_chan() self.req_soh_chans = self.get_requested_soh_chan()
try: try:
self.req_wf_chans = self.get_requested_wf_chans() self.req_wf_chans = self.get_requested_wf_chans()
except Exception as e: except Exception as e:
QMessageBox.information(self, "Waveform Selection", str(e)) QMessageBox.information(self, "Waveform Selection", str(e))
self.cancel_loading()
return return
start_tm_str = self.time_from_date_edit.date().toString( start_tm_str = self.time_from_date_edit.date().toString(
...@@ -601,10 +647,12 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -601,10 +647,12 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
self.data_loader.init_loader( self.data_loader.init_loader(
self.data_type, self.data_type,
self.tracking_info_text_browser, self.tracking_info_text_browser,
self.is_multiplex,
self.dir_names, self.dir_names,
self.selected_rt130_paths, self.selected_rt130_paths,
req_wf_chans=self.req_wf_chans, req_wf_chans=self.req_wf_chans,
req_soh_chans=self.req_soh_chans, req_soh_chans=self.req_soh_chans,
gap_minimum=self.gap_minimum,
read_start=self.start_tm, read_start=self.start_tm,
read_end=self.end_tm, read_end=self.end_tm,
include_mp123=self.mass_pos_123zne_check_box.isChecked(), include_mp123=self.mass_pos_123zne_check_box.isChecked(),
...@@ -679,13 +727,19 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -679,13 +727,19 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
self.is_stopping = False self.is_stopping = False
@QtCore.Slot() @QtCore.Slot()
def data_loaded(self, data_obj: DataTypeModel): def data_loaded(self, data_obj: GeneralData):
""" """
Process the loaded data. Process the loaded data.
:param data_obj: the data object that contains the loaded data. :param data_obj: the data object that contains the loaded data.
""" """
self.is_loading_data = False self.is_loading_data = False
self.data_object = data_obj 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: try:
self.gps_dialog.gps_points = extract_gps_data(data_obj) self.gps_dialog.gps_points = extract_gps_data(data_obj)
except ValueError as e: except ValueError as e:
...@@ -721,6 +775,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -721,6 +775,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if self.has_problem: if self.has_problem:
return return
self.is_plotting_soh = True 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 d_obj = self.data_object
...@@ -728,7 +786,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -728,7 +786,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
sel_key = d_obj.selected_key sel_key = d_obj.selected_key
d_obj.reset_all_selected_data() d_obj.reset_all_selected_data()
d_obj.reset_need_process_for_mass_pos()
try: try:
check_masspos(d_obj.mass_pos_data[sel_key], sel_key, check_masspos(d_obj.mass_pos_data[sel_key], sel_key,
self.mass_pos_123zne_check_box.isChecked(), self.mass_pos_123zne_check_box.isChecked(),
...@@ -843,6 +900,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -843,6 +900,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
# current directory # current directory
self.current_directory_changed.emit(path) self.current_directory_changed.emit(path)
self.current_dir = path self.current_dir = path
self.save_plot_dir = path
execute_db(f'UPDATE PersistentData SET FieldValue="{path}" WHERE ' execute_db(f'UPDATE PersistentData SET FieldValue="{path}" WHERE '
'FieldName="currentDirectory"') 'FieldName="currentDirectory"')
self.set_open_files_list_texts() self.set_open_files_list_texts()
...@@ -1061,10 +1119,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): ...@@ -1061,10 +1119,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow):
if not checked: if not checked:
return return
self.color_mode = color_mode 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() @QtCore.Slot()
def clear_file_search(self): def clear_file_search(self):
......
...@@ -6,8 +6,7 @@ from typing import List, Optional, Dict, NoReturn ...@@ -6,8 +6,7 @@ from typing import List, Optional, Dict, NoReturn
import numpy as np import numpy as np
from obspy import UTCDateTime from obspy import UTCDateTime
from sohstationviewer.controller.processing import detect_data_type from sohstationviewer.model.mseed_data.mseed import MSeed
from sohstationviewer.model.mseed.mseed import MSeed
from sohstationviewer.model.reftek.reftek import RT130 from sohstationviewer.model.reftek.reftek import RT130
from sohstationviewer.view.plotting.gps_plot.gps_point import GPSPoint from sohstationviewer.view.plotting.gps_plot.gps_point import GPSPoint
from sohstationviewer.view.util.enums import LogType from sohstationviewer.view.util.enums import LogType
...@@ -184,9 +183,10 @@ def get_gps_channel_prefix(data_obj: MSeed, data_type: str) -> Optional[str]: ...@@ -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 # Determine the GPS channels by checking if the current data set
# has all the GPS channels of a data type. # 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' gps_prefix = 'V'
elif centaur_gps_channels & data_obj.channels == centaur_gps_channels: elif centaur_gps_channels & channels == centaur_gps_channels:
gps_prefix = 'G' gps_prefix = 'G'
else: else:
msg = "Can't detect GPS channels." msg = "Can't detect GPS channels."
...@@ -234,7 +234,9 @@ def extract_gps_data_pegasus_centaur(data_obj: MSeed, data_type: str ...@@ -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_prefix = get_gps_channel_prefix(data_obj, data_type)
gps_chans = {gps_prefix + 'NS', gps_prefix + 'LA', gps_prefix + 'LO', gps_chans = {gps_prefix + 'NS', gps_prefix + 'LA', gps_prefix + 'LO',
gps_prefix + 'EL'} 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): if not gps_chans.issubset(channels):
missing_gps_chans = gps_chans - channels missing_gps_chans = gps_chans - channels
missing_gps_chans_string = ', '.join(missing_gps_chans) missing_gps_chans_string = ', '.join(missing_gps_chans)
...@@ -434,8 +436,23 @@ def gps_data_rt130(data_obj: RT130) -> List[GPSPoint]: ...@@ -434,8 +436,23 @@ def gps_data_rt130(data_obj: RT130) -> List[GPSPoint]:
@extract_gps_data.register(MSeed) @extract_gps_data.register(MSeed)
def gps_data_mseed(data_obj: MSeed) -> List[GPSPoint]: 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': if data_type == 'Q330':
return extract_gps_data_q330(data_obj) return extract_gps_data_q330(data_obj)
elif data_type == 'Centaur' or data_type == 'Pegasus': elif data_type == 'Centaur' or data_type == 'Pegasus':
return extract_gps_data_pegasus_centaur(data_obj, data_type) 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 # 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 from PySide2 import QtCore
...@@ -105,20 +105,17 @@ class MultiThreadedPlottingWidget(PlottingWidget): ...@@ -105,20 +105,17 @@ class MultiThreadedPlottingWidget(PlottingWidget):
return True return True
def create_plotting_channel_processors( def create_plotting_channel_processors(
self, plotting_data: Dict, self, plotting_data: Dict, need_db_info: bool = False) -> None:
get_plot_info: Optional[Callable[[str, Dict, str], Dict]]) -> None:
""" """
Create a data processor for each channel data. Create a data processor for each channel data.
:param plotting_data: dict of data by chan_id :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: for chan_id in plotting_data:
if get_plot_info is not None: if need_db_info:
chan_db_info = get_plot_info(chan_id, chan_db_info = get_chan_plot_info(
plotting_data[chan_id], chan_id, self.parent.data_type, self.c_mode)
self.parent.data_type,
self.c_mode)
if chan_db_info['height'] == 0: if chan_db_info['height'] == 0:
# not draw # not draw
continue continue
...@@ -196,16 +193,10 @@ class MultiThreadedPlottingWidget(PlottingWidget): ...@@ -196,16 +193,10 @@ class MultiThreadedPlottingWidget(PlottingWidget):
self.clean_up() self.clean_up()
self.finished.emit() self.finished.emit()
return return
self.create_plotting_channel_processors( self.create_plotting_channel_processors(self.plotting_data1, True)
self.plotting_data1, self.get_plot_info) self.create_plotting_channel_processors(self.plotting_data2, True)
self.create_plotting_channel_processors(
self.plotting_data2, get_chan_plot_info)
self.process_channel() self.process_channel()
def get_plot_info(self, *args, **kwargs):
# function to get database info for channels in self.plotting_data1
pass
@QtCore.Slot() @QtCore.Slot()
def process_channel(self, channel_data=None, channel_id=None): def process_channel(self, channel_data=None, channel_id=None):
""" """
...@@ -347,6 +338,6 @@ class MultiThreadedPlottingWidget(PlottingWidget): ...@@ -347,6 +338,6 @@ class MultiThreadedPlottingWidget(PlottingWidget):
self.is_working = True self.is_working = True
start_msg = 'Zooming in...' start_msg = 'Zooming in...'
display_tracking_info(self.tracking_box, start_msg, 'info') 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_data1)
self.create_plotting_channel_processors(self.plotting_data2, None) self.create_plotting_channel_processors(self.plotting_data2)
self.process_channel() self.process_channel()
# class with all plotting functions # class with all plotting functions
import numpy as np
from sohstationviewer.controller.util import get_val from sohstationviewer.controller.util import get_val
from sohstationviewer.controller.plotting_data import get_masspos_value_colors from sohstationviewer.controller.plotting_data import get_masspos_value_colors
...@@ -75,8 +77,10 @@ class Plotting: ...@@ -75,8 +77,10 @@ class Plotting:
if chan_db_info['valueColors'] in [None, 'None', '']: if chan_db_info['valueColors'] in [None, 'None', '']:
chan_db_info['valueColors'] = '*:W' chan_db_info['valueColors'] = '*:W'
value_colors = chan_db_info['valueColors'].split('|') value_colors = chan_db_info['valueColors'].split('|')
colors = []
for vc in value_colors: for vc in value_colors:
v, c = vc.split(':') v, c = vc.split(':')
colors.append(c)
val = get_val(v) val = get_val(v)
if c == '_': if c == '_':
prev_val = val prev_val = val
...@@ -104,9 +108,14 @@ class Plotting: ...@@ -104,9 +108,14 @@ class Plotting:
total_samples = len(x) total_samples = len(x)
x = sorted(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( self.plotting_axes.set_axes_info(
ax, [total_samples], chan_db_info=chan_db_info, ax, [total_samples], sample_no_colors=sample_no_colors,
linked_ax=linked_ax) chan_db_info=chan_db_info, linked_ax=linked_ax)
if linked_ax is None: if linked_ax is None:
ax.x = x ax.x = x
else: else:
...@@ -168,6 +177,8 @@ class Plotting: ...@@ -168,6 +177,8 @@ class Plotting:
ax.set_ylim(-2, 2) ax.set_ylim(-2, 2)
self.plotting_axes.set_axes_info( self.plotting_axes.set_axes_info(
ax, [len(points_list[0]), len(points_list[1])], 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) chan_db_info=chan_db_info, linked_ax=linked_ax)
if linked_ax is None: if linked_ax is None:
ax.x = x ax.x = x
...@@ -203,7 +214,8 @@ class Plotting: ...@@ -203,7 +214,8 @@ class Plotting:
x_list = c_data['times'] x_list = c_data['times']
total_x = sum([len(x) for x in x_list]) total_x = sum([len(x) for x in x_list])
self.plotting_axes.set_axes_info( 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: for x in x_list:
ax.plot(x, [0] * len(x), marker='s', markersize=1.5, ax.plot(x, [0] * len(x), marker='s', markersize=1.5,
...@@ -250,10 +262,7 @@ class Plotting: ...@@ -250,10 +262,7 @@ class Plotting:
self.parent.plotting_bot, plot_h) self.parent.plotting_bot, plot_h)
x_list, y_list = c_data['times'], c_data['data'] 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 = {} colors = {}
if chan_db_info['valueColors'] not in [None, 'None', '']: if chan_db_info['valueColors'] not in [None, 'None', '']:
color_parts = chan_db_info['valueColors'].split('|') color_parts = chan_db_info['valueColors'].split('|')
...@@ -261,12 +270,27 @@ class Plotting: ...@@ -261,12 +270,27 @@ class Plotting:
obj, c = cStr.split(':') obj, c = cStr.split(':')
colors[obj] = c colors[obj] = c
l_color = 'G' l_color = 'G'
d_color = 'W'
has_dot = False has_dot = False
if 'L' in colors: if 'L' in colors:
l_color = colors['L'] l_color = colors['L']
if 'D' in colors: if 'D' in colors:
d_color = colors['D'] d_color = colors['D']
has_dot = True 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): for x, y in zip(x_list, y_list):
if not has_dot: if not has_dot:
# set marker to be able to click point for info # 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.patches import ConnectionPatch, Rectangle
from matplotlib.ticker import AutoMinorLocator from matplotlib.ticker import AutoMinorLocator
from matplotlib import pyplot as pl from matplotlib import pyplot as pl
...@@ -7,9 +9,10 @@ from matplotlib.backends.backend_qt5agg import ( ...@@ -7,9 +9,10 @@ from matplotlib.backends.backend_qt5agg import (
FigureCanvasQTAgg as Canvas) FigureCanvasQTAgg as Canvas)
from sohstationviewer.controller.plotting_data import ( 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.conf import constants
from sohstationviewer.view.util.color import clr
class PlottingAxes: class PlottingAxes:
...@@ -75,6 +78,7 @@ class PlottingAxes: ...@@ -75,6 +78,7 @@ class PlottingAxes:
labelbottom = False labelbottom = False
else: else:
labelbottom = True labelbottom = True
self.parent.plotting_bot -= 0.007 # space for ticks
timestamp_bar.tick_params(which='major', length=7, width=2, timestamp_bar.tick_params(which='major', length=7, width=2,
direction='inout', direction='inout',
colors=self.parent.display_color['basic'], colors=self.parent.display_color['basic'],
...@@ -87,7 +91,8 @@ class PlottingAxes: ...@@ -87,7 +91,8 @@ class PlottingAxes:
fontweight='bold', fontweight='bold',
fontsize=self.parent.font_size, fontsize=self.parent.font_size,
rotation=0, rotation=0,
labelpad=constants.HOUR_TO_TMBAR_D, labelpad=constants.HOUR_TO_TMBAR_D *
self.parent.ratio_w,
ha='left', ha='left',
color=self.parent.display_color['basic']) color=self.parent.display_color['basic'])
# not show any y ticks # not show any y ticks
...@@ -109,7 +114,8 @@ class PlottingAxes: ...@@ -109,7 +114,8 @@ class PlottingAxes:
timestamp_bar.set_xticks(times, minor=True) timestamp_bar.set_xticks(times, minor=True)
timestamp_bar.set_xticks(major_times) timestamp_bar.set_xticks(major_times)
timestamp_bar.set_xticklabels(major_time_labels, 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) 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): def create_axes(self, plot_b, plot_h, has_min_max_lines=True):
...@@ -148,24 +154,30 @@ class PlottingAxes: ...@@ -148,24 +154,30 @@ class PlottingAxes:
ax.patch.set_alpha(0) ax.patch.set_alpha(0)
return ax return ax
def set_axes_info(self, ax, sample_no_list, def set_axes_info(self, ax: Axes,
label=None, info='', y_list=None, chan_db_info=None, sample_no_list: List[int],
linked_ax=None): 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 Draw plot's title, sub title, sample total label, center line, y labels
for a channel. for a channel.
:param ax: matplotlib.axes.Axes - axes of a channel :param ax: axes of a channel
:param sample_no_list: [int,] - list of totals of different sample :param sample_no_list: list of totals of different sample groups
groups :param sample_no_colors: list of color to display sample numbers
:param label: str/None - title of the plot. :param sample_no_pos: list of position to display sample numbers
If None, show chan_db_info['label'] top/bottom
:param info: str - additional info to show in sub title which is :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 smaller and under title on the left side
:param y: numpy.array - y values of the channel, to show min/max labels :param y: y values of the channel for min/max labels, min/max lines
and min/max lines :param chan_db_info: info of channel from database
:param chan_db_info: dict - info of channel from database :param linked_ax:
:param linked_ax: matplotlib.axes.Axes/None -
if linked_ax is None, this is a main channel, label of channel will 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. 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 if linked_ax is not None, this is a channel using main channel's
...@@ -181,6 +193,7 @@ class PlottingAxes: ...@@ -181,6 +193,7 @@ class PlottingAxes:
if label is None: if label is None:
label = chan_db_info['label'] label = chan_db_info['label']
title_ver_alignment = 'center' title_ver_alignment = 'center'
# set info in subtitle under title # set info in subtitle under title
if linked_ax is not None: if linked_ax is not None:
...@@ -211,7 +224,7 @@ class PlottingAxes: ...@@ -211,7 +224,7 @@ class PlottingAxes:
rotation='horizontal', rotation='horizontal',
transform=ax.transAxes, transform=ax.transAxes,
color=color, color=color,
size=self.parent.font_size + 2 size=self.parent.font_size + 2 * self.parent.ratio_w
) )
# set samples' total on right side # set samples' total on right side
...@@ -223,7 +236,7 @@ class PlottingAxes: ...@@ -223,7 +236,7 @@ class PlottingAxes:
verticalalignment='center', verticalalignment='center',
rotation='horizontal', rotation='horizontal',
transform=ax.transAxes, transform=ax.transAxes,
color=self.parent.display_color['basic'], color=sample_no_colors[0],
size=self.parent.font_size size=self.parent.font_size
) )
else: else:
...@@ -233,30 +246,31 @@ class PlottingAxes: ...@@ -233,30 +246,31 @@ class PlottingAxes:
# on data created in trim_downsample_chan_with_spr_less_or_equal_1 # 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 # and won't be changed in set_lim, then don't need to assign a
# variable for it. # variable for it.
# bottom # bottom
ax.text( ax.text(
1.005, 0.25, 1.005, sample_no_pos[0],
sample_no_list[0], sample_no_list[0],
horizontalalignment='left', horizontalalignment='left',
verticalalignment='center', verticalalignment='center',
rotation='horizontal', rotation='horizontal',
transform=ax.transAxes, transform=ax.transAxes,
color=self.parent.display_color['basic'], color=sample_no_colors[0],
size=self.parent.font_size size=self.parent.font_size
) )
# top # top
ax.text( ax.text(
1.005, 0.75, 1.005, sample_no_pos[1],
sample_no_list[1], sample_no_list[1],
horizontalalignment='left', horizontalalignment='left',
verticalalignment='center', verticalalignment='center',
rotation='horizontal', rotation='horizontal',
transform=ax.transAxes, transform=ax.transAxes,
color=self.parent.display_color['basic'], color=sample_no_colors[1],
size=self.parent.font_size size=self.parent.font_size
) )
if linked_ax is not None:
ax.set_yticks([])
return
if y_list is None: if y_list is None:
# draw center line # draw center line
ax.plot([self.parent.min_x, self.parent.max_x], ax.plot([self.parent.min_x, self.parent.max_x],
...@@ -311,15 +325,15 @@ class PlottingAxes: ...@@ -311,15 +325,15 @@ class PlottingAxes:
:param gaps: [[float, float], ] - list of [min, max] of gaps :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 return
self.gaps = gaps = get_gaps(gaps, self.main_window.min_gap) self.gaps = gaps
self.parent.plotting_bot -= 0.003 self.parent.plotting_bot -= 0.003
self.parent.gap_bar = self.create_axes(self.parent.plotting_bot, self.parent.gap_bar = self.create_axes(self.parent.plotting_bot,
0.001, 0.001,
has_min_max_lines=False) 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 h = 0.001 # height of rectangle represent gap
self.set_axes_info(self.parent.gap_bar, [len(gaps)], self.set_axes_info(self.parent.gap_bar, [len(gaps)],
label=gap_label) label=gap_label)
...@@ -341,17 +355,23 @@ class PlottingAxes: ...@@ -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. Calculate new plot's bottom position and return plot's height.
:param ratio: float - ratio of the plot height on the BASIC_HEIGHT :param ratio: ratio of the plot height on the BASIC_HEIGHT
:param bw_plots_distance: float - distance between plots :param bw_plots_distance: distance between plots
:return plot_h: float - height of the plot :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 plot_h = constants.BASIC_HEIGHT * ratio # ratio with figure height
self.parent.plotting_bot -= plot_h + bw_plots_distance 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 return plot_h
def add_ruler(self, color): def add_ruler(self, color):
...@@ -392,4 +412,4 @@ class PlottingAxes: ...@@ -392,4 +412,4 @@ class PlottingAxes:
horizontalalignment='left', horizontalalignment='left',
transform=self.parent.timestamp_bar_top.transAxes, transform=self.parent.timestamp_bar_top.transAxes,
color=self.parent.display_color['basic'], 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 PySide2 import QtCore
from obspy import UTCDateTime
from obspy.core import Trace
from sohstationviewer.conf import constants as const from sohstationviewer.conf import constants as const
import numpy as np from sohstationviewer.view.plotting.plotting_widget.plotting_processor_helper\
import downsample
# 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
class PlottingChannelProcessorSignals(QtCore.QObject): class PlottingChannelProcessorSignals(QtCore.QObject):
...@@ -33,10 +25,6 @@ class PlottingChannelProcessor(QtCore.QRunnable): ...@@ -33,10 +25,6 @@ class PlottingChannelProcessor(QtCore.QRunnable):
self.stop_requested = False self.stop_requested = False
self.downsampler = Downsampler()
# self.downsampler = Decimator()
self.decimator = self.downsampler
self.channel_data: dict = channel_data self.channel_data: dict = channel_data
self.channel_id = channel_id self.channel_id = channel_id
...@@ -44,288 +32,27 @@ class PlottingChannelProcessor(QtCore.QRunnable): ...@@ -44,288 +32,27 @@ class PlottingChannelProcessor(QtCore.QRunnable):
self.end_time = end_time self.end_time = end_time
self.first_time = first_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): def run(self):
""" """
The main method of this class. First check that the channel is not Because of changes that read less data instead of all data in files,
already small enough after the first trim that there is no need for now data has only one trace. We can assign the times and data in that
further processing. Then, trim the plotting data based on trace to times and data of the channel. Trimming won't be necessary
self.start_time and self.end_time. Afterwards, do some checks to anymore.
determine if there is a need to downsample the data. If yes, initialize
and start the downsampler.
""" """
if 'needProcess' in self.channel_data: tr = self.channel_data['tracesInfo'][0]
# refer to DataTypeModel.reset_need_process_for_mass_pos if 'logIdx' in tr.keys():
# for needProcess tr_times, tr_data, tr_logidx = downsample(
if not self.channel_data['needProcess']: tr['times'], tr['data'], tr['logIdx'],
self.finished.emit(self.channel_data, self.channel_id) rq_points=const.CHAN_SIZE_LIMIT)
return self.channel_data['logIdx'] = [tr_logidx]
else: else:
# put needProcess flag down tr_times, tr_data, _ = downsample(
self.channel_data['needProcess'] = False tr['times'], tr['data'], rq_points=const.CHAN_SIZE_LIMIT)
self.channel_data['times'] = [tr_times]
if self.channel_data['fullData']: self.channel_data['data'] = [tr_data]
# 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
self.channel_data['needConvert'] = True self.finished.emit(self.channel_data, self.channel_id)
self.init_downsampler()
self.downsampler.start()
def request_stop(self): def request_stop(self):
""" """
...@@ -333,4 +60,3 @@ class PlottingChannelProcessor(QtCore.QRunnable): ...@@ -333,4 +60,3 @@ class PlottingChannelProcessor(QtCore.QRunnable):
running. running.
""" """
self.stop_requested = True 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 @@ ...@@ -2,10 +2,10 @@
Class of which object is used to plot data Class of which object is used to plot data
""" """
from typing import List, Optional, Union from typing import List, Optional, Union
import matplotlib.text import matplotlib.text
from PySide2.QtCore import QTimer, Qt
from matplotlib import pyplot as pl from matplotlib import pyplot as pl
from matplotlib.transforms import Bbox
from PySide2.QtCore import QTimer, Qt
from PySide2 import QtCore, QtWidgets from PySide2 import QtCore, QtWidgets
from PySide2.QtWidgets import QWidget, QApplication, QTextBrowser from PySide2.QtWidgets import QWidget, QApplication, QTextBrowser
...@@ -18,6 +18,7 @@ from sohstationviewer.view.plotting.plotting_widget.plotting_axes import ( ...@@ -18,6 +18,7 @@ from sohstationviewer.view.plotting.plotting_widget.plotting_axes import (
PlottingAxes PlottingAxes
) )
from sohstationviewer.view.plotting.plotting_widget.plotting import Plotting 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.plotting_data import format_time
from sohstationviewer.controller.util import display_tracking_info from sohstationviewer.controller.util import display_tracking_info
...@@ -110,6 +111,7 @@ class PlottingWidget(QtWidgets.QScrollArea): ...@@ -110,6 +111,7 @@ class PlottingWidget(QtWidgets.QScrollArea):
font_size: float - font size on plot. With some require bigger font, font_size: float - font size on plot. With some require bigger font,
+2 to the font_size +2 to the font_size
""" """
self.base_font_size = 7
self.font_size = 7 self.font_size = 7
""" """
bottom: float - y position of the bottom edge of all plots in self.axes bottom: float - y position of the bottom edge of all plots in self.axes
...@@ -243,6 +245,7 @@ class PlottingWidget(QtWidgets.QScrollArea): ...@@ -243,6 +245,7 @@ class PlottingWidget(QtWidgets.QScrollArea):
# set view size fit with the scroll's view port size # set view size fit with the scroll's view port size
self.main_widget.setFixedWidth(geo.width()) self.main_widget.setFixedWidth(geo.width())
self.ratio_w = geo.width() / self.width_base_px 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_w = self.ratio_w * self.width_base
self.plotting_l = self.ratio_w * self.plotting_l_base self.plotting_l = self.ratio_w * self.plotting_l_base
if self.plot_total == 0: if self.plot_total == 0:
...@@ -366,12 +369,6 @@ class PlottingWidget(QtWidgets.QScrollArea): ...@@ -366,12 +369,6 @@ class PlottingWidget(QtWidgets.QScrollArea):
# tps_t was assigned in TPS Widget # tps_t was assigned in TPS Widget
xdata = self.tps_t xdata = self.tps_t
else: 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) xdata = self.get_timestamp(event)
# We only want to remove the text on the ruler when we start zooming in # We only want to remove the text on the ruler when we start zooming in
...@@ -652,6 +649,57 @@ class PlottingWidget(QtWidgets.QScrollArea): ...@@ -652,6 +649,57 @@ class PlottingWidget(QtWidgets.QScrollArea):
""" """
self.peer_plotting_widgets = widgets 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): def clear(self):
self.plotting_axes.fig.clear() self.plotting_axes.fig.clear()
self.axes = [] self.axes = []
......
...@@ -4,12 +4,8 @@ from typing import Tuple, Union, Dict ...@@ -4,12 +4,8 @@ from typing import Tuple, Union, Dict
from sohstationviewer.view.util.plot_func_names import plot_functions 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.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.util.enums import LogType
from sohstationviewer.view.plotting.plotting_widget.\ from sohstationviewer.view.plotting.plotting_widget.\
multi_threaded_plotting_widget import MultiThreadedPlottingWidget multi_threaded_plotting_widget import MultiThreadedPlottingWidget
...@@ -35,10 +31,10 @@ class SOHWidget(MultiThreadedPlottingWidget): ...@@ -35,10 +31,10 @@ class SOHWidget(MultiThreadedPlottingWidget):
:param time_ticks_total: max number of tick to show on time bar :param time_ticks_total: max number of tick to show on time bar
""" """
self.data_object = d_obj self.data_object = d_obj
self.plotting_data1 = d_obj.soh_data[key] self.plotting_data1 = d_obj.soh_data[key] if key else {}
self.plotting_data2 = d_obj.mass_pos_data[key] self.plotting_data2 = d_obj.mass_pos_data[key] if key else {}
channel_list = d_obj.soh_data[key].keys() channel_list = d_obj.soh_data[key].keys() if key else []
data_time = d_obj.data_time[key] 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, ret = super().init_plot(d_obj, data_time, key, start_tm, end_tm,
time_ticks_total, is_waveform=False) time_ticks_total, is_waveform=False)
if not ret: if not ret:
...@@ -52,10 +48,6 @@ class SOHWidget(MultiThreadedPlottingWidget): ...@@ -52,10 +48,6 @@ class SOHWidget(MultiThreadedPlottingWidget):
self.processing_log.append((msg, LogType.WARNING)) self.processing_log.append((msg, LogType.WARNING))
return True 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): def plot_single_channel(self, c_data: Dict, chan_id: str):
""" """
Plot the channel chan_id. Plot the channel chan_id.
...@@ -70,7 +62,6 @@ class SOHWidget(MultiThreadedPlottingWidget): ...@@ -70,7 +62,6 @@ class SOHWidget(MultiThreadedPlottingWidget):
return return
chan_db_info = c_data['chan_db_info'] chan_db_info = c_data['chan_db_info']
plot_type = chan_db_info['plotType'] plot_type = chan_db_info['plotType']
apply_convert_factor(c_data, chan_db_info['convertFactor'])
linked_ax = None linked_ax = None
if chan_db_info['linkedChan'] not in [None, 'None', '']: if chan_db_info['linkedChan'] not in [None, 'None', '']:
......
...@@ -13,7 +13,7 @@ from sohstationviewer.controller.util import ( ...@@ -13,7 +13,7 @@ from sohstationviewer.controller.util import (
display_tracking_info, add_thousand_separator, display_tracking_info, add_thousand_separator,
) )
from sohstationviewer.database.extract_data import ( 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.data_type_model import DataTypeModel
from sohstationviewer.model.handling_data import ( from sohstationviewer.model.handling_data import (
...@@ -89,8 +89,10 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -89,8 +89,10 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
self.is_working = True self.is_working = True
self.set_key = key self.set_key = key
self.plotting_data1 = d_obj.waveform_data[key] self.plotting_data1 = d_obj.waveform_data[key]
self.plot_total = len(self.plotting_data1)
self.plotting_bot = const.BOTTOM self.plotting_bot = const.BOTTOM
self.plotting_bot_pixel = const.BOTTOM_PX
self.processed_channels = [] self.processed_channels = []
self.channels = [] self.channels = []
self.tps_processors = [] self.tps_processors = []
...@@ -111,9 +113,10 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -111,9 +113,10 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
title = get_title(key, self.min_x, self.max_x, self.date_mode) 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.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 == {}: if self.plotting_data1 == {}:
self.is_working = False
self.draw() self.draw()
self.clean_up('NO DATA') self.clean_up('NO DATA')
return return
...@@ -219,11 +222,12 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -219,11 +222,12 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
total_days = c_data['tps_data'].shape[0] total_days = c_data['tps_data'].shape[0]
plot_h = self.plotting_axes.get_height( 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 = self.create_axes(self.plotting_bot, plot_h)
ax.spines[['right', 'left', 'top', 'bottom']].set_visible(False)
ax.text( ax.text(
-0.1, 1.2, -0.12, 1,
f"{get_chan_label(chan_id)} {c_data['samplerate']}", f"{get_seismic_chan_label(chan_id)} {c_data['samplerate']}sps",
horizontalalignment='left', horizontalalignment='left',
verticalalignment='top', verticalalignment='top',
rotation='horizontal', rotation='horizontal',
...@@ -233,17 +237,17 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -233,17 +237,17 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
) )
zoom_marker1 = ax.plot( zoom_marker1 = ax.plot(
[], [], marker='|', markersize=10, [], [], marker='|', markersize=5,
markeredgecolor=self.display_color['zoom_marker'])[0] markeredgecolor=self.display_color['zoom_marker'])[0]
self.zoom_marker1s.append(zoom_marker1) self.zoom_marker1s.append(zoom_marker1)
zoom_marker2 = ax.plot( zoom_marker2 = ax.plot(
[], [], marker='|', markersize=10, [], [], marker='|', markersize=5,
markeredgecolor=self.display_color['zoom_marker'])[0] markeredgecolor=self.display_color['zoom_marker'])[0]
self.zoom_marker2s.append(zoom_marker2) self.zoom_marker2s.append(zoom_marker2)
ruler = ax.plot( ruler = ax.plot(
[], [], marker='s', markersize=5, [], [], marker='s', markersize=4,
markeredgecolor=self.display_color['time_ruler'], markeredgecolor=self.display_color['time_ruler'],
markerfacecolor='None')[0] markerfacecolor='None')[0]
self.rulers.append(ruler) self.rulers.append(ruler)
...@@ -257,8 +261,8 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -257,8 +261,8 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
# not draw data out of day range # not draw data out of day range
color_set = self.get_color_set(y, square_counts, color_codes) color_set = self.get_color_set(y, square_counts, color_codes)
# (- dayIdx): each day is a line, increase from top to bottom # (- dayIdx): each day is a line, increase from top to bottom
ax.scatter(x, [- dayIdx] * len(x), marker='|', ax.scatter(x, [- dayIdx] * len(x), marker='s',
c=color_set, s=7, alpha=0.8) c=color_set, s=3)
# extra to show highlight square # extra to show highlight square
ax.set_ylim(-(c_data['tps_data'].shape[0] + 1), 1) ax.set_ylim(-(c_data['tps_data'].shape[0] + 1), 1)
...@@ -273,11 +277,13 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -273,11 +277,13 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
ax.legend will create one label for each dot. ax.legend will create one label for each dot.
""" """
# set height of legend and distance bw legend and upper ax # 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( ax = self.plotting_axes.canvas.figure.add_axes(
[self.plotting_l, self.plotting_bot, self.plotting_w, plot_h], [self.plotting_l, self.plotting_bot, self.plotting_w, plot_h],
picker=True picker=True
) )
ax.axis('off')
ax.patch.set_alpha(0) ax.patch.set_alpha(0)
c_labels = self.parent.sel_col_labels c_labels = self.parent.sel_col_labels
clrs = self.parent.color_def # colordef clrs = self.parent.color_def # colordef
...@@ -465,6 +471,7 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ...@@ -465,6 +471,7 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget):
with new color range selected. with new color range selected.
""" """
self.clear() self.clear()
self.set_colors(self.main_window.color_mode)
self.plotting_bot = const.BOTTOM self.plotting_bot = const.BOTTOM
title = get_title(self.set_key, self.min_x, self.max_x, self.date_mode) 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.) self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.)
...@@ -553,6 +560,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): ...@@ -553,6 +560,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
""" """
self.color_range_choice = QtWidgets.QComboBox(self) self.color_range_choice = QtWidgets.QComboBox(self)
self.color_range_choice.addItems(self.color_ranges) self.color_range_choice.addItems(self.color_ranges)
self.color_range_choice.setCurrentText('High') self.color_range_choice.setCurrentText('High')
color_layout.addWidget(self.color_range_choice) color_layout.addWidget(self.color_range_choice)
# ##################### Replot button ######################## # ##################### Replot button ########################
...@@ -560,8 +568,8 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): ...@@ -560,8 +568,8 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
buttons_layout.addWidget(self.replot_button) buttons_layout.addWidget(self.replot_button)
# ##################### Save button ########################## # ##################### Save button ##########################
self.save_button = QtWidgets.QPushButton('Save', self) self.save_plot_button = QtWidgets.QPushButton('Save Plot', self)
buttons_layout.addWidget(self.save_button) buttons_layout.addWidget(self.save_plot_button)
self.info_text_browser.setFixedHeight(60) self.info_text_browser.setFixedHeight(60)
bottom_layout.addWidget(self.info_text_browser) bottom_layout.addWidget(self.info_text_browser)
...@@ -594,7 +602,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): ...@@ -594,7 +602,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
""" """
Connect functions to widgets 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.replot_button.clicked.connect(self.plotting_widget.replot)
self.color_range_choice.currentTextChanged.connect( self.color_range_choice.currentTextChanged.connect(
self.color_range_changed) self.color_range_changed)
...@@ -611,8 +619,8 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): ...@@ -611,8 +619,8 @@ class TimePowerSquaredDialog(QtWidgets.QWidget):
self.sel_col_labels = self.color_label[cr_index] self.sel_col_labels = self.color_label[cr_index]
@QtCore.Slot() @QtCore.Slot()
def save(self): def save_plot(self):
""" """
Save the plotting to a file 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 ...@@ -3,7 +3,8 @@ from typing import Dict, Optional, List
import numpy as np import numpy as np
from PySide2 import QtCore 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): class TimePowerSquaredProcessorSignal(QtCore.QObject):
...@@ -76,75 +77,9 @@ class TimePowerSquaredProcessor(QtCore.QRunnable): ...@@ -76,75 +77,9 @@ class TimePowerSquaredProcessor(QtCore.QRunnable):
saved in channel_data['tps-data']: np.mean(np.square(5m data)) 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) self.signals.finished.emit(self.channel_id)
def request_stop(self): def request_stop(self):
......
...@@ -9,10 +9,6 @@ from sohstationviewer.view.util.plot_func_names import plot_functions ...@@ -9,10 +9,6 @@ from sohstationviewer.view.util.plot_func_names import plot_functions
from sohstationviewer.view.plotting.plotting_widget.\ from sohstationviewer.view.plotting.plotting_widget.\
multi_threaded_plotting_widget import MultiThreadedPlottingWidget 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): class WaveformWidget(MultiThreadedPlottingWidget):
""" """
...@@ -33,16 +29,12 @@ class WaveformWidget(MultiThreadedPlottingWidget): ...@@ -33,16 +29,12 @@ class WaveformWidget(MultiThreadedPlottingWidget):
:param time_ticks_total: max number of tick to show on time bar :param time_ticks_total: max number of tick to show on time bar
""" """
self.data_object = d_obj self.data_object = d_obj
self.plotting_data1 = d_obj.waveform_data[key] self.plotting_data1 = d_obj.waveform_data[key] if key else {}
self.plotting_data2 = d_obj.mass_pos_data[key] self.plotting_data2 = d_obj.mass_pos_data[key] if key else {}
data_time = d_obj.data_time[key] 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, return super().init_plot(d_obj, data_time, key, start_tm, end_tm,
time_ticks_total, is_waveform=True) 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): def plot_single_channel(self, c_data: Dict, chan_id: str):
""" """
Plot the channel chan_id. Plot the channel chan_id.
...@@ -57,7 +49,7 @@ class WaveformWidget(MultiThreadedPlottingWidget): ...@@ -57,7 +49,7 @@ class WaveformWidget(MultiThreadedPlottingWidget):
return return
chan_db_info = c_data['chan_db_info'] chan_db_info = c_data['chan_db_info']
plot_type = chan_db_info['plotType'] 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' # refer to doc string for mass_pos_data to know the reason for 'ax_wf'
if 'ax_wf' not in c_data: if 'ax_wf' not in c_data:
ax = getattr(self.plotting, plot_functions[plot_type][1])( ax = getattr(self.plotting, plot_functions[plot_type][1])(
...@@ -93,7 +85,7 @@ class WaveformDialog(QtWidgets.QWidget): ...@@ -93,7 +85,7 @@ class WaveformDialog(QtWidgets.QWidget):
data_type: str - type of data being plotted data_type: str - type of data being plotted
""" """
self.data_type = None self.data_type = None
self.setGeometry(300, 300, 1200, 700) self.setGeometry(50, 10, 1600, 700)
self.setWindowTitle("Raw Data Plot") self.setWindowTitle("Raw Data Plot")
main_layout = QtWidgets.QVBoxLayout() main_layout = QtWidgets.QVBoxLayout()
...@@ -118,11 +110,11 @@ class WaveformDialog(QtWidgets.QWidget): ...@@ -118,11 +110,11 @@ class WaveformDialog(QtWidgets.QWidget):
bottom_layout = QtWidgets.QHBoxLayout() bottom_layout = QtWidgets.QHBoxLayout()
main_layout.addLayout(bottom_layout) 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_plot_button = QtWidgets.QPushButton('Save Plot', self)
self.save_button.clicked.connect(self.save) self.save_plot_button.clicked.connect(self.save_plot)
bottom_layout.addWidget(self.save_button) bottom_layout.addWidget(self.save_plot_button)
self.info_text_browser.setFixedHeight(60) self.info_text_browser.setFixedHeight(60)
bottom_layout.addWidget(self.info_text_browser) bottom_layout.addWidget(self.info_text_browser)
...@@ -148,11 +140,11 @@ class WaveformDialog(QtWidgets.QWidget): ...@@ -148,11 +140,11 @@ class WaveformDialog(QtWidgets.QWidget):
self.plotting_widget.init_size() self.plotting_widget.init_size()
@QtCore.Slot() @QtCore.Slot()
def save(self): def save_plot(self):
""" """
Save the plotting to a file Save the plotting to a file
""" """
print("save") self.plotting_widget.save_plot('Waveform-Plot')
def plot_finished(self): def plot_finished(self):
self.parent.is_plotting_waveform = False 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): ...@@ -793,6 +793,8 @@ class UIMainWindow(object):
self.stop_button.clicked.connect(main_window.stop) self.stop_button.clicked.connect(main_window.stop)
self.save_plot_button.clicked.connect(main_window.save_plot)
def read_config(self): def read_config(self):
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
config_path = Path('sohstationviewer/conf/read_settings.ini') config_path = Path('sohstationviewer/conf/read_settings.ini')
......
...@@ -96,6 +96,9 @@ def create_table_of_content_file(base_path: Path) -> None: ...@@ -96,6 +96,9 @@ def create_table_of_content_file(base_path: Path) -> None:
"this software.\n\n" "this software.\n\n"
"On the left-hand side you will find a list of currently available" "On the left-hand side you will find a list of currently available"
" help topics.\n\n" " 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" "The home button can be used to return to this page at any time.\n\n"
"# Table of Contents\n\n") "# Table of Contents\n\n")
links = "" links = ""
......