diff --git a/documentation/01 _ Table of Contents.help.md b/documentation/01 _ Table of Contents.help.md new file mode 100644 index 0000000000000000000000000000000000000000..336457508cd3e0ae4b77cff1983219fe966dcd98 --- /dev/null +++ b/documentation/01 _ Table of Contents.help.md @@ -0,0 +1,18 @@ +# SOH Station Viewer Documentation + +Welcome to the SOH Station Viewer documentation. Here you will find usage guides and other useful information in navigating and using this software. + +On the left-hand side you will find a list of currently available help topics. + +The home button can be used to return to this page at any time. + +# Table of Contents + ++ [Table of Contents](01%20_%20Table%20of%20Contents.help.md) + ++ [Shortcuts](02%20_%20Shortcuts.help.md) + ++ [How to Use Help](03%20_%20How%20to%20Use%20Help.help.md) + ++ [Search SOH n LOG](04%20_%20Search%20SOH%20n%20LOG.help.md) + diff --git a/documentation/02 _ Shortcuts.help.md b/documentation/02 _ Shortcuts.help.md new file mode 100644 index 0000000000000000000000000000000000000000..2469cc6b745385d36a5b0d03290fd1f7725689b8 --- /dev/null +++ b/documentation/02 _ Shortcuts.help.md @@ -0,0 +1,13 @@ +# Shortcuts + +<br /> + +Below is a list of available keyboard shortcuts for commonly used operations and +forms. + +<br /> + +| Shortcut | Combination | +| -------- | ----------- | +| Ctrl + I | Open Documentation Viewer | +| Ctrl + F | Open folder (Change data directory to another location) | diff --git a/documentation/03 _ How to Use Help.help.md b/documentation/03 _ How to Use Help.help.md new file mode 100644 index 0000000000000000000000000000000000000000..603fb1e2bd81333050153e1105f68870c051cf98 --- /dev/null +++ b/documentation/03 _ How to Use Help.help.md @@ -0,0 +1,75 @@ + + +# How to Use Help + +--------------------------- +--------------------------- + +## How to open +Go to Menu - Help and click on "Documentation". + +<br /> + +--------------------------- +## User interface + +<br /> + +### Search box +The box to enter search text is placed at the top of the dialog. + +<br /> + +### Search Through All Documents button +The button to search through all documents for search text located on the right of Search box. + +<br /> + +### Toolbar + ++ <img alt="Navigate to Table of Contents" src="images/help/table_contents.png" width="30" /> **Navigate to Table of Contents**: Go to 'Table of Contents' page. + ++ <img alt="Recreate Table of Contents" src="images/help/recreate_table_contents.png" width="30" /> **Recreate Table of Contents**: If links in 'Table of Contents' are broken, the page can be recreated by clicking this button. + ++ <img alt="Search Previous" src="images/help/search_previous.png" width="30" /> **Search Previous**: Highlight the previous found text and the view roll to its position. + ++ <img alt="Search Next" src="images/help/search_next.png" width="30" /> **Search Next**: Highlight the next found text and the view roll to its position. + ++ <img alt="Search Next" src="images/help/search_all_doc.png" width="30" /> **Navigate to Search Through All Documents**: Go to 'Search Through All Documents' result page. + +<br /> + +### Document list +Is the list of all help documents located on the left of Help Dialog. + +<br /> + +### Document View +Occupies the largest section of Help Dialog located on the right to display the content of the current selected document. + +<br /> + +--------------------------- +## How to search in Help Dialog + +<br /> + +### Search for a text on the current document +Type a searched text, the first text found will be highlighted in the Document View and the view scrolls to its location. + +User can traverse back and forth the view to look for previous or next search text using **Search Previous** or **Search Next** button. + +<br /> + +### Search for all documents that contain the searched text +Type a searched text, click the button 'Search Through All Documents'. + +A list of all documents that contain the searched text will be created in document 'Search Through All Documents'. + +The document will be brought to Document View. + +Click on the link of a document in 'Search Through All Documents' will bring its to Document View. + +User can go back to 'Search Through All Documents' document any time by clicking on the name on Document List. + +<br /> diff --git a/documentation/04 _ Search SOH n LOG.help.md b/documentation/04 _ Search SOH n LOG.help.md new file mode 100644 index 0000000000000000000000000000000000000000..6d764f3ced4d33324b94b980f2829616bd3d71af --- /dev/null +++ b/documentation/04 _ Search SOH n LOG.help.md @@ -0,0 +1,67 @@ +# Search Messages + +--------------------------- +--------------------------- + +## How to open +Search Messages dialog will open by itself after a data set are done with reading and plotting. +If it is closed by any reason, user can open it by go to Menu - Forms, and click on "Search Messages" + +<br /> + +--------------------------- +## User interface + +<br /> + +### Search box +The box to enter search text is placed at the top of the dialog. + +<br /> + +### Toolbar + ++ <img alt="To Selected" src="images/search_messages/to_selected.png" width="30" /> **To Selected**: The current table rolls back to the current selected line. + ++ <img alt="Search Previous" src="images/search_messages/search_previous.png" width="30" /> **Search Previous**: The current table rolls to the previous line that contains the searched text. + ++ <img alt="Search Next" src="images/search_messages/search_next.png" width="30" /> **Search Next**: The current table rolls to the next line that contains the searched text. + ++ <img alt="Save" src="images/search_messages/save.png" width="30" /> **Save**: Save the content of the table to a text file. + +<br /> + +### Tabs + ++ **Search SOH Lines** tab: to list all lines that includes searched text with its channel ++ **Processing Logs** tab: to list all processing logs with its log type and color of each line depends on its log type. ++ Each of the following tab is for one of SOH LOG channels + +<br /> + +### Info box +Is the box to display the info of the selected line. + +<br /> + +--------------------------- +## How to search + +<br /> + +### Search for a text +Type a searched text, the searched text will be highlighted through the table and the current table scrolls to the first line that contains the searched text. + +User can traverse back and forth the current tab to look for previous or next search text using **Search Previous** or **Search Next** button. + +<br /> + +### Filter all lines with searched text +The first tab **Search SOH Lines** is for filtering all lines that contains the searched text. + +<br /> + +### Interaction with RT130 SOH channel's data point +When user click on a clickable data point on a SOH channel of RT130, SOH tab will be focused and the line corresponding to the data point will be brought up to view. + +<br /> \ No newline at end of file diff --git a/documentation/99 _ test.md b/documentation/99 _ test.md new file mode 100644 index 0000000000000000000000000000000000000000..7ef0655b760ac6880ab28c7b87f54ad34c2bb4ae --- /dev/null +++ b/documentation/99 _ test.md @@ -0,0 +1,77 @@ +Document Title? +=== +# Testing markdown features supported by Qt +To show this file in help, change extension to ".help.md" +[Links to other documents?](01 _ Table of Contents.md) +1. Numbered List + 1. Sublist + 2. Another Entry + 1. Another Sublist + +* Bulleted list + * Sublist + * Another Sublist + +# Section +## Subsection +### Sub-subsection +#### Subsections all the way down + +*Emphasis* + +**Bold** + +***Bold-Emphasis*** + +~strikethrough?~ + +> This is a quote + +``` +printf("%s\n", codeFences.doTheyWork ? "Success!" : "Oof."); +``` + +```c +printf("%s\n", syntaxHighlighting.doesItWork ? "Success!" : "Oof."); +``` + +--- +^ This is a horizontal line + +v This is an image + + +--- +Another horizontal line + +Is there a footnote? [^1] + +[^1]: Maybe. + +Definition Lists +: Do they work? + +Task Lists? +- [ ] Yes +- [ ] No +- [x] Maybe + +==Highlighting==? Nope. + +Subscripts? x~0 Nope. + +Superscripts? ax^2 + bx + c = 0. Also Nope. + +Html? +x<sub>0</sub> Yup. +x<sup>2</sup> Yup. + +Tables? + +~Not supported.~ +Just kidding, they totally are, and I just didn't do it right. + +| Is this | a table? | +| ------- | ------------ | +| Perhaps | Perhaps not. | + diff --git a/documentation/Search Results.md b/documentation/Search Results.md new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/documentation/images/help/home.png b/documentation/images/help/home.png new file mode 100644 index 0000000000000000000000000000000000000000..2ee44ccfe6905c2567ef9270877c12fa48a6817a Binary files /dev/null and b/documentation/images/help/home.png differ diff --git a/documentation/images/help/recreate_table_contents.png b/documentation/images/help/recreate_table_contents.png new file mode 100644 index 0000000000000000000000000000000000000000..34ab02a858eb4da3d62325cff47e1bd56dc90186 Binary files /dev/null and b/documentation/images/help/recreate_table_contents.png differ diff --git a/documentation/images/help/search_next.png b/documentation/images/help/search_next.png new file mode 100644 index 0000000000000000000000000000000000000000..07f642b938b2098dd1e64ff6edd81ca4c94fa6f8 Binary files /dev/null and b/documentation/images/help/search_next.png differ diff --git a/documentation/images/help/search_previous.png b/documentation/images/help/search_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..1a4c53854ad24ac1ad8983bd6936a008217cb9c4 Binary files /dev/null and b/documentation/images/help/search_previous.png differ diff --git a/documentation/images/help/search_results.png b/documentation/images/help/search_results.png new file mode 100644 index 0000000000000000000000000000000000000000..80f6ab53e137413202a83196a07f66511b09e6cb Binary files /dev/null and b/documentation/images/help/search_results.png differ diff --git a/documentation/images/help/table_contents.png b/documentation/images/help/table_contents.png new file mode 100644 index 0000000000000000000000000000000000000000..a4fa90caa1e7a173fad809c01d46a0fb7b248ed7 Binary files /dev/null and b/documentation/images/help/table_contents.png differ diff --git a/documentation/images/image.jpg b/documentation/images/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d7b974f761cf912f46030febaf994d10df8a360b Binary files /dev/null and b/documentation/images/image.jpg differ diff --git a/documentation/images/search_messages/save.png b/documentation/images/search_messages/save.png new file mode 100644 index 0000000000000000000000000000000000000000..2a694fe7cdc510b093a22f8428a290ac614f3bf6 Binary files /dev/null and b/documentation/images/search_messages/save.png differ diff --git a/documentation/images/search_messages/search_next.png b/documentation/images/search_messages/search_next.png new file mode 100644 index 0000000000000000000000000000000000000000..07f642b938b2098dd1e64ff6edd81ca4c94fa6f8 Binary files /dev/null and b/documentation/images/search_messages/search_next.png differ diff --git a/documentation/images/search_messages/search_previous.png b/documentation/images/search_messages/search_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..1a4c53854ad24ac1ad8983bd6936a008217cb9c4 Binary files /dev/null and b/documentation/images/search_messages/search_previous.png differ diff --git a/documentation/images/search_messages/to_selected.png b/documentation/images/search_messages/to_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..b43bc4b8411d2b1bbb30cdda1514a5526dca0f50 Binary files /dev/null and b/documentation/images/search_messages/to_selected.png differ diff --git a/images/home.png b/images/home.png new file mode 100644 index 0000000000000000000000000000000000000000..2ee44ccfe6905c2567ef9270877c12fa48a6817a Binary files /dev/null and b/images/home.png differ diff --git a/images/recreate_table_contents.png b/images/recreate_table_contents.png new file mode 100644 index 0000000000000000000000000000000000000000..34ab02a858eb4da3d62325cff47e1bd56dc90186 Binary files /dev/null and b/images/recreate_table_contents.png differ diff --git a/images/search_results.png b/images/search_results.png new file mode 100644 index 0000000000000000000000000000000000000000..fe2a583e5b7877450c38dba942c24a6d22079358 Binary files /dev/null and b/images/search_results.png differ diff --git a/images/table_contents.png b/images/table_contents.png new file mode 100644 index 0000000000000000000000000000000000000000..a4fa90caa1e7a173fad809c01d46a0fb7b248ed7 Binary files /dev/null and b/images/table_contents.png differ diff --git a/images/to_selected.png b/images/to_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..b43bc4b8411d2b1bbb30cdda1514a5526dca0f50 Binary files /dev/null and b/images/to_selected.png differ diff --git a/sohstationviewer/conf/constants.py b/sohstationviewer/conf/constants.py index a537627f795451653f950600cdc4a571eee93b2b..e9a81414adaed80b306ab58da3dfb1b121c6580e 100644 --- a/sohstationviewer/conf/constants.py +++ b/sohstationviewer/conf/constants.py @@ -34,6 +34,11 @@ NO_5M_DAY = 288 # total of 5 minutes in an hour NO_5M_1H = int(60 * 60 / 300) +# name of table of contents file +TABLE_CONTENTS = "01 _ Table of Contents.help.md" + +# name of search through all documents file +SEARCH_RESULTS = "Search Results.md" # ================================================================= # # PLOTTING CONSTANT diff --git a/sohstationviewer/controller/plottingData.py b/sohstationviewer/controller/plottingData.py index c0ec746ceca774b0a1159fe030fe5937d9922fe6..6849df2aacb6b9f2eefd077fbe7fba2a8c3d2de0 100755 --- a/sohstationviewer/controller/plottingData.py +++ b/sohstationviewer/controller/plottingData.py @@ -6,7 +6,9 @@ import math from typing import List, Union, Optional, Tuple, Dict from obspy import UTCDateTime + from sohstationviewer.conf import constants as const +from sohstationviewer.view.util.enums import LogType maxInt = 1E100 maxFloat = 1.0E100 @@ -42,7 +44,7 @@ def getMassposValueColors(rangeOpt: str, chan_id: str, cMode: str, f"{chan_id}: The current selected Mass Position color range is" f" '{rangeOpt}' isn't allowable. The accept ranges are: " f"{', '.join(MassPosVoltRanges.keys())}", - "error" + LogType.ERROR ) ) return diff --git a/sohstationviewer/controller/processing.py b/sohstationviewer/controller/processing.py index 81ac94fad9aae398e9961c90add2e9d1d392cacb..4a0e0e8914bf8c11c6c4bafc8a752ed6de96b058 100644 --- a/sohstationviewer/controller/processing.py +++ b/sohstationviewer/controller/processing.py @@ -1,11 +1,12 @@ """ -Function that ignite from MainWindow, Dialogs to read data files for data, +Function that ignite from main_window, Dialogs to read data files for data, channels, datatype """ import os import json import re +import traceback from pathlib import Path from typing import List, Set, Optional, Dict, Tuple @@ -14,17 +15,26 @@ from obspy.core import read as read_ms from obspy.io.reftek.core import Reftek130Exception from sohstationviewer.model.mseed.mseed import MSeed +from sohstationviewer.database.extract_data import get_signature_channels from sohstationviewer.model.data_type_model import DataTypeModel -from sohstationviewer.database.extractData import signatureChannels + from sohstationviewer.controller.util import validateFile, displayTrackingInfo +from sohstationviewer.view.util.enums import LogType + def loadData(dataType: str, tracking_box: QTextBrowser, listOfDir: List[str], reqWFChans: List[str] = [], reqSOHChans: List[str] = [], readStart: Optional[float] = None, readEnd: Optional[float] = None) -> DataTypeModel: """ - Go through root dir and read all files in that dir and its subdirs + Load the data stored in listOfDir and store it in a DataTypeModel object. + The concrete class of the data object is based on dataType. Run on the same + thread as its caller, and so will block the GUI if called on the main + thread. It is advisable to use model.data_loader.DataLoader to load data + unless it is necessary to load data in the main thread (e.g. if there is + a need to access the call stack). + :param dataType: str - type of data read :param tracking_box: QTextBrowser - widget to display tracking info :param listOfDir: [str,] - list of directories selected by users @@ -43,9 +53,11 @@ def loadData(dataType: str, tracking_box: QTextBrowser, listOfDir: List[str], dataType, tracking_box, d, reqWFChans=reqWFChans, reqSOHChans=reqSOHChans, readStart=readStart, readEnd=readEnd) - except Exception as e: - msg = f"Dir {d} can't be read due to error: {str(e)}" - displayTrackingInfo(tracking_box, msg, "Warning") + except Exception: + fmt = traceback.format_exc() + msg = f"Dir {d} can't be read due to error: {str(fmt)}" + displayTrackingInfo(tracking_box, msg, LogType.WARNING) + # if dataObject.hasData(): # continue # If no data can be read from the first dir, throw exception @@ -97,8 +109,7 @@ def detectDataType(tracking_box: QTextBrowser, listOfDir: List[str] return None with a warning message + if data type found, return data_type, """ - - sign_chan_dataType_dict = signatureChannels() + sign_chan_dataType_dict = get_signature_channels() dirDataTypeDict = {} for d in listOfDir: @@ -126,13 +137,13 @@ def detectDataType(tracking_box: QTextBrowser, listOfDir: List[str] msg = (f"There are more than one types of data detected:\n" f"{dirDataTypeStr}\n\n" f"Please have only data that related to each other.") - displayTrackingInfo(tracking_box, msg, "error") + displayTrackingInfo(tracking_box, msg, LogType.ERROR) return elif dataTypeList == {'Unknown'}: msg = ("There are no known data detected.\n" "Please select different folder(s).") - displayTrackingInfo(tracking_box, msg, "error") + displayTrackingInfo(tracking_box, msg, LogType.ERROR) return return list(dirDataTypeDict.values())[0][0] diff --git a/sohstationviewer/controller/util.py b/sohstationviewer/controller/util.py index 6f022a9943228f5d76e0ca8554ba1537e53cb8c8..2e85563750e4afa4a7f8cd2e02e7e870b509cee9 100644 --- a/sohstationviewer/controller/util.py +++ b/sohstationviewer/controller/util.py @@ -5,11 +5,14 @@ basic functions: format, validate, display tracking import os import re from datetime import datetime + +from PySide2 import QtCore from typing import Tuple from PySide2.QtWidgets import QTextBrowser from obspy import UTCDateTime import numpy as np +from sohstationviewer.view.util.enums import LogType def validateFile(path2file: str, fileName: str): @@ -29,8 +32,8 @@ def validateFile(path2file: str, fileName: str): return True -def displayTrackingInfo(trackingBox: QTextBrowser, text: str, - type: str = 'info'): +@QtCore.Slot() +def displayTrackingInfo(trackingBox: QTextBrowser, text: str, type: LogType = LogType.INFO): """ Display text in the given widget with different background and text colors :param trackingBox: QTextBrowser - widget to display tracking info @@ -40,14 +43,14 @@ def displayTrackingInfo(trackingBox: QTextBrowser, text: str, """ if trackingBox is None: - print(f"{type}: {text}") + print(f"{type.name}: {text}") return msg = {'text': text} - if type == 'error': + if type == LogType.ERROR: msg['color'] = 'white' msg['bgcolor'] = '#e46269' - elif type == 'warning': + elif type == LogType.WARNING: msg['color'] = '#ffd966' msg['bgcolor'] = 'orange' else: diff --git a/sohstationviewer/database/extractData.py b/sohstationviewer/database/extractData.py deleted file mode 100755 index f12209bc00bf7cf9410b8d87efd25323cc13dcb2..0000000000000000000000000000000000000000 --- a/sohstationviewer/database/extractData.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Read DB for necessary information -""" - -from sohstationviewer.database.proccessDB import executeDB_dict, executeDB -from sohstationviewer.conf.dbSettings import dbConf - - -def getChanPlotInfo(orgChan, data_type): - """ - Given chanID read from raw data file and detected dataType - Return plotting info from DB for that channel - - :param orgChan: str - channel ID from raw data file - :param data_type: str - data type detected - :return chanInfo: dict - plotting info from DB for that channel - """ - chan = orgChan - if orgChan.startswith('EX'): - chan = 'EX?' - if orgChan.startswith('VM'): - chan = 'VM?' - if orgChan.startswith('MP'): - chan = 'MP?' - if orgChan.startswith('Event DS'): - chan = 'Event DS?' - if orgChan.startswith('DS'): - chan = 'DS?' - if orgChan.startswith('Disk Usage'): - chan = 'Disk Usage?' - if dbConf['seisRE'].match(chan): - chan = 'SEISMIC' - - o_sql = ("SELECT channel, plotType, height, unit, linkedChan," - " convertFactor, label, fixPoint, valueColors " - "FROM Channels as C, Parameters as P") - if data_type == 'Unknown': - sql = f"{o_sql} WHERE channel='{chan}' and C.param=P.param" - else: - sql = (f"{o_sql} WHERE channel='{chan}' and C.param=P.param" - f" and dataType='{data_type}'") - # print("SQL:", sql) - chanInfo = executeDB_dict(sql) - - if len(chanInfo) == 0: - chanInfo = executeDB_dict( - f"{o_sql} WHERE channel='DEFAULT' and C.param=P.param") - else: - if chanInfo[0]['channel'] == 'SEISMIC': - chanInfo[0]['label'] = dbConf['seisLabel'][orgChan[-1]] - chanInfo[0]['channel'] = orgChan - - chanInfo[0]['label'] = ( - '' if chanInfo[0]['label'] is None else chanInfo[0]['label']) - chanInfo[0]['unit'] = ( - '' if chanInfo[0]['unit'] is None else chanInfo[0]['unit']) - chanInfo[0]['fixPoint'] = ( - 0 if chanInfo[0]['fixPoint'] is None else chanInfo[0]['fixPoint']) - if chanInfo[0]['label'].strip() == '': - chanInfo[0]['label'] = chanInfo[0]['channel'] - else: - chanInfo[0]['label'] = '-'.join([chanInfo[0]['channel'], - chanInfo[0]['label']]) - if chanInfo[0]['label'].strip() == 'DEFAULT': - chanInfo[0]['label'] = 'DEFAULT-' + orgChan - return chanInfo[0] - - -def getWFPlotInfo(orgChan): - """ - Similar to getChanPlotInfo but for waveform channel (param=Seismic data) - :param orgChan: str - channel ID from raw data file - :return chanInfo: dict - plotting info from DB for that waveform channel - """ - chanInfo = executeDB_dict( - "SELECT * FROM Parameters WHERE param='Seismic data'")[0] - chanInfo['label'] = getChanLabel(orgChan) - chanInfo['unit'] = '' - chanInfo['channel'] = 'SEISMIC' - return chanInfo - - -def getChanLabel(chanID): - """ - Get waveform channel label for the given chan_id in which: - + RT130's start with DS, remain unchanged - + MSEED's need to change last character according to dbConf[seisLabel] - :param chanID: str - channel name - :return label: str - plot's label for the channel - """ - if chanID.startswith("DS"): - label = chanID - else: - label = chanID + '-' + dbConf['seisLabel'][chanID[-1]] - return label - - -def signatureChannels(): - """ - Get channels that are unique for datatype. - :return: {str: str,} - the dict {channel: dataType,} - in which channel is unique for dataType - """ - sql = ("SELECT channel, dataType FROM Channels where channel in" - "(SELECT channel FROM Channels GROUP BY channel" - " HAVING COUNT(channel)=1)") - rows = executeDB_dict(sql) - sign_chan_dataType_dict = {r['channel']: r['dataType'] for r in rows} - return sign_chan_dataType_dict - - -def getColorDef(): - """ - Get TPS Color definition from DB - :return [str,]: list of color names - """ - sql = "SELECT color FROM TPS_ColorDefinition ORDER BY name ASC" - rows = executeDB(sql) - return [r[0] for r in rows] - - -def getColorRanges(): - """ - Get TPS color range from DB - :return rangeNames: [str,] - 'antarctica'/'low'/'med'/'high' - :return allSquareCounts: [[int,],] - time-power-squared range - (base_count * 10 ** (level-1)) ** 2 - :return clrLabels: [str,] - labels that define count range for colors - """ - sql = "SELECT name, baseCounts FROM TPS_ColorRange" - rows = executeDB(sql) - rangeNames = [r[0] for r in rows] - baseCounts = [r[1] for r in rows] - allSquareCounts = [] - clrLabels = [] - cnt = 0 - # 7 : number of color definition, not include 'E' - for idx, bc in enumerate(baseCounts): - allSquareCounts.append([0] * 7) - clrLabels.append(['0 counts']) - for cidx in range(1, 7): - cnt = (bc * 10 ** (cidx - 1)) - allSquareCounts[idx][cidx] = cnt ** 2 - clrLabels[idx].append("+/- {:,} counts".format(cnt)) - clrLabels[idx].append("> {:,} counts".format(cnt)) - return rangeNames, allSquareCounts, clrLabels diff --git a/sohstationviewer/database/extract_data.py b/sohstationviewer/database/extract_data.py new file mode 100755 index 0000000000000000000000000000000000000000..934d3afc7f66eb89cff9110290ddc5982b4355a3 --- /dev/null +++ b/sohstationviewer/database/extract_data.py @@ -0,0 +1,114 @@ + +from sohstationviewer.database.process_db import execute_db_dict, execute_db +from sohstationviewer.conf.dbSettings import dbConf + + +def get_chan_plot_info(org_chan, data_type): + """ + Given chanID read from raw data file and detected dataType + Return plotting info from DB for that channel + """ + chan = org_chan + if org_chan.startswith('EX'): + chan = 'EX?' + if org_chan.startswith('VM'): + chan = 'VM?' + if org_chan.startswith('MP'): + chan = 'MP?' + if org_chan.startswith('Event DS'): + chan = 'Event DS?' + if org_chan.startswith('DS'): + chan = 'DS?' + if org_chan.startswith('Disk Usage'): + chan = 'Disk Usage?' + if dbConf['seisRE'].match(chan): + chan = 'SEISMIC' + + o_sql = ("SELECT channel, plotType, height, unit, linkedChan," + " convertFactor, label, fixPoint, valueColors " + "FROM Channels as C, Parameters as P") + if data_type == 'Unknown': + sql = f"{o_sql} WHERE channel='{chan}' and C.param=P.param" + else: + sql = (f"{o_sql} WHERE channel='{chan}' and C.param=P.param" + f" and dataType='{data_type}'") + # print("SQL:", sql) + chan_info = execute_db_dict(sql) + + if len(chan_info) == 0: + chan_info = execute_db_dict( + f"{o_sql} WHERE channel='DEFAULT' and C.param=P.param") + else: + if chan_info[0]['channel'] == 'SEISMIC': + chan_info[0]['label'] = dbConf['seisLabel'][org_chan[-1]] + chan_info[0]['channel'] = org_chan + + chan_info[0]['label'] = ( + '' if chan_info[0]['label'] is None else chan_info[0]['label']) + chan_info[0]['unit'] = ( + '' if chan_info[0]['unit'] is None else chan_info[0]['unit']) + chan_info[0]['fixPoint'] = ( + 0 if chan_info[0]['fixPoint'] is None else chan_info[0]['fixPoint']) + if chan_info[0]['label'].strip() == '': + chan_info[0]['label'] = chan_info[0]['channel'] + else: + chan_info[0]['label'] = '-'.join([chan_info[0]['channel'], + chan_info[0]['label']]) + if chan_info[0]['label'].strip() == 'DEFAULT': + chan_info[0]['label'] = 'DEFAULT-' + org_chan + return chan_info[0] + + +def get_wf_plot_info(org_chan): + chan_info = execute_db_dict( + "SELECT * FROM Parameters WHERE param='Seismic data'") + chan_info[0]['label'] = get_chan_label(org_chan) + chan_info[0]['unit'] = '' + chan_info[0]['channel'] = 'SEISMIC' + return chan_info[0] + + +def get_chan_label(chan_id): + if chan_id.startswith("DS"): + label = chan_id + else: + label = chan_id + '-' + dbConf['seisLabel'][chan_id[-1]] + return label + + +def get_signature_channels(): + """ + return the dict {channel: dataType} in which channel is unique for dataType + """ + sql = ("SELECT channel, dataType FROM Channels where channel in" + "(SELECT channel FROM Channels GROUP BY channel" + " HAVING COUNT(channel)=1)") + rows = execute_db_dict(sql) + sign_chan_data_type_dict = {r['channel']: r['dataType'] for r in rows} + return sign_chan_data_type_dict + + +def get_color_def(): + sql = "SELECT color FROM TPS_ColorDefinition ORDER BY name ASC" + rows = execute_db(sql) + return [r[0] for r in rows] + + +def get_color_ranges(): + sql = "SELECT name, baseCounts FROM TPS_ColorRange" + rows = execute_db(sql) + range_names = [r[0] for r in rows] + base_counts = [r[1] for r in rows] + all_square_counts = [] + clr_labels = [] + cnt = 0 + # 7 : number of color definition, not include 'E' + for idx, bc in enumerate(base_counts): + all_square_counts.append([0] * 7) + clr_labels.append(['0 counts']) + for c_idx in range(1, 7): + cnt = (bc * 10 ** (c_idx - 1)) + all_square_counts[idx][c_idx] = cnt ** 2 + clr_labels[idx].append("+/- {:,} counts".format(cnt)) + clr_labels[idx].append("> {:,} counts".format(cnt)) + return range_names, all_square_counts, clr_labels diff --git a/sohstationviewer/database/proccessDB.py b/sohstationviewer/database/process_db.py similarity index 92% rename from sohstationviewer/database/proccessDB.py rename to sohstationviewer/database/process_db.py index b28756639950b3d7c7adf0d8b5880c35d31aafb0..b8b50fdc4c63e07c2c9c45a24fccbca5d3050e13 100755 --- a/sohstationviewer/database/proccessDB.py +++ b/sohstationviewer/database/process_db.py @@ -6,7 +6,7 @@ import sqlite3 from sohstationviewer.conf.dbSettings import dbConf -def executeDB(sql): +def execute_db(sql): """ Execute or fetch data from DB :param sql: str - request string to execute or fetch data from database @@ -25,7 +25,7 @@ def executeDB(sql): return rows -def executeDB_dict(sql): +def execute_db_dict(sql): """ Fetch data and return rows in dictionary with fields as keys :param sql: str - request string to fetch data from database @@ -44,11 +44,11 @@ def executeDB_dict(sql): return [dict(row) for row in rows] -def trunc_addDB(table, sqls): +def trunc_add_db(table, sql_list): """ Truncate table and refill with new data :param table: str - name of data table to process - :param sqls: [str, ] - list of INSERT query to add values to table + :param sql_list: [str, ] - list of INSERT query to add values to table :return: str: error message if not successful or bool(True): if successful """ @@ -57,7 +57,7 @@ def trunc_addDB(table, sqls): cur = conn.cursor() cur.execute('BEGIN') cur.execute(f'DELETE FROM {table}') - for sql in sqls: + for sql in sql_list: cur.execute(sql) cur.execute('COMMIT') except sqlite3.Error as e: diff --git a/sohstationviewer/database/soh.db b/sohstationviewer/database/soh.db index 7dd0ed988adb7a10d48627f6aa8f6125d99e85ad..8a34e889f0fc4c48d561a774f1f567f82ce6fc9e 100755 Binary files a/sohstationviewer/database/soh.db and b/sohstationviewer/database/soh.db differ diff --git a/sohstationviewer/model/data_loader.py b/sohstationviewer/model/data_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..732d8ede8d05369a10b45a1f8a57acb58bba0987 --- /dev/null +++ b/sohstationviewer/model/data_loader.py @@ -0,0 +1,212 @@ +""" +This module provides access to a class that loads data in a separate thread. +""" +import traceback +from pathlib import Path +from typing import Union, List, Optional + +from PySide2 import QtCore, QtWidgets + +from sohstationviewer.conf import constants +from sohstationviewer.controller.util import displayTrackingInfo +from sohstationviewer.model.data_type_model import DataTypeModel, ThreadStopped +from sohstationviewer.view.util.enums import LogType + + +class DataLoaderWorker(QtCore.QObject): + """ + The worker class that executes the code to load the data. + """ + finished = QtCore.Signal(DataTypeModel) + failed = QtCore.Signal() + stopped = QtCore.Signal() + notification = QtCore.Signal(QtWidgets.QTextBrowser, str, str) + button_dialog = QtCore.Signal(str, list) + button_chosen = QtCore.Signal(int) + + def __init__(self, data_type: str, tracking_box: QtWidgets.QTextBrowser, + folder: str, req_wf_chans: Union[List[str], List[int]] = [], + req_soh_chans: List[str] = [], read_start: float = 0, + read_end: float = constants.HIGHEST_INT, parent_thread=None): + super().__init__() + self.data_type = data_type + self.tracking_box = tracking_box + self.folder = folder + self.req_wf_chans = req_wf_chans + self.req_soh_chans = req_soh_chans + self.read_start = read_start + self.read_end = read_end + self.parent_thread = parent_thread + # displayTrackingInfo updates a QtWidget, which can only be done in the + # main thread. Since self.run runs in a background thread, we need to + # use signal-slot mechanism to ensure that displayTrackingInfo runs in + # the main thread. + self.notification.connect(displayTrackingInfo) + self.end_msg = None + + def run(self): + try: + if self.data_type == 'RT130': + from sohstationviewer.model.reftek.reftek import RT130 + ObjectType = RT130 + else: + from sohstationviewer.model.mseed.mseed import MSeed + ObjectType = MSeed + # Create data object without loading any data in order to connect + # its unpause slot to the loader's unpause signal + dataObject = ObjectType.get_empty_instance() + self.button_chosen.connect(dataObject.receive_pause_response, + type=QtCore.Qt.DirectConnection) + dataObject.__init__( + self.tracking_box, self.folder, + reqWFChans=self.req_wf_chans, + reqSOHhans=self.req_soh_chans, readStart=self.read_start, + readEnd=self.read_end, creator_thread=self.parent_thread, + notification_signal=self.notification, + pause_signal=self.button_dialog + ) + + except ThreadStopped: + self.end_msg = 'Data loading has been stopped' + self.stopped.emit() + except Exception: + fmt = traceback.format_exc() + self.end_msg = (f"Dir {self.folder} can't be read " + f"due to error: {str(fmt)}") + self.failed.emit() + else: + self.end_msg = f'Finished loading data stored in {self.folder}' + self.finished.emit(dataObject) + + +class DataLoader: + """ + The class that coordinate the loading of data using multiple threads. The + code inside has to be encapsulated in a class because a connection between + a signal and a receiver is automatically disconnected when either of them + is deleted (e.g. goes out of scope). + """ + + def __init__(self): + self.running = False + self.thread: Optional[QtCore.QThread] = None + self.worker: Optional[DataLoaderWorker] = None + + def init_loader(self, data_type: str, tracking_box: QtWidgets.QTextBrowser, + list_of_dir: List[Union[str, Path]], + req_wf_chans: Union[List[str], List[int]] = [], + req_soh_chans: List[str] = [], read_start: float = 0, + read_end: float = constants.HIGHEST_INT): + """ + Initialize the data loader. Construct the thread and worker and connect + them together. Separated from the actual loading of the data to allow + the main window a chance to connect its slots to the data loader. + + :param data_type: the type of data being loaded. 'RT130' for RT130 data + and 'Centaur', 'Pegasus', and 'Q330' for MSeed data. + :param tracking_box: the widget used to display tracking info + :param list_of_dir: list of directories selected by users + :param req_wf_chans: list of requested waveform channels + :param req_soh_chans: list of requested SOH channel + :param read_start: the time before which no data is read + :param read_end: the time after which no data is read + :return: + """ + if self.running: + # TODO: implement showing an error window + print('Already running') + return False + + self.running = True + self.thread = QtCore.QThread() + self.worker = DataLoaderWorker( + data_type, + tracking_box, + list_of_dir[0], # Only work on one directory for now. + req_wf_chans=req_wf_chans, + req_soh_chans=req_soh_chans, + read_start=read_start, + read_end=read_end, + parent_thread=self.thread + ) + + self.connect_worker_signals() + + self.worker.moveToThread(self.thread) + + def connect_worker_signals(self): + """ + Connect the signals of the data loader to the appropriate slots. + """ + # Connection order from https://realpython.com/python-pyqt-qthread + self.thread.started.connect(self.worker.run) + + self.worker.finished.connect(self.thread.quit) + self.worker.failed.connect(self.thread.quit) + self.worker.stopped.connect(self.thread.quit) + + self.thread.finished.connect(self.thread.deleteLater) + self.thread.finished.connect(self.load_end) + self.thread.finished.connect(self.worker.deleteLater) + + self.worker.button_dialog.connect(self.create_button_dialog) + + def load_data(self): + """ + Start the data loading thread. + """ + self.thread.start() + + @QtCore.Slot() + def load_end(self): + """ + Cleans up after data loading ended. Called even if the loading fails or + is stopped. + + Currently does the following: + - Set running state of self to False + """ + displayTrackingInfo(self.worker.tracking_box, + self.worker.end_msg, LogType.INFO) + print(self.worker.end_msg) + self.running = False + + @QtCore.Slot() + def create_button_dialog(self, msg: str, button_labels: List[str]): + """ + Create a modal dialog with buttons. Show the dialog and send the user's + choice to the data object being created. + + :param msg: the instruction shown to the user + :type msg: str + :param button_labels: the list of labels that are shown on the buttons + :type button_labels: List[str] + """ + msg_box = QtWidgets.QMessageBox() + msg_box.setText(msg) + buttons = [] + for label in button_labels: + # RT130's labels have type Tuple[str, int], so we need to convert + # them to strings. + if not isinstance(label, str): + # When we convert a tuple to a string, any strings in the tuple + # will be surrounded by quotes in the result string. We remove + # those quotes before displaying them to the user for aesthetic + # reasons. + label = str(label).replace("'", '').replace('"', '') + buttons.append( + msg_box.addButton(label, QtWidgets.QMessageBox.ActionRole) + ) + abortButton = msg_box.addButton(QtWidgets.QMessageBox.Abort) + + msg_box.exec_() + + if msg_box.clickedButton() == abortButton: + # The default choice is the first item, so we default to it if the + # user presses the abort button. An alternative choice is to stop + # when the user presses the abort button. + chosen_idx = 0 + else: + chosen_idx = buttons.index(msg_box.clickedButton()) + + self.worker.button_chosen.emit(chosen_idx) diff --git a/sohstationviewer/model/data_type_model.py b/sohstationviewer/model/data_type_model.py index 81cb91d8b0f99449d455d221e1fd36f6f8296854..458e350f880c00b0498c5278c9e981d8f655a978 100644 --- a/sohstationviewer/model/data_type_model.py +++ b/sohstationviewer/model/data_type_model.py @@ -1,10 +1,16 @@ +from __future__ import annotations import os from tempfile import mkdtemp import shutil +from typing import Optional + +from PySide2 import QtCore from sohstationviewer.controller.util import displayTrackingInfo from sohstationviewer.conf import constants +from sohstationviewer.view.util.enums import LogType +from sohstationviewer.database.process_db import execute_db class WrongDataTypeError(Exception): @@ -12,10 +18,22 @@ class WrongDataTypeError(Exception): self.args = (args, kwargs) +class ThreadStopped(Exception): + """ + An exception that is raised when the user requests for the data loader + thread to be stopped. + """ + def __init__(self, *args, **kwargs): + self.args = (args, kwargs) + + class DataTypeModel(): def __init__(self, trackingBox, folder, readChanOnly=False, reqWFChans=[], reqSOHChans=[], readStart=0, readEnd=constants.HIGHEST_INT, + creator_thread: Optional[QtCore.QThread] = None, + notification_signal: Optional[QtCore.Signal] = None, + pause_signal: Optional[QtCore.Signal] = None, *args, **kwargs): """ Super class for different data type to process data from data files @@ -27,6 +45,13 @@ class DataTypeModel(): :param reqSOHChans: list of str - requested SOH channel list :param readStart: float - requested start time to read :param readEnd: float - requested end time to read + :param creator_thread: the thread the current DataTypeModel instance is + being created in. If None, the DataTypeModel instance is being + created in the main thread + :param notification_signal: signal used to send notifications to the + main thread. Only not None when creator_thread is not None + :param pause_signal: signal used to notify the main thread that the + data loader is paused. """ self.trackingBox = trackingBox self.dir = folder @@ -35,7 +60,17 @@ class DataTypeModel(): self.readChanOnly = readChanOnly self.readStart = readStart self.readEnd = readEnd - + if creator_thread is None: + err_msg = ( + 'A signal is not None while running in main thread' + ) + assert notification_signal is None, err_msg + assert pause_signal is None, err_msg + self.creator_thread = QtCore.QThread() + else: + self.creator_thread = creator_thread + self.notification_signal = notification_signal + self.pause_signal = pause_signal """ processingLog: [(message, type)] - record the progress of processing """ @@ -43,8 +78,9 @@ class DataTypeModel(): """ Log data: info from log channels, soh messages, text file in dict: - {chan_id: list of log strings} - 'TEXT': is the chan_id given by sohview for text only file. + {'TEXT': [str,], key:{chan_id: [str,],},} + In which 'TEXT': is the chan_id given by sohview for text only file. + Note: logData for RT130's dataset has only one channel: SOH """ self.logData = {'TEXT': []} @@ -106,11 +142,14 @@ class DataTypeModel(): 'end_tm_epoch': end epoch time of the trace - float, 'times': data's real time in epoch - np.array of float, 'data': data - np.array of float, + 'logIdx: soh message line indexes - np.array of int, } times: times that has been trimmed and down- sampled for plotting - np.array of float, data: data that has been trimmed and down-sampled for plotting - np.array of float/int, + logIdx: soh message line indexes that has been trimmed and + down-sampled for plotting - np.array of int, 'chan_db_info': the plotting parameters got from database for this channel - dict, ax: axes to draw the channel in PlottingWidget @@ -179,12 +218,16 @@ class DataTypeModel(): Will be deleted when object is deleted """ self.tmpDir = mkdtemp() + self.save_temp_data_folder_to_database() try: os.mkdir(self.tmpDir) except FileExistsError: shutil.rmtree(self.tmpDir) os.mkdir(self.tmpDir) + self._pauser = QtCore.QSemaphore() + self.pause_response = None + def __del__(self): print("delete dataType Object") try: @@ -192,7 +235,7 @@ class DataTypeModel(): except OSError as e: self.trackInfo( "Error deleting %s : %s" % (self.tmpDir, e.strerror), - "error") + LogType.ERROR) print("Error deleting %s : %s" % (self.tmpDir, e.strerror)) print("finish deleting") @@ -206,21 +249,46 @@ class DataTypeModel(): return False return True - def trackInfo(self, text, type): + def trackInfo(self, text: str, type: LogType = LogType.INFO) -> None: """ Display tracking info in tracking_box. Add all errors/warnings to processing_log. :param text: str - message to display :param type: str - type of message (error/warning/info) """ - displayTrackingInfo(self.trackingBox, text, type) - if type != 'info': + # displayTrackingInfo updates a QtWidget, which can only be done in the + # main thread. So, if we are running in a background thread + # (i.e. self.creator_thread is not None), we need to use signal slot + # mechanism to ensure that displayTrackingInfo is run in the main + # thread. + if self.notification_signal is None: + displayTrackingInfo(self.trackingBox, text, type) + else: + self.notification_signal.emit(self.trackingBox, text, type) + if type != LogType.INFO: self.processingLog.append((text, type)) @classmethod def create_data_object(cls, data_type, tracking_box, folder, readChanOnly=False, reqWFChans=[], reqSOHChans=[], readStart=0, readEnd=constants.HIGHEST_INT): + """ + Create a DataTypeModel object, with the concrete class being based on + data_type. Run on the same thread as its caller, and so will block the + GUI if called on the main thread. Do not call this method directly. + Instead, call the wrapper controller.processing.loadData. + + :param data_type: str - type of data read + :param tracking_box: QTextBrowser - widget to display tracking info + :param folder: [str,] - the data directory + :param readChanOnly: if True, only read channel name + :param reqWFChans: [str,] - requested waveform channel list + :param reqSOHChans: [str,] - requested soh channel list + :param readStart: [float,] - start time of read data + :param readEnd: [float,] - finish time of read data + :return: DataTypeModel - object that keep the data read from + folder + """ if data_type == 'RT130': from sohstationviewer.model.reftek.reftek import RT130 dataObject = RT130( @@ -234,3 +302,60 @@ class DataTypeModel(): reqWFChans=reqWFChans, reqSOHChans=reqSOHChans, readStart=readStart, readEnd=readEnd) return dataObject + + def pause(self) -> None: + """ + Pause the thread this DataTypeModel instance is in. Works by trying + to acquire a semaphore that is not available, which causes the thread + to block. + + Note: due to how this is implemented, each call to pause will require + a corresponding call to unpause. Thus, it is inadvisable to call this + method more than once. + + Caution: not safe to call in the main thread. Unless a background + thread releases the semaphore, the whole program will freeze. + """ + self._pauser.acquire() + + @QtCore.Slot() + def unpause(self): + """ + Unpause the thread this DataTypeModel instance is in. Works by trying + to acquire a semaphore that is not available, which causes the thread + to block. + + Caution: due to how this is implemented, if unpause is called before + pause, the thread will not be paused until another call to pause is + made. Also, like pause, each call to unpause must be matched by another + call to pause for everything to work. + """ + self._pauser.release() + + @QtCore.Slot() + def receive_pause_response(self, response: object): + """ + Receive a response to a request made to another thread and unpause the + calling thread. + + :param response: the response to the request made + :type response: object + """ + self.pause_response = response + self.unpause() + + @classmethod + def get_empty_instance(cls) -> DataTypeModel: + """ + Create an empty data object. Useful if a DataTypeModel instance is + needed, but it is undesirable to load a data set. Basically wraps + __new__(). + + :return: an empty data object + :rtype: DataTypeModel + """ + return cls.__new__(cls) + + def save_temp_data_folder_to_database(self): + execute_db(f'UPDATE PersistentData SET FieldValue="{self.tmpDir}" ' + f'WHERE FieldName="tempDataDirectory"') diff --git a/sohstationviewer/model/handling_data.py b/sohstationviewer/model/handling_data.py index 87321a559cdc510ba203b75f2ac8d8c1893e2d92..b28f9de0fcd32a71cad163752627fa6e1546f82f 100644 --- a/sohstationviewer/model/handling_data.py +++ b/sohstationviewer/model/handling_data.py @@ -15,6 +15,7 @@ from sohstationviewer.model.mseed.blockettes_reader import ( from sohstationviewer.conf.dbSettings import dbConf from sohstationviewer.conf import constants as const from sohstationviewer.model.reftek.from_rt2ms import core +from sohstationviewer.view.util.enums import LogType def readSOHMSeed(path2file, fileName, @@ -246,7 +247,7 @@ def readASCII(path2file, file, sta_id, chan_id, trace, log_data, track_info): """ byteorder = trace.stats.mseed['byteorder'] h = trace.stats - logText = "\n\n**** STATE OF HEALTH: " + logText = "\n\nSTATE OF HEALTH: " logText += ("From:%s To:%s\n" % (h.starttime, h.endtime)) textFromData = trace.data.tobytes().decode() logText += textFromData @@ -269,7 +270,7 @@ def readASCII(path2file, file, sta_id, chan_id, trace, log_data, track_info): nextBlktByteNo, databytes, byteorder) logText += info except ReadBlocketteError as e: - track_info(f"{sta_id} - {chan_id}: {e.msg}", 'error') + track_info(f"{sta_id} - {chan_id}: {e.msg}", LogType.ERROR) if sta_id not in log_data: log_data[sta_id] = {} @@ -292,7 +293,7 @@ def readText(path2file, fileName, textLogs, ): try: content = file.read() except UnicodeDecodeError: - raise Exception("Can't process file: %s" % fileName, 'error') + raise Exception("Can't process file: %s" % fileName, LogType.ERROR) logText = "\n\n** STATE OF HEALTH: %s\n" % fileName logText += content @@ -430,7 +431,7 @@ def squash_gaps(gaps): return squashed_gaps -def downsample(times, data, rq_points): +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. @@ -440,11 +441,19 @@ def downsample(times, data, rq_points): 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 - new times and new data with the requested size - """ + :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 log_indexes is None: + log_indexes = np.empty(times.size) if times.size <= rq_points: - return times, data + return times, data, log_indexes dataMax = max(abs(data.max()), abs(data.min())) dataMean = abs(data.mean()) indexes = np.where( @@ -452,9 +461,12 @@ def downsample(times, data, rq_points): (dataMax - dataMean) * const.CUT_FROM_MEAN_FACTOR) times = times[indexes] data = data[indexes] + log_indexes = log_indexes[indexes] + if times.size <= rq_points: - return times, data - return chunk_minmax(times, data, rq_points) + return times, data, log_indexes + + return chunk_minmax(times, data, log_indexes, rq_points) def constant_rate(times, data, rq_points): @@ -478,24 +490,25 @@ def constant_rate(times, data, rq_points): return times, data -def chunk_minmax(times, data, rq_points): +def chunk_minmax(times, data, log_indexes, rq_points): """ - Split data into differen chunks, take the min, max of each chunk to add + Split data into different chunks, take the min, max of each chunk to add to the data return - :param times: numpy array - of a waveform channel's times - :param data: numpy array - of a waveform channel's data + :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 """ - x, y = times, data + x, y, z = times, data, log_indexes final_points = 0 if x.size <= rq_points: final_points += x.size - return x, y + return x, y, log_indexes if rq_points < 2: - return np.empty((1, 0)), np.empty((1, 0)) + 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 @@ -512,36 +525,35 @@ def chunk_minmax(times, data, rq_points): # the requested sample size, but not by much. x0 = times[:cs * size] y0 = data[:cs * size] + z0 = log_indexes[:cs * size] x1 = times[cs * size:] y1 = data[cs * size:] + z1 = data[cs * size:] - dx0, dy0 = downsample(x0, y0, rq_points) + dx0, dy0, dz0 = downsample(x0, y0, z0, rq_points=rq_points) # right-most subarray is always smaller than # the initially requested number of points. - dx1, dy1 = downsample(x1, y1, cs) + dx1, dy1, dz1 = downsample(x1, y1, z1, rq_points=cs) dx = np.zeros(dx0.size + dx1.size) dy = np.zeros(dy0.size + dy1.size) - - # print(dx0.size + dx1.size) + dz = np.zeros(dz0.size + dz1.size) dx[:dx0.size] = dx0 dy[:dy0.size] = dy0 + dz[:dz0.size] = dz0 dx[dx0.size:] = dx1 dy[dy0.size:] = dy1 - del x0 - del y0 - del x1 - del y1 - del times - del data - return dx, dy + dz[dz0.size:] = dz1 + + return dx, dy, dz x = x.reshape(size, cs) y = y.reshape(size, cs) + z = z.reshape(size, cs) imin = np.argmin(y, axis=1) imax = np.argmax(y, axis=1) @@ -554,7 +566,8 @@ def chunk_minmax(times, data, rq_points): dx = x[mask] dy = y[mask] - return dx, dy + dz = z[mask] + return dx, dy, dz def trim_downsample_SOHChan(chan, startTm, endTm, firsttime): @@ -570,14 +583,17 @@ def trim_downsample_SOHChan(chan, startTm, endTm, firsttime): :param endTm: float - end time of zoomed section :param firsttime: bool True for original size when channel is not zoomed in """ - # TODO, add logIdx to downsample if using reftex - # zoom in to the given time + # zoom into the given time tr = chan['orgTrace'] - indexes = np.where((startTm <= tr['times']) & (tr['times'] <= endTm)) - chan['times'], chan['data'] = downsample( - tr['times'][indexes], tr['data'][indexes], - const.CHAN_SIZE_LIMIT) + if 'logIdx' in tr.keys(): + chan['times'], chan['data'], chan['logIdx'] = downsample( + tr['times'][indexes], tr['data'][indexes], tr['logIdx'][indexes], + rq_points=const.CHAN_SIZE_LIMIT) + else: + chan['times'], chan['data'], _ = downsample( + tr['times'][indexes], tr['data'][indexes], + rq_points=const.CHAN_SIZE_LIMIT) def trim_waveform_data(wf_channel_data: Dict, start_time: float, @@ -661,7 +677,8 @@ def downsample_waveform_data(trimmed_traces_list: List[Dict], times = times[indexes] data = data[indexes] if requested_points != 0: - times, data = downsample(times, data, requested_points) + times, data, _ = downsample(times, data, + rq_points=requested_points) downsampled_times_list.append(times) downsampled_data_list.append(data) diff --git a/sohstationviewer/model/mseed/mseed.py b/sohstationviewer/model/mseed/mseed.py index 6bc7a2108460a1f0cd128e9fd89699dc8ddb66e1..4c09b26075c92594fada14c4e0352961f0e86cac 100644 --- a/sohstationviewer/model/mseed/mseed.py +++ b/sohstationviewer/model/mseed/mseed.py @@ -2,23 +2,17 @@ MSeed object to hold and process MSeed data """ - import os from pathlib import Path -from PySide2 import QtWidgets - -from sohstationviewer.view.select_buttons_dialog import ( - SelectButtonDialog) -from sohstationviewer.model.data_type_model import DataTypeModel - from sohstationviewer.conf import constants - -from sohstationviewer.model.mseed.from_mseedpeek.mseed_header import readHdrs from sohstationviewer.controller.util import validateFile +from sohstationviewer.model.data_type_model import DataTypeModel, ThreadStopped from sohstationviewer.model.handling_data import ( - readWaveformMSeed, squash_gaps, checkWFChan, - sortData, readSOHTrace) + readWaveformMSeed, squash_gaps, checkWFChan, sortData, readSOHTrace, +) +from sohstationviewer.model.mseed.from_mseedpeek.mseed_header import readHdrs +from sohstationviewer.view.util.enums import LogType class MSeed(DataTypeModel): @@ -46,10 +40,16 @@ class MSeed(DataTypeModel): """ self.netsProbInFile = {} + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() self.read_soh_and_index_waveform(self.dir) + + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() self.selectedKey = self.selectStaID() + if self.selectedKey is None: - return + raise ThreadStopped() if len(self.reqWFChans) != 0: self.readWFFiles(self.selectedKey) @@ -89,13 +89,16 @@ class MSeed(DataTypeModel): for path, sub_dirs, files in os.walk(folder): for file_name in files: + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() + path2file = Path(path).joinpath(file_name) if not validateFile(path2file, file_name): continue count += 1 if count % 50 == 0: self.trackInfo( - f'Read {count} file headers/ SOH files', 'info') + f'Read {count} file headers/ SOH files', LogType.INFO) ret = readHdrs( path2file, file_name, soh_streams, self.logData, @@ -148,14 +151,14 @@ class MSeed(DataTypeModel): if len(stat_prop) > 0: errmsg = (f"More than one stations in a file: {stat_prop}. " f"Will use the first one.") - self.trackInfo(errmsg, "error") + self.trackInfo(errmsg, LogType.ERROR) if len(net_stat_prop) > 0: errmsg = "More than one netIDs in a file: %s" % net_stat_prop - self.trackInfo(errmsg, "warning") + self.trackInfo(errmsg, LogType.WARNING) if len(chan_prop) > 0: errmsg = (f"More than one channels in a file: {chan_prop} " f"\nThis is a CRITICAL ERROR.") - self.trackInfo(errmsg, "error") + self.trackInfo(errmsg, LogType.ERROR) return waveform_data, soh_streams def merge_soh_streams(self, soh_streams): @@ -189,16 +192,16 @@ class MSeed(DataTypeModel): stream = soh_streams[sta_id][chan_id] stream.merge() + + tr = stream[0] if len(stream) > 1: nets = [tr.stats['network'].strip() for tr in stream] nets += [f"Combine to {n}" for n in nets] msg = (f"There are more than one net for sta {sta_id}.\n" "Please select one or combine all to one.") - msg_box = SelectButtonDialog(message=msg, - button_labels=nets) - msg_box.exec_() - sel_net = nets[msg_box.ret] - + self.pause_signal.emit(msg, nets) + self.pause() + sel_net = nets[self.pause_response] if "Combine" not in sel_net: tr = [tr for tr in stream if tr.stats['network'] == sel_net][0] @@ -210,8 +213,6 @@ class MSeed(DataTypeModel): self.nets.add(sel_net) stream.merge() tr = stream[0] - else: - tr = stream[0] gaps_in_stream = stream.get_gaps() all_gaps += [[g[4].timestamp, g[5].timestamp] @@ -243,22 +244,12 @@ class MSeed(DataTypeModel): selectedStaID = stats[0] if len(stats) > 1: msg = ("There are more than one stations in the given data.\n" - "Please select one to one to display") - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg) - staButtons = [] - for staID in stats: - staButtons.append(msgBox.addButton( - staID, QtWidgets.QMessageBox.ActionRole)) - abortButton = msgBox.addButton(QtWidgets.QMessageBox.Abort) - - msgBox.exec_() - - if msgBox.clickedButton() == abortButton: - return selectedStaID - selectedIdx = staButtons.index(msgBox.clickedButton()) - selectedStaID = stats[selectedIdx] - self.trackInfo(f'Select Station {selectedStaID}', 'info') + "Please select one to display") + self.pause_signal.emit(msg, stats) + self.pause() + selectedStaID = stats[self.pause_response] + + self.trackInfo(f'Select Station {selectedStaID}', LogType.INFO) return selectedStaID def readWFFiles(self, staID): @@ -289,6 +280,8 @@ class MSeed(DataTypeModel): 'readData'][chanID]['tracesInfo'] for fileInfo in self.waveformData[staID]['filesInfo'][chanID]: + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped # file have been read if fileInfo['read']: continue @@ -307,6 +300,7 @@ class MSeed(DataTypeModel): fileInfo['read'] = True count += 1 if count % 50 == 0: - self.trackInfo(f'Read {count} waveform files', 'info') + self.trackInfo( + f'Read {count} waveform files', LogType.INFO) sortData(self.waveformData) diff --git a/sohstationviewer/model/reftek/logInfo.py b/sohstationviewer/model/reftek/logInfo.py index b8f1caf0727d1a41abe07e78cb50be0381f6e969..dc98cbd93976c574ce11c6baa3352eea7c07fd95 100644 --- a/sohstationviewer/model/reftek/logInfo.py +++ b/sohstationviewer/model/reftek/logInfo.py @@ -2,6 +2,7 @@ from sohstationviewer.conf import constants from sohstationviewer.controller.util import ( getTime6, getTime4, getVal, rtnPattern) +from sohstationviewer.view.util.enums import LogType class LogInfo(): @@ -68,7 +69,7 @@ class LogInfo(): else: epoch, _ = getTime6(parts[8]) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False if epoch > 0: self.minEpoch = min(epoch, self.minEpoch) @@ -90,7 +91,7 @@ class LogInfo(): try: epoch, self.trackYear = getTime6(parts[3]) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False self.yAdded = False # reset yAdded self.minEpoch = min(epoch, self.minEpoch) @@ -101,7 +102,7 @@ class LogInfo(): "read, but now there is information for DAS %s in " "the same State Of Health messages.\n" "Skip reading State Of Health." % (self.unitID, unitID)) - self.trackInfo(msg, 'error') + self.trackInfo(msg, LogType.ERROR) False return epoch @@ -119,7 +120,7 @@ class LogInfo(): epoch, self.trackYear, self.yAdded = getTime4( parts[0], self.trackYear, self.yAdded) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False self.maxEpoch = max(epoch, self.maxEpoch) return parts, epoch @@ -161,7 +162,7 @@ class LogInfo(): volts = getVal(parts[4]) temp = getVal(parts[7]) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False if self.model == "RT130": bkupV = getVal(parts[10]) @@ -186,7 +187,7 @@ class LogInfo(): disk = getVal(parts[2]) val = getVal(parts[4]) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False return epoch, disk, val @@ -203,7 +204,7 @@ class LogInfo(): secs = getVal(parts[4]) msecs = getVal(parts[-2]) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False total = abs(secs) * 1000.0 + abs(msecs) if secs < 0.0 or msecs < 0.0: @@ -231,7 +232,7 @@ class LogInfo(): try: epoch, _ = getTime6(parts[-3]) except AttributeError: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append(line, LogType.ERROR) return False else: epoch = self.maxEpoch @@ -315,10 +316,13 @@ class LogInfo(): Extract data from each line of log string to add to SOH channels's orgTrace using addChanInfo() """ - lines = [ln.strip() for ln in self.logText.splitlines() if ln != ''] + lines = [ln.strip() for ln in self.logText.splitlines()] sohEpoch = 0 for idx, line in enumerate(lines): + + if line == '': + continue line = line.upper() if 'FST' in line: ret = self.readEVT(line) @@ -329,9 +333,11 @@ class LogInfo(): chanName = 'Event DS%s' % DS self.addChanInfo(chanName, epoch, 1, idx) elif epoch == 0: - self.parent.processingLog.append(line, 'warning') + self.parent.processingLog.append( + line, LogType.WARNING) else: - self.parent.processingLog.append(line, 'error') + self.parent.processingLog.append( + line, LogType.ERROR) elif line.startswith("STATE OF HEALTH"): epoch = self.readSHHeader(line) @@ -413,7 +419,7 @@ class LogInfo(): elif "MASS RE-CENTER" in line: epoch = self.simpleRead(line)[1] if epoch: - self.addChanInfo('Mass Re-center', epoch, idx) + self.addChanInfo('Mass Re-center', epoch, 0, idx) elif any(x in line for x in ["SYSTEM RESET", "FORCE RESET"]): epoch = self.simpleRead(line)[1] diff --git a/sohstationviewer/model/reftek/reftek.py b/sohstationviewer/model/reftek/reftek.py index 2cf1d9024c66f7ff1e682740e0c992366e6dad77..6333c44caae4a2924790542572473b8e47e611ce 100755 --- a/sohstationviewer/model/reftek/reftek.py +++ b/sohstationviewer/model/reftek/reftek.py @@ -6,13 +6,12 @@ import os from pathlib import Path import numpy as np -from PySide2 import QtWidgets from obspy.core import Stream from sohstationviewer.model.reftek.from_rt2ms import ( core, soh_packet, packet) from sohstationviewer.model.reftek.logInfo import LogInfo -from sohstationviewer.model.data_type_model import DataTypeModel +from sohstationviewer.model.data_type_model import DataTypeModel, ThreadStopped from sohstationviewer.model.handling_data import ( readWaveformReftek, squash_gaps, sortData, readMPTrace, readText) @@ -20,6 +19,8 @@ from sohstationviewer.conf import constants from sohstationviewer.controller.util import validateFile +from sohstationviewer.view.util.enums import LogType + class RT130(DataTypeModel): """ @@ -32,11 +33,19 @@ class RT130(DataTypeModel): self.keys = set() self.reqDSs = self.reqWFChans self.massPosStream = {} + + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() self.readSOH_indexWaveform(self.dir) + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() self.selectedKey = self.selectKey() if self.selectedKey is None: - return + raise ThreadStopped() + + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() if len(self.reqWFChans) != 0: self.readWFFiles(self.selectedKey) @@ -49,6 +58,8 @@ class RT130(DataTypeModel): count = 0 for path, subdirs, files in os.walk(folder): for fileName in files: + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() path2file = Path(path).joinpath(fileName) if not validateFile(path2file, fileName): continue @@ -57,7 +68,7 @@ class RT130(DataTypeModel): count += 1 if count % 50 == 0: self.trackInfo( - f'Read {count} file headers/ SOH files', 'info') + f'Read {count} file headers/ SOH files', LogType.INFO) self.combineData() @@ -74,22 +85,12 @@ class RT130(DataTypeModel): selectedKey = self.keys[0] if len(self.keys) > 1: msg = ("There are more than one keys in the given data.\n" - "Please select one to one to display") - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg) - staButtons = [] - for key in self.keys: - staButtons.append(msgBox.addButton( - key, QtWidgets.QMessageBox.ActionRole)) - abortButton = msgBox.addButton(QtWidgets.QMessageBox.Abort) - - msgBox.exec_() - - if msgBox.clickedButton() == abortButton: - return selectedKey - selectedIdx = staButtons.index(msgBox.clickedButton()) - selectedKey = self.keys[selectedIdx] - self.trackInfo(f'Select Key {selectedKey}', 'info') + "Please select one to display") + self.pause_signal.emit(msg, self.keys) + self.pause() + selectedKey = self.keys[self.pause_response] + + self.trackInfo(f'Select Key {selectedKey}', LogType.INFO) return selectedKey def readWFFiles(self, key): @@ -111,6 +112,8 @@ class RT130(DataTypeModel): for DS in self.waveformData[key]['filesInfo']: readData = self.waveformData[key]['readData'] for fileInfo in self.waveformData[key]['filesInfo'][DS]: + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() # file have been read if fileInfo['read']: continue @@ -127,7 +130,8 @@ class RT130(DataTypeModel): fileInfo['read'] = True count += 1 if count % 50 == 0: - self.trackInfo(f'Read {count} waveform files', 'info') + self.trackInfo( + f'Read {count} waveform files', LogType.INFO) sortData(self.waveformData) def addLog(self, chan_pkt, logInfo): @@ -282,6 +286,8 @@ class RT130(DataTypeModel): is too big to consider calculating gaps. """ for k in self.logData: + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() if k == 'TEXT': continue if k not in self.dataTime: @@ -302,7 +308,7 @@ class RT130(DataTypeModel): except KeyError: pass logStr = ''.join(logs) - self.logData[k][pktType] = logStr + self.logData[k] = {'SOH': [logStr]} logObj = LogInfo( self, self.trackInfo, logStr, k, self.reqDSs) self.dataTime[k][0] = min(logObj.minEpoch, self.dataTime[k][0]) @@ -317,6 +323,8 @@ class RT130(DataTypeModel): self.gaps = {k: [] for k in self.keys} self.massPosData = {k: {} for k in self.keys} for k in self.massPosStream: + if self.creator_thread.isInterruptionRequested(): + raise ThreadStopped() stream = self.massPosStream[k] stream.merge() for tr in stream: diff --git a/sohstationviewer/view/channel_prefer_dialog.py b/sohstationviewer/view/channel_prefer_dialog.py index c50b945b74586243fc9dfd9f74e2b1c4c5714a38..ff2b43769135d314010101497a2adbde31c12519 100755 --- a/sohstationviewer/view/channel_prefer_dialog.py +++ b/sohstationviewer/view/channel_prefer_dialog.py @@ -1,10 +1,14 @@ from PySide2 import QtWidgets, QtCore -from sohstationviewer.database.proccessDB import ( - executeDB, trunc_addDB, executeDB_dict) +from sohstationviewer.database.process_db import ( + execute_db, trunc_add_db, execute_db_dict) + from sohstationviewer.controller.processing import readChannels, detectDataType from sohstationviewer.controller.util import displayTrackingInfo +from sohstationviewer.view.util.enums import LogType + + INSTRUCTION = """ Place lists of channels to be read in the IDs field.\n Select the radiobutton for the list to be used in plotting. @@ -373,9 +377,9 @@ class ChannelPreferDialog(QtWidgets.QWidget): self.parent.data_type = 'Unknown' return True - ret = trunc_addDB('ChannelPrefer', sql_list) + ret = trunc_add_db('ChannelPrefer', sql_list) if ret is not True: - displayTrackingInfo(self.parent, ret, "error") + displayTrackingInfo(self.parent, ret, LogType.ERROR) self.parent.IDs = [ t.strip() for t in self.id_widget.text().split(',')] self.parent.IDsName = self.name_widget.text().strip() @@ -425,7 +429,7 @@ class ChannelPreferDialog(QtWidgets.QWidget): :return: [str, ] - list of data types """ - data_type_rows = executeDB( + data_type_rows = execute_db( 'SELECT * FROM DataTypes ORDER BY dataType ASC') return [d[0] for d in data_type_rows] @@ -435,7 +439,7 @@ class ChannelPreferDialog(QtWidgets.QWidget): :param data_type: str - the given data type """ - channel_rows = executeDB( + channel_rows = execute_db( f"SELECT channel FROM CHANNELS WHERE dataType='{data_type}' " f" ORDER BY dataType ASC") return [c[0] for c in channel_rows] @@ -447,7 +451,7 @@ class ChannelPreferDialog(QtWidgets.QWidget): :param id_rows: [dict,] - list of data for each row """ - id_rows = executeDB_dict( + id_rows = execute_db_dict( "SELECT name, IDs, dataType, current FROM ChannelPrefer " " ORDER BY name ASC") return id_rows diff --git a/sohstationviewer/view/core/plotting_widget.py b/sohstationviewer/view/core/plotting_widget.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/sohstationviewer/view/db_config/channel_dialog.py b/sohstationviewer/view/db_config/channel_dialog.py index 1bc7ebb1c03c67d45e8eeb04710c78203d088a38..a866b9c3250b953f78d8672cf97396d7638f1a21 100755 --- a/sohstationviewer/view/db_config/channel_dialog.py +++ b/sohstationviewer/view/db_config/channel_dialog.py @@ -4,7 +4,7 @@ GUI to add/edit/remove channels """ from sohstationviewer.view.db_config.db_config_dialog import UiDBInfoDialog -from sohstationviewer.database.proccessDB import executeDB +from sohstationviewer.database.process_db import execute_db class ChannelDialog(UiDBInfoDialog): @@ -30,7 +30,7 @@ class ChannelDialog(UiDBInfoDialog): Create list of parameters to used in widget for selecting parameters before update item in self.data_table_widgets """ - param_rows = executeDB("SELECT param from parameters") + param_rows = execute_db("SELECT param from parameters") self.param_choices = [''] + sorted([d[0] for d in param_rows]) super(ChannelDialog, self).update_data_table_widget_items() @@ -76,7 +76,7 @@ class ChannelDialog(UiDBInfoDialog): """ Get list of data to fill self.data_table_widgets' content """ - channel_rows = executeDB( + channel_rows = execute_db( f"SELECT channel, label, param, convertFactor, unit, fixPoint " f"FROM Channels " f"WHERE dataType='{self.data_type}'") diff --git a/sohstationviewer/view/db_config/data_type_dialog.py b/sohstationviewer/view/db_config/data_type_dialog.py index 2710cda896785c598c2cf86a6d8b3b43d6242906..75c8aea23f1ef7e22acb6428bf4b8dcf90c92527 100755 --- a/sohstationviewer/view/db_config/data_type_dialog.py +++ b/sohstationviewer/view/db_config/data_type_dialog.py @@ -5,7 +5,7 @@ NOTE: Cannot remove or change dataTypes that already have channels. """ from sohstationviewer.view.db_config.db_config_dialog import UiDBInfoDialog -from sohstationviewer.database.proccessDB import executeDB +from sohstationviewer.database.process_db import execute_db class DataTypeDialog(UiDBInfoDialog): @@ -30,7 +30,7 @@ class DataTypeDialog(UiDBInfoDialog): Get list of data to fill self.data_table_widgets' content """ - data_type_rows = executeDB('SELECT * FROM DataTypes') + data_type_rows = execute_db('SELECT * FROM DataTypes') return [[d[0]] for d in data_type_rows] def get_row_inputs(self, row_idx): diff --git a/sohstationviewer/view/db_config/db_config_dialog.py b/sohstationviewer/view/db_config/db_config_dialog.py index 33c811ad51dec1da72a1d1e66be51a488b12d8e0..9cb182bdaaa05d9873cb4897060a04b521fb65da 100755 --- a/sohstationviewer/view/db_config/db_config_dialog.py +++ b/sohstationviewer/view/db_config/db_config_dialog.py @@ -1,6 +1,6 @@ from PySide2 import QtWidgets, QtGui, QtCore -from sohstationviewer.database.proccessDB import executeDB +from sohstationviewer.database.process_db import execute_db def set_widget_color(widget, changed=False, read_only=False): @@ -198,7 +198,7 @@ class UiDBInfoDialog(QtWidgets.QWidget): if self.need_data_type_choice: self.data_type_combo_box = QtWidgets.QComboBox(self) - data_type_rows = executeDB('SELECT * FROM DataTypes') + data_type_rows = execute_db('SELECT * FROM DataTypes') self.data_type_combo_box.addItems([d[0] for d in data_type_rows]) self.data_type_combo_box.currentTextChanged.connect( self.data_type_changed) @@ -317,7 +317,7 @@ class UiDBInfoDialog(QtWidgets.QWidget): return False sql = (f"SELECT {self.col_name} FROM channels " f"WHERE {self.col_name}='{val}'") - param_rows = executeDB(sql) + param_rows = execute_db(sql) if len(param_rows) > 0: return True else: @@ -442,7 +442,7 @@ class UiDBInfoDialog(QtWidgets.QWidget): f"WHERE {self.col_name}='{org_row[0]}'") if del_sql_add is not None: sql += sql - executeDB(sql) + execute_db(sql) self.data_list.remove(org_row) self.remove_row(widget_idx) self.remove_count += 1 @@ -473,7 +473,7 @@ class UiDBInfoDialog(QtWidgets.QWidget): if result == QtWidgets.QMessageBox.Cancel: return 1 else: - executeDB(update_sql % org_row[0]) + execute_db(update_sql % org_row[0]) self.data_list[list_idx] = row return 0 @@ -499,7 +499,7 @@ class UiDBInfoDialog(QtWidgets.QWidget): QtWidgets.QMessageBox.information(self, "Error", msg) self.remove_row(widget_idx) return -1 - executeDB(insert_sql) + execute_db(insert_sql) self.data_list.append(row) self.insert_count += 1 return 0 diff --git a/sohstationviewer/view/db_config/param_dialog.py b/sohstationviewer/view/db_config/param_dialog.py index b55ac1a22de92c1a1de48905afa0dee12e792683..5734067d62bf700e3c28c4b2086d94e1aa6bece6 100755 --- a/sohstationviewer/view/db_config/param_dialog.py +++ b/sohstationviewer/view/db_config/param_dialog.py @@ -9,7 +9,7 @@ from PySide2 import QtWidgets from sohstationviewer.view.util.plot_func_names import plot_functions from sohstationviewer.view.db_config.db_config_dialog import UiDBInfoDialog -from sohstationviewer.database.proccessDB import executeDB +from sohstationviewer.database.process_db import execute_db from sohstationviewer.conf.dbSettings import dbConf @@ -46,7 +46,7 @@ class ParamDialog(UiDBInfoDialog): """ Get list of data to fill self.data_table_widgets' content """ - param_rows = executeDB('SELECT * FROM Parameters') + param_rows = execute_db('SELECT * FROM Parameters') return [[d[0], '' if d[1] is None else d[1], d[2], diff --git a/sohstationviewer/view/file_list_widget.py b/sohstationviewer/view/file_list_widget.py index f01499fd52d3cb5e2661217a1de61e50b886bdd8..8cb08e9b929049804fbb021f3c8201bab7a14267 100644 --- a/sohstationviewer/view/file_list_widget.py +++ b/sohstationviewer/view/file_list_widget.py @@ -4,9 +4,9 @@ from PySide2 import QtWidgets class FileListItem(QtWidgets.QListWidgetItem): """ Widget to select file and save the absolute path of the file under - self.filePath variable + self.file_path variable """ - def __init__(self, filePath, parent=None): - super().__init__(filePath.name, parent, + def __init__(self, file_path, parent=None): + super().__init__(file_path.name, parent, type=QtWidgets.QListWidgetItem.UserType) - self.filePath = filePath + self.file_path = file_path diff --git a/sohstationviewer/view/help_view.py b/sohstationviewer/view/help_view.py new file mode 100644 index 0000000000000000000000000000000000000000..a13e1b78626f58f1cce594b5c01742bf59ae5f5b --- /dev/null +++ b/sohstationviewer/view/help_view.py @@ -0,0 +1,545 @@ +import sys +from pathlib import Path +from typing import Tuple, Callable, Union, List, Dict + +from PySide2 import QtCore, QtGui, QtWidgets +from PySide2.QtWidgets import QStyle + +from sohstationviewer.view.util.functions import ( + create_search_results_file, create_table_of_content_file) +from sohstationviewer.view.util.color import set_color_mode +from sohstationviewer.conf import constants as const + + +class HelpBrowserItemDelegate(QtWidgets.QStyledItemDelegate): + """ + To help showing names of files in tree list without extension. + """ + def initStyleOption(self, opt, index): + super().initStyleOption(opt, index) + if not index.model().isDir(index): + base_file_name = index.model().fileInfo(index).baseName() + try: + display_file_name = base_file_name.split(" _ ")[1] + except IndexError: + print(f"WARNING: Filename '{base_file_name}' in " + f"'documentation/' is NOT in the " + f"correct format: <order number> _ <text>") + display_file_name = base_file_name + + opt.text = display_file_name + + def setEditorData(self, ed, index): + if isinstance(index.model(), QtWidgets.QFileSystemModel): + if not index.model().isDir(index): + ed.setText(index.model().fileInfo(index).baseName()) + else: + super().setEditorData(ed, index) + + +class HelpBrowser(QtWidgets.QWidget): + """ + GUI: + + Search box: to enter text to search for, first search is processed + with text changed + + Search through all documents button: To create the links to all + documents that contain search text. Click on a link will go to + the link's page with search text highlight at the first position. + + Table of Contents button: to go to Table of Contents page. + + Recreate Table of Contents button: to recreate Table of Contents + when there are any changes in the name of help documents that may + make links broken or some pages has no links in Table of Contents. + + Backward arrow button: to search backward on the current page. + + Forward arrow button: to search forward on the current page. + + Go to Search Results page: to continue working on Search Results page + + Document list: tree list located on left side to browse for help + documents. + + Document view: the largest text box located on the right side of the + dialog to display the current selected document's content. + + Attributes + ---- + SCREEN_SIZE_SCALAR_X : float + Specifies how to scale the width of the window, in relation to total + screen size. + SCREEN_SIZE_SCALAR_Y : float + Specifies how to scale the height of the window, in relation to total + screen size. + TREE_VIEW_SCALAR: float + Specifies how to scale the width of tree_view, in relation to total + screen size. + """ + + SCREEN_SIZE_SCALAR_X = 0.40 + SCREEN_SIZE_SCALAR_Y = 0.50 + TREE_VIEW_SCALAR = 0.25 + + def __init__( + self, + parent: Union[QtWidgets.QWidget, QtWidgets.QMainWindow] = None, + home_path: str = '.'): + """ + :param parent: The window that call HelpBrowser object + :type parent: QWidget/QMainWindow + :param home_path: relative path to the folder where app is started + :type home_path: str + """ + super().__init__(parent) + """ + home_path: absolute path to the root of the project + """ + self.home_path = Path(home_path).resolve() + """ + docdir_path: path to the folder containing the documentations + """ + self.docdir_path: Path = self.home_path.joinpath('documentation') + """ + images_path: path to the folder containing images + """ + self.images_path: Path = self.home_path.joinpath('images') + """ + contents_table_path: the absolute path to "01 _ Table of Contents.md" + """ + self.contents_table_path: Path = self.docdir_path.joinpath( + const.TABLE_CONTENTS) + """ + search_results_path: the absolute path to "Search Results.md" + """ + self.search_results_path: Path = self.docdir_path.joinpath( + 'Search Results.md') + + # Get screen dimensions + geom = QtGui.QGuiApplication.screens()[0].availableGeometry() + self.setGeometry(10, 10, + geom.size().width() * self.SCREEN_SIZE_SCALAR_X, + geom.size().height() * self.SCREEN_SIZE_SCALAR_Y + ) + self.setWindowTitle("Help Documents") + # -------------------- PRE-DEFINE WIDGETS ---------------------------- + """ + search_box: text box to enter a string to search along the + current table. + """ + self.search_box: QtWidgets.QLineEdit = None + """ + search_all_button: button to perform create list of links of documents + that contain search text which is called search results + """ + self.search_all_button: QtWidgets.QPushButton = None + """ + go_to_search_results_button: button to navigate to the search results + """ + self.go_to_search_results_button: QtWidgets.QToolButton = None + """ + tree_view: tree list of all documents + """ + self.tree_view: QtWidgets.QTreeView = None + """ + file_system_model: provide data model for document's filesystem + """ + self.file_system_model: QtWidgets.QFileSystemModel = None + """ + help_view: to display selected document's content + """ + self.help_view: QtWidgets.QTextBrowser = None + # --------------------- PRE-DEFINED OTHER ATTRIBUTES ---------------- + """ + cursor_list: list of cursors of search text found + """ + self.cursor_list: List[QtGui.QTextCursor] = None + """ + current_index: index of the current cursor in cursor_list + """ + self.current_index: int = 0 + """ + display_color: {type: color,} - color map for setting background color + for different types of processing log or + "state of health"/"Events:" labels + """ + self.display_color: Dict[str, str] = set_color_mode("W") + + """ + highlight_format: to apply to the found text in help view + """ + self.highlight_format: QtGui.QTextCharFormat = \ + self.get_highlight_format() + + self.setup_ui() + + self.tree_view.setFocus() + + def __del__(self): + try: + with open(self.docdir_path.joinpath(const.SEARCH_RESULTS), 'w'): + pass + except NameError: + pass + + def get_highlight_format(self): + """ + Setting format to apply for search_text found in document + + :return: format to apply for search_text + :rtype: QtGui.QTextCharFormat + """ + text_format = QtGui.QTextCharFormat() + text_format.setForeground( + QtGui.QColor(self.display_color['highlight']) + ) + return text_format + + def setup_ui(self): + """ + Setting up GUI including + + A search box and a button to search through all documents + + A navigation bar to go to Table of Contents, recreate Table of + Contents, search back and forth, go to Search Results + + A tree to list all documents and select document to view + + A view to view the selected documents + """ + # Searching + search_layout = QtWidgets.QHBoxLayout() + self.search_box = QtWidgets.QLineEdit() + self.search_box.setTextMargins(7, 7, 7, 7) + self.search_box.setFixedHeight(40) + self.search_box.setClearButtonEnabled(True) + self.search_box.setPlaceholderText("Enter a string to search") + self.search_box.textChanged.connect(self.start_search_on_curr_doc) + search_layout.addWidget(self.search_box) + + self.search_all_button = QtWidgets.QPushButton( + "Search through\nAll Documents") + self.search_all_button.setFixedHeight(50) + self.search_all_button.setFixedWidth(150) + self.search_all_button.clicked.connect(self.search_through_all_docs) + search_layout.addWidget(self.search_all_button) + + navigation = self.create_navigation() + + # Documentation listing + self.tree_view, self.file_system_model = self.create_tree_view() + + # Help documentation display + self.help_view = QtWidgets.QTextBrowser() + # set stylesheet for the selected text in help_view + self.help_view.setStyleSheet( + f"selection-background-color:" + f" {self.display_color['highlight_background']};" + f"selection-color: {self.display_color['highlight']};") + self.help_view.sourceChanged.connect(self.on_source_changed) + split = QtWidgets.QSplitter() + split.addWidget(self.tree_view) + split.addWidget(self.help_view) + + # --------- set layout ------------------------------------- + main_layout = QtWidgets.QVBoxLayout() + self.setLayout(main_layout) + main_layout.setSpacing(10) + main_layout.setContentsMargins(5, 5, 5, 5) + + main_layout.addLayout(search_layout) + main_layout.addWidget(navigation) + main_layout.addWidget(split, 2) + + self.load_file(self.contents_table_path.as_posix()) + + def create_navigation(self) -> QtWidgets.QToolBar: + """ + Create navigation bar includes: + + A button to go back to Table of Contents document + + A button to recreate TAble of Contents document + + A button to search backward on the current document + + A button to search forward on the current document + + A button to go back to Search Results + + :return: Toolbar that includes necessary buttons + :rtype: QtWidgets.QToolBar + """ + + nav_bar = QtWidgets.QToolBar("Navigation") + + self.add_nav_button( + nav_bar, + QtGui.QIcon(self.images_path.joinpath( + 'table_contents.png').as_posix()), + 'Navigate to Table of Contents', self.go_table_contents) + + self.add_nav_button( + nav_bar, + QtGui.QIcon(self.images_path.joinpath( + 'recreate_table_contents.png').as_posix()), + 'Recreate Table of Contents', self.recreate_table_contents) + + self.add_nav_button( + nav_bar, self.style().standardIcon(QStyle.SP_ArrowBack), + 'Search Backward', self.search_backward) + + self.add_nav_button( + nav_bar, self.style().standardIcon(QStyle.SP_ArrowForward), + 'Search Forward', self.search_forward) + + self.go_to_search_results_button = self.add_nav_button( + nav_bar, + QtGui.QIcon(self.images_path.joinpath( + 'search_results.png').as_posix()), + 'Navigate to Search Results', self.go_search_results) + self.go_to_search_results_button.setEnabled(False) + return nav_bar + + def add_nav_button(self, nav: QtWidgets.QToolBar, + icon: QtGui.QIcon, + tool_tip_text: str, function: Callable[[], None])\ + -> QtWidgets.QToolButton: + """ + Add a QToolButton to Navigation QToolBar including: icon, tool tip, + function to do + + :param nav: Tool bar to add button + :type nav: QToolBar + :param icon: icon of the button + :type icon: QtGui.QIcon + :param tool_tip_text: Description of what the button will do + :type tool_tip_text: str + :param function: The method that perform the button's action + :type function: method + :return: The added button + :rtype: QtWidgets.QToolButton + """ + button = QtWidgets.QToolButton() + button.setIcon(icon) + button.setToolTip(tool_tip_text) + button.clicked.connect(function) + nav.addWidget(button) + return button + + def create_tree_view(self) -> Tuple[QtWidgets.QTreeView, + QtWidgets.QFileSystemModel]: + """ + Create tree_view associating with file_system_model + + :return: tree view to show list of files in document folder + :rtype: QTreeView + :return: file system model for mechanism to access file when it is + clicked on tree view + :rtype: QFileSystemModel + """ + tree_view = QtWidgets.QTreeView() + + file_system_model = QtWidgets.QFileSystemModel() + file_system_icon_provider = QtWidgets.QFileIconProvider() + + file_system_model.setIconProvider(file_system_icon_provider) + index = file_system_model.setRootPath(self.docdir_path.as_posix()) + + file_system_model.setFilter( + QtCore.QDir.NoDotAndDotDot | QtCore.QDir.Files) + file_system_model.setNameFilters(['*.help.md']) + file_system_model.setNameFilterDisables(False) # hide inactive + + tree_view.setItemDelegate(HelpBrowserItemDelegate()) + + tree_view.setModel(file_system_model) + + tree_view.setRootIndex( + index) + + for i in range(1, file_system_model.columnCount()): + tree_view.hideColumn(i) + + width = self.geometry().size().width() + tree_view.setMaximumWidth(width * self.TREE_VIEW_SCALAR) + + tree_view.clicked.connect(self.on_tree_view_item_clicked) + tree_view.setToolTip('Available documentation pages') + return tree_view, file_system_model + + def load_file(self, file_path: str): + """ + Load file from file_path to help_view + + :param file_path: absolute path to a document + :type file_path: str + """ + url = QtCore.QUrl.fromLocalFile(file_path) + self.help_view.setSource(url) + + @QtCore.Slot() + def on_tree_view_item_clicked(self, index: QtCore.QModelIndex): + """ + Load file to help_view when a file name is click on tree_view + + :param index: index of the file name + :type index: QtCore.QModelIndex + """ + path = self.file_system_model.filePath(index) + self.load_file(path) + + @QtCore.Slot() + def go_table_contents(self): + """ + Select Table of Contents on tree view and bring the file to help view + """ + self._go_to_file(self.contents_table_path) + + @QtCore.Slot() + def recreate_table_contents(self): + """ + Recreate Table of Contents when users see any inconsistent with + the documents in documentation folder. + """ + create_table_of_content_file(self.docdir_path) + QtWidgets.QMessageBox.information( + self, "Link fixed!!!", "Table of Contents has been recreated.") + self.help_view.clear() + self.help_view.setSource( + QtCore.QUrl(self.contents_table_path.as_posix())) + + @QtCore.Slot() + def go_search_results(self): + """ + Bring "Search Results.md" file to view + """ + self._go_to_file(self.search_results_path) + + @QtCore.Slot(QtCore.QUrl) + def on_source_changed(self, url: QtCore.QUrl): + """ + When bringing up a page to help_view from clicking on a link on + help_view, this slot is implemented to set the current selection + on tree_view and start Search on that page. + For Search Results, it's better experience if + not perform search. + + :param url: The url emit from clicking on a link on help view + :type url: QtCore.QUrl + """ + self.tree_view.setCurrentIndex(self.file_system_model.index( + url.path(), 0)) + if const.SEARCH_RESULTS == url.fileName(): + return + try: + self.start_search_on_curr_doc(self.search_box.text(), + from_entering_search_text=False) + except AttributeError: + # Error happens because highlight_format not exist when help_view + # first open + pass + + @QtCore.Slot() + def search_backward(self): + """ + When clicking on "Search Backward" button, the cursor_index will go + back one on the cursor_list, then highlight text in that cursor. + """ + self.current_index -= 1 + if self.current_index < 0: + self.current_index = len(self.cursor_list) - 1 + + self.help_view.setTextCursor(self.cursor_list[self.current_index]) + + @QtCore.Slot() + def search_forward(self): + """ + When clicking on "Search Forward" button, the cursor_index will go + forsard one on the cursor_list, then highlight text in that cursor. + """ + self.current_index += 1 + if self.current_index > len(self.cursor_list) - 1: + self.current_index = 0 + self.help_view.setTextCursor(self.cursor_list[self.current_index]) + + @QtCore.Slot(str) + def start_search_on_curr_doc(self, search_text: str, + from_entering_search_text: bool = True): + """ + If current page is 'Search Results' the content isn't + matched with search_text anymore. So, navigate to + 'Table of Contents' if in 'Search Results' page and disable + 'Navigate to Search Results' button if the function is called + from clicking on go_to_search_results_button + Build cursor_list which is the list of cursors of all search texts' + occurrences on the current document on help_view. + Highlight the first cursor to show the starting of the search to user. + + :param search_text: text to search + :type search_text: str + :param from_entering_search_text: flag indicate if the method is called + from entering text in search box + :type from_entering_search_text: bool + """ + if from_entering_search_text: + if self.help_view.source().fileName() == const.SEARCH_RESULTS: + self.help_view.setSource( + QtCore.QUrl(self.contents_table_path.as_posix())) + self.go_to_search_results_button.setEnabled(False) + + self.help_view.setTextCursor(QtGui.QTextCursor()) + doc = self.help_view.document() + self.cursor_list = [] + cursor = QtGui.QTextCursor() + selections = [] + while 1: + cursor = doc.find(search_text, cursor) + if cursor.isNull(): + break + self.cursor_list.append(cursor) + sel = QtWidgets.QTextEdit.ExtraSelection() + sel.cursor = cursor + sel.format = self.highlight_format + selections.append(sel) + self.help_view.setExtraSelections(selections) # to highlight + self.current_index = 0 + # to roll to the current index and select text + if self.cursor_list != []: + self.help_view.setTextCursor(self.cursor_list[self.current_index]) + + @QtCore.Slot() + def search_through_all_docs(self): + """ + Create the links to all documents that contain search text. + Bring up the result to view. + Allow user to go back to the result page. + """ + self.go_to_search_results_button.setEnabled(True) + + search_text = self.search_box.text() + if search_text == '': + return + search_results_file = create_search_results_file( + self.docdir_path, search_text) + + self._go_to_file(search_results_file) + + def _go_to_file(self, filepath): + """ + + Select filename on tree_view, + + Bring file's content to help_view + """ + self.tree_view.setCurrentIndex(self.file_system_model.index( + filepath.as_posix(), 0, 0)) + self.tree_view.update() + self.help_view.setSource(QtCore.QUrl(filepath.as_posix())) + + +def main(): + import platform + import os + # Enable Layer-backing for MacOs version >= 11 + # Only needed if using the pyside2 library with version>=5.15. + # Layer-backing is always enabled in pyside6. + os_name, version, *_ = platform.platform().split('-') + # if os_name == 'macOS' and version >= '11': + # mac OSX 11.6 appear to be 10.16 when read with python and still required + # this environment variable + if os_name == 'macOS': + os.environ['QT_MAC_WANTS_LAYER'] = '1' + + app = QtWidgets.QApplication(sys.argv) + + wnd = HelpBrowser(home_path='../../') + wnd.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() diff --git a/sohstationviewer/view/main_window.py b/sohstationviewer/view/main_window.py index a49e5114a56fe37f49e2324a93dfde4d757ebafe..3d083f8b8ab2a69ae9a6727536105b25f0c9b89d 100755 --- a/sohstationviewer/view/main_window.py +++ b/sohstationviewer/view/main_window.py @@ -1,30 +1,33 @@ -import pathlib import os -from pathlib import Path -from datetime import datetime +import pathlib +import shutil from copy import deepcopy -from PySide2 import QtCore, QtWidgets - -from sohstationviewer.view.ui.main_ui import UIMainWindow -from sohstationviewer.view.calendar.calendar_dialog import ( - CalendarDialog) -from sohstationviewer.view.file_list_widget import FileListItem -from sohstationviewer.view.plotting.waveform_dialog import WaveformDialog -from sohstationviewer.view.plotting.time_power_squared_dialog import ( - TimePowerSquaredDialog) +from datetime import datetime +from pathlib import Path +from PySide2 import QtCore, QtWidgets, QtGui +from sohstationviewer.conf.constants import TM_FORMAT +from sohstationviewer.controller.processing import detectDataType +from sohstationviewer.controller.util import displayTrackingInfo +from sohstationviewer.database.process_db import execute_db_dict, execute_db +from sohstationviewer.model.data_loader import DataLoader +from sohstationviewer.model.data_type_model import DataTypeModel + +from sohstationviewer.view.calendar.calendar_dialog import CalendarDialog +from sohstationviewer.view.channel_prefer_dialog import ChannelPreferDialog +from sohstationviewer.view.db_config.channel_dialog import ChannelDialog from sohstationviewer.view.db_config.data_type_dialog import DataTypeDialog from sohstationviewer.view.db_config.param_dialog import ParamDialog -from sohstationviewer.view.db_config.channel_dialog import ChannelDialog from sohstationviewer.view.db_config.plot_type_dialog import PlotTypeDialog -from sohstationviewer.view.channel_prefer_dialog import ( - ChannelPreferDialog) - -from sohstationviewer.controller.processing import loadData, detectDataType - -from sohstationviewer.database.proccessDB import executeDB_dict - -from sohstationviewer.conf.constants import TM_FORMAT +from sohstationviewer.view.file_list_widget import FileListItem +from sohstationviewer.view.plotting.time_power_squared_dialog import ( + TimePowerSquaredDialog) +from sohstationviewer.view.plotting.waveform_dialog import WaveformDialog +from sohstationviewer.view.search_message.search_message_dialog import ( + SearchMessageDialog) +from sohstationviewer.view.help_view import HelpBrowser +from sohstationviewer.view.ui.main_ui import UIMainWindow +from sohstationviewer.view.util.enums import LogType class MainWindow(QtWidgets.QMainWindow, UIMainWindow): @@ -42,6 +45,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): data_type: str - type of data set """ self.data_type = 'Unknown' + + self.data_loader = DataLoader() + """ req_soh_chans: [str,] - list of State-Of-Health channels to read data from. For Reftek, the list of channels is fixed => may not need @@ -107,6 +113,19 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): waveform channels """ self.tps_dlg = TimePowerSquaredDialog(self) + """ + help_browser: HelpBrowser - Display help documents with searching + feature. + """ + self.help_browser = HelpBrowser() + """ + search_message_dialog: SearchMessageDialog - Display log, soh message + with searching feature. + """ + self.search_message_dialog = SearchMessageDialog() + + self.pull_current_directory_from_db() + self.delete_old_temp_data_folder() @QtCore.Slot() def open_data_type(self): @@ -148,13 +167,21 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): calendar = CalendarDialog(self) calendar.show() + @QtCore.Slot() + def open_help_browser(self): + """ + Open Help Dialog to view and search help documents + """ + self.help_browser.show() + self.help_browser.raise_() + @QtCore.Slot() def open_channel_preferences(self): """ Open a dialog to view, select, add, edit, scan for preferred channels list. """ - dir_names = [os.path.join(self.cwd_line_edit.text(), item.text()) + dir_names = [os.path.join(self.curr_dir_line_edit.text(), item.text()) for item in self.open_files_list.selectedItems()] if dir_names == []: msg = "No directories has been selected." @@ -209,11 +236,11 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): """ Constructs a QFileDialog to select a new working directory from which the user can load data. The starting directory is taken from - cwdLineEdit. + curr_dir_line_edit. """ fd = QtWidgets.QFileDialog(self) fd.setFileMode(QtWidgets.QFileDialog.Directory) - fd.setDirectory(self.cwd_line_edit.text()) + fd.setDirectory(self.curr_dir_line_edit.text()) fd.exec_() new_path = fd.selectedFiles()[0] self.set_current_directory(new_path) @@ -233,8 +260,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): if not self.all_soh_chan_check_box.isChecked() else []) - self.dir_names = [Path(self.cwd_line_edit.text()).joinpath(item.text()) - for item in self.open_files_list.selectedItems()] + self.dir_names = [ + Path(self.curr_dir_line_edit.text()).joinpath(item.text()) + for item in self.open_files_list.selectedItems()] if self.dir_names == []: msg = "No directories has been selected." QtWidgets.QMessageBox.warning(self, "Select directory", msg) @@ -277,13 +305,39 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): end_tm_str = self.time_to_date_edit.date().toString(QtCore.Qt.ISODate) self.start_tm = datetime.strptime(start_tm_str, TM_FORMAT).timestamp() self.end_tm = datetime.strptime(end_tm_str, TM_FORMAT).timestamp() - self.data_object = loadData(self.data_type, - self.tracking_info_text_browser, - self.dir_names, - reqWFChans=self.req_wf_chans, - reqSOHChans=self.req_soh_chans, - readStart=self.start_tm, - readEnd=self.end_tm) + + self.data_loader.init_loader(self.data_type, + self.tracking_info_text_browser, + self.dir_names, + req_wf_chans=self.req_wf_chans, + req_soh_chans=self.req_soh_chans, + read_start=self.start_tm, + read_end=self.end_tm) + self.data_loader.worker.finished.connect(self.plot_data) + self.data_loader.load_data() + + @QtCore.Slot() + def stop_load_data(self): + # TODO: find a way to stop the data loader without a long wait. + """ + Request the data loader thread to stop. The thread will stop at the + earliest possible point, meaning that the wait is variable and can be + very long. + """ + if self.data_loader.running: + self.data_loader.thread.requestInterruption() + displayTrackingInfo(self.tracking_info_text_browser, + 'Stopping data loading...', + LogType.INFO) + + @QtCore.Slot() + def plot_data(self, data_obj: DataTypeModel): + """ + Process the loaded data and pass control to the plotter. + + :param data_obj: the data object that contains the loaded data. + """ + self.data_object = data_obj self.replot_loaded_data() @QtCore.Slot() @@ -346,20 +400,33 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.tps_dlg.plotting_widget.set_peer_plotting_widgets( peer_plotting_widgets) + processing_log = do.processingLog + self.plotting_widget.processing_log + self.search_message_dialog.setup_logview( + sel_key, do.logData, processing_log) + self.search_message_dialog.show() + def set_current_directory(self, path=''): """ - Set all directories under current directory to self.open_files_list + Update currentDirectory with path in DB table PersistentData. + Set all directories under current directory to self.open_files_list. + :param path: str - absolute path to current directory """ - # Remove entries when cwd changes + # Remove entries when current directory changed self.open_files_list.clear() - # Signal cwd changed, and gather list of files in new cwd + # Signal current_directory_changed, and gather list of files in new + # current directory self.current_directory_changed.emit(path) - for dent in pathlib.Path(path).iterdir(): - if not dent.is_dir() or dent.name.startswith('.'): - continue + execute_db(f'UPDATE PersistentData SET FieldValue="{path}" WHERE ' + 'FieldName="currentDirectory"') + try: + for dent in pathlib.Path(path).iterdir(): + if not dent.is_dir() or dent.name.startswith('.'): + continue - self.open_files_list.addItem(FileListItem(dent)) + self.open_files_list.addItem(FileListItem(dent)) + except FileNotFoundError: + self.set_current_directory() def get_channel_prefer(self): """ @@ -369,8 +436,8 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.ids_name = '' self.ids = [] self.data_type = 'Unknown' - rows = executeDB_dict('SELECT name, IDs, dataType FROM ChannelPrefer ' - 'WHERE current=1') + rows = execute_db_dict('SELECT name, IDs, dataType FROM ChannelPrefer ' + 'WHERE current=1') if len(rows) > 0: self.ids_name = rows[0]['name'] self.ids = [t.strip() for t in rows[0]['IDs'].split(',')] @@ -378,9 +445,48 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): def resizeEvent(self, event): """ - When MainWindow is resized, its plotting_widget need to initialize + OVERRIDE Qt method. + When main_window is resized, its plotting_widget need to initialize its size to fit the viewport. :param event: QResizeEvent - resize event """ self.plotting_widget.init_size() + + def pull_current_directory_from_db(self): + """ + Set current directory with info saved in DB + """ + rows = execute_db_dict( + 'SELECT FieldName, FieldValue FROM PersistentData ' + 'WHERE FieldName="currentDirectory"') + if len(rows) > 0 and rows[0]['FieldValue']: + self.set_current_directory(rows[0]['FieldValue']) + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + """ + Cleans up when the user exits the program. Currently only clean up + running data loaders. + + :param event: parameter of method being overridden + """ + displayTrackingInfo(self.tracking_info_text_browser, 'Cleaning up...', + 'info') + if self.data_loader.running: + self.data_loader.thread.requestInterruption() + self.data_loader.thread.quit() + self.data_loader.thread.wait() + + def delete_old_temp_data_folder(self): + rows = execute_db( + 'SELECT FieldValue FROM PersistentData ' + 'WHERE FieldName="tempDataDirectory"') + temp_data_folder = rows[0][0] + try: + shutil.rmtree(temp_data_folder) + execute_db( + f'UPDATE PersistentData SET FieldValue="{None}" WHERE' + f' FieldName="tempDataDirectory"' + ) + except (FileNotFoundError, TypeError): + pass diff --git a/sohstationviewer/view/mainwindow.py b/sohstationviewer/view/mainwindow.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/sohstationviewer/view/param_dialog.py b/sohstationviewer/view/param_dialog.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py b/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py index b72c95e06d6b04f455f4f1512536a5b8c081e15f..537df02597f9f9f1565b3504c6dadc8979121d93 100644 --- a/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py +++ b/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py @@ -33,6 +33,8 @@ class PlottingAxes: self.fig = pl.Figure(facecolor='white', figsize=(50, 100)) self.fig.canvas.mpl_connect('button_press_event', parent.on_button_press_event) + self.fig.canvas.mpl_connect('pick_event', + parent.on_pick_event) """ canvas: matplotlib.backends.backend_qt5agg.FigureCanvasQTAgg - the diff --git a/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py b/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py index 364ddd4ad2bc139da4beb0576104ea96b47e5579..701ce9385235a1ce074ac082570da3b708b8a82b 100755 --- a/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py +++ b/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py @@ -2,7 +2,7 @@ Class of which object is used to plot data """ import numpy as np - +from matplotlib import pyplot as pl from PySide2 import QtCore, QtWidgets from sohstationviewer.conf import constants @@ -12,6 +12,9 @@ from sohstationviewer.view.plotting.plotting_widget.plotting_axes import ( PlottingAxes) from sohstationviewer.view.plotting.plotting_widget.plotting import Plotting +from sohstationviewer.controller.plottingData import formatTime +from sohstationviewer.controller.util import displayTrackingInfo + class PlottingWidget(QtWidgets.QScrollArea): """ @@ -258,6 +261,49 @@ class PlottingWidget(QtWidgets.QScrollArea): self.zoom_marker1.set_visible(False) self.zoom_marker2.set_visible(False) + def on_pick_event(self, event): + """ + When click mouse on a clickable data point (dot with picker=True in + Plotting), + + Point's info will be displayed in tracking_box + + If the chan_data has key 'logIdx', raise the Search Messages dialog, + focus SOH tab, roll to the corresponding line. + """ + artist = event.artist + ax = artist.axes + chan_id = ax.chan + if isinstance(artist, pl.Line2D): + chan_data = self.plotting_data1[chan_id] + # list of x values of the plot + x_list = artist.get_xdata() + # index of the clicked point on the plot + click_plot_index = event.ind[0] + # time value of the clicked point + clicked_time = x_list[click_plot_index] + # indexes of the clicked time in data (one value only) + clicked_indexes = np.where(chan_data['times'] == clicked_time) + """ + clicked_indexes and click_plot_index can be different if there + are different plots for a channel. + """ + clicked_data = chan_data['data'][clicked_indexes][0] + + if hasattr(ax, 'unit_bw'): + clicked_data = ax.unit_bw.format(clicked_data) + formatted_clicked_time = formatTime( + clicked_time, self.date_mode, 'HH:MM:SS') + info_str = (f"<pre>Channel: {chan_id} " + f"Point:{click_plot_index + 1} " + f"Time: {formatted_clicked_time} " + f"Value: {clicked_data}</pre>") + displayTrackingInfo(self.tracking_box, info_str) + + if 'logIdx' in chan_data.keys(): + self.parent.search_message_dialog.show() + clicked_log_idx = chan_data['logIdx'][clicked_indexes][0] + self.parent.search_message_dialog. \ + show_log_entry_from_data_index(clicked_log_idx) + def on_button_press_event(self, event): """ When click mouse on the current plottingWidget, SOHView will loop diff --git a/sohstationviewer/view/plotting/state_of_health_widget.py b/sohstationviewer/view/plotting/state_of_health_widget.py index 0d6af0a0ff5bdbf26cfaecaacc9c7f0489ef912e..0b4afecc04f147b6f174572660b9225c0164ee05 100644 --- a/sohstationviewer/view/plotting/state_of_health_widget.py +++ b/sohstationviewer/view/plotting/state_of_health_widget.py @@ -1,5 +1,5 @@ """ -Drawing State-Of-Health channels +Drawing State-Of-Health channels and mass position """ from sohstationviewer.view.util.plot_func_names import plot_functions from sohstationviewer.view.plotting.plotting_widget import plotting_widget @@ -10,10 +10,12 @@ from sohstationviewer.controller.util import ( from sohstationviewer.conf import constants -from sohstationviewer.database import extractData +from sohstationviewer.database import extract_data from sohstationviewer.model.handling_data import trim_downsample_SOHChan +from sohstationviewer.view.util.enums import LogType + class SOHWidget(plotting_widget.PlottingWidget): def plot_channels(self, start_tm, end_tm, key, data_time, @@ -62,18 +64,19 @@ class SOHWidget(plotting_widget.PlottingWidget): if len(not_found_chan) > 0: msg = (f"The following channels is in Channel Preferences but " f"not in the given data: {not_found_chan}") - self.processing_log.append((msg, 'warning')) + self.processing_log.append((msg, LogType.WARNING)) for chan_id in self.plotting_data1: - chan_db_info = extractData.getChanPlotInfo(chan_id, - self.parent.data_type) + chan_db_info = extract_data.get_chan_plot_info( + chan_id, self.parent.data_type + ) if chan_db_info['height'] == 0: # not draw continue if chan_db_info['channel'] == 'DEFAULT': msg = (f"Channel {chan_id}'s " f"definition can't be found database.") - displayTrackingInfo(self.tracking_box, msg, 'warning') + displayTrackingInfo(self.tracking_box, msg, LogType.WARNING) if chan_db_info['plotType'] == '': continue @@ -82,7 +85,7 @@ class SOHWidget(plotting_widget.PlottingWidget): self.plotting_data1, True) for chan_id in self.plotting_data2: - chan_db_info = extractData.getChanPlotInfo( + chan_db_info = extract_data.get_chan_plot_info( chan_id, self.parent.data_type) self.plotting_data2[chan_id]['chan_db_info'] = chan_db_info self.get_zoom_data(self.plotting_data2[chan_id], chan_id, True) diff --git a/sohstationviewer/view/plotting/time_power_squared_dialog.py b/sohstationviewer/view/plotting/time_power_squared_dialog.py index 1cbcb5c42045d8cb697a5365cfe07d907c97d496..8cc2e383e4e3de736d0c3308bc4828cb950d2dda 100755 --- a/sohstationviewer/view/plotting/time_power_squared_dialog.py +++ b/sohstationviewer/view/plotting/time_power_squared_dialog.py @@ -1,4 +1,4 @@ -# UI and connectSignals for MainWindow +# Display time-power-squared values for waveform data from math import sqrt import numpy as np @@ -16,8 +16,8 @@ from sohstationviewer.controller.util import ( from sohstationviewer.model.handling_data import ( get_trimTPSData, get_eachDay5MinList, findTPSTm) -from sohstationviewer.database.extractData import ( - getColorDef, getColorRanges, getChanLabel) +from sohstationviewer.database.extract_data import ( + get_color_def, get_color_ranges, get_chan_label) from sohstationviewer.conf import constants as const @@ -57,8 +57,6 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): self.tps_t = 0 super().__init__(*args, **kwarg) - self.plotting_axes.fig.canvas.mpl_connect( - 'pick_event', self.on_pick_event) def plot_channels(self, start_tm=None, end_tm=None, key=None, data_time=None, waveform_data=None): @@ -109,9 +107,19 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): def get_plot_data(self, c_data, chan_id): """ - Trim data to minx, max_x and calculate time-power-square for each 5 - minute into c_data['tps_data'] then draw each 5 minute with the - color corresponding to value + TPS is plotted in lines of small rectangular, so called bars. + Each line is a day so - y value is the order of days + Each bar is data represent for 5 minutes so x value is the order of + five minute in a day + If there is no data in a portion of a day, the bars in the portion + will have grey color. + For the five minutes that have data, the color of the bars will be + based on mapping between tps value of the five minutes against + the selected color range. + + This function trim data to minx, max_x and calculate time-power-square + for each 5 minute into c_data['tps_data'] then draw each 5 minute + with the color corresponding to value. Create ruler, zoom_marker1, zoom_marker2 for the channel. :param c_data: dict - data of the channel which includes down-sampled @@ -130,7 +138,7 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): ax = self.create_axes(self.plotting_bot, plot_h) ax.text( -0.1, 1.2, - f"{getChanLabel(chan_id)} {c_data['samplerate']}", + f"{get_chan_label(chan_id)} {c_data['samplerate']}", horizontalalignment='left', verticalalignment='top', rotation='horizontal', @@ -159,6 +167,7 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): square_counts = self.parent.sel_square_counts # square counts range color_codes = self.parent.color_def # colordef + # --------------------------- PLOT TPS -----------------------------# for dayIdx, y in enumerate(c_data['tps_data']): # not draw data out of day range color_set = self.get_color_set(y, square_counts, color_codes) @@ -268,28 +277,34 @@ class TimePowerSquaredWidget(plotting_widget.PlottingWidget): xdata = event.mouseevent.xdata if xdata is None: return - xdata = round(xdata) + xdata = round(xdata) # x value on the plot # when click on outside xrange that close to edge, adjust to edge if xdata in [-2, -1]: xdata = 0 if xdata in [288, 289]: xdata = 287 - ydata = round(event.mouseevent.ydata) - - y_idx = - ydata - x_idx = xdata - # identify time for rulers on other plotting widget - self.tps_t = self.each_day5_min_list[y_idx, x_idx] - format_t = formatTime(self.tps_t, self.date_mode, 'HH:MM:SS') - info_str += f"{format_t}:" - for chan_id in self.plotting_data1: - c_data = self.plotting_data1[chan_id] - data = c_data['tps_data'][y_idx, x_idx] - info_str += (f" {chan_id}:" - f"{add_thousand_separator_to_int(sqrt(data))}") - info_str += " (counts)" - displayTrackingInfo(self.tracking_box, info_str) - self.draw() + ydata = round(event.mouseevent.ydata) # y value on the plot + + # refer to description in get_plot_data to understand x,y vs + # day_index, five_min_index + day_index = - ydata + five_min_index = xdata + try: + # identify time for rulers on other plotting widget + self.tps_t = self.each_day5_min_list[day_index, five_min_index] + format_t = formatTime(self.tps_t, self.date_mode, 'HH:MM:SS') + info_str += f"<pre>{format_t}:" + for chan_id in self.plotting_data1: + c_data = self.plotting_data1[chan_id] + data = c_data['tps_data'][day_index, five_min_index] + info_str += f" {chan_id}:{fmti(sqrt(data))}" + info_str += " (counts)</pre>" + displayTrackingInfo(self.tracking_box, info_str) + self.draw() + except IndexError: + # exclude the extra points added to the 2 sides of x axis to + # show the entire highlight box + pass def on_ctrl_cmd_click(self, xdata): """ @@ -398,7 +413,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): color_def: [str,] - list of color codes in order of values to be displayed """ - self.color_def = getColorDef() + self.color_def = get_color_def() """ sel_square_counts: [int,] - selected time-power-square ranges """ @@ -416,7 +431,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): """ (self.color_ranges, self.all_square_counts, - self.color_label) = getColorRanges() + self.color_label) = get_color_ranges() """ color_range_choice: QComboBox - dropdown box for user to choose a @@ -449,6 +464,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): def resizeEvent(self, event): """ + OVERRIDE Qt method. When TimePowerDialog is resized, its plotting_widget need to initialize its size to fit the viewport. diff --git a/sohstationviewer/view/plotting/waveform_dialog.py b/sohstationviewer/view/plotting/waveform_dialog.py index 2893a16f6e035454ac6084ae8879a13f1335303d..75fb9343af8c19f297762047ff3c567ef258fbf5 100755 --- a/sohstationviewer/view/plotting/waveform_dialog.py +++ b/sohstationviewer/view/plotting/waveform_dialog.py @@ -10,7 +10,7 @@ from sohstationviewer.model.handling_data import trim_downsample_WFChan from sohstationviewer.controller.plottingData import getTitle from sohstationviewer.controller.util import apply_convert_factor -from sohstationviewer.database import extractData +from sohstationviewer.database import extract_data from sohstationviewer.conf import constants as const @@ -56,13 +56,13 @@ class WaveformWidget(plotting_widget.PlottingWidget): self.plotting_axes.set_title(title) for chan_id in self.plotting_data1: - chan_db_info = extractData.getWFPlotInfo(chan_id) + chan_db_info = extract_data.get_wf_plot_info(chan_id) if chan_db_info['plotType'] == '': continue self.plotting_data1[chan_id]['chan_db_info'] = chan_db_info self.get_zoom_data(self.plotting_data1[chan_id], chan_id, True) for chan_id in self.plotting_data2: - chan_db_info = extractData.getChanPlotInfo( + chan_db_info = extract_data.get_chan_plot_info( chan_id, self.parent.data_type) self.plotting_data2[chan_id]['chan_db_info'] = chan_db_info self.get_zoom_data(self.plotting_data2[chan_id], chan_id, True) @@ -193,6 +193,7 @@ class WaveformDialog(QtWidgets.QWidget): def resizeEvent(self, event): """ + OVERRIDE Qt method. When WaveformDialog is resized, its plotting_widget need to initialize its size to fit the viewport. diff --git a/sohstationviewer/view/search_message/__init__.py b/sohstationviewer/view/search_message/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/sohstationviewer/view/search_message/highlight_delegate.py b/sohstationviewer/view/search_message/highlight_delegate.py new file mode 100644 index 0000000000000000000000000000000000000000..cda425ca1c27191f8c0376f048fc36c06ce8d2ef --- /dev/null +++ b/sohstationviewer/view/search_message/highlight_delegate.py @@ -0,0 +1,120 @@ +""" +Credit: https://stackoverflow.com/questions/53353450/how-to-highlight-a-words-in-qtablewidget-from-a-searchlist # noqa +""" +from typing import List, Dict +from PySide2 import QtCore, QtGui, QtWidgets +from PySide2.QtGui import QPen, QTextCursor, QTextCharFormat, QColor + + +class HighlightDelegate(QtWidgets.QStyledItemDelegate): + """ + Help with highlighting search text in SOH Message tables. + """ + def __init__(self, parent=None, display_color=None): + super(HighlightDelegate, self).__init__(parent) + """ + doc: container to format soh table's item + """ + self.doc: QtGui.QTextDocument = QtGui.QTextDocument(self) + """ + filters: list of texts to apply text highlighted + """ + self.filters: List[str] = [] + """ + current_row: the row index of the item to apply border highlighted + """ + self.current_row: int = -1 + """ + display_color: {type: color,} - color map for setting highlight color + """ + self.display_color: Dict[str, str] = display_color + + def paint(self, painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex): + """ + Custom rendering which is a reimplementation of the abstract function + paint() + + Parameters + ---- + painter: painter of the table + option: the item widget + index: location of the item on the table widget + """ + painter.save() + options = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(options, index) + self.doc.setPlainText(options.text) + if index.column() != 0: + # not apply highlight for column 0 + self.apply_highlight(painter, option, index.row()) + options.text = "" + style = QtWidgets.QApplication.style() if options.widget is None \ + else options.widget.style() + style.drawControl(QtWidgets.QStyle.CE_ItemViewItem, options, painter) + + context = QtGui.QAbstractTextDocumentLayout.PaintContext() + if option.state & QtWidgets.QStyle.State_Selected: + role = QtGui.QPalette.HighlightedText + else: + role = QtGui.QPalette.Text + context.palette.setColor( + QtGui.QPalette.Text, + option.palette.color(QtGui.QPalette.Active, role) + ) + + text_rect = style.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, options) + + if index.column() != 0: + text_rect.adjust(5, 0, 0, 0) + + the_constant = 4 + margin = (option.rect.height() - options.fontMetrics.height()) // 2 + margin = margin - the_constant + text_rect.setTop(text_rect.top() + margin) + + painter.translate(text_rect.topLeft()) + painter.setClipRect(text_rect.translated(-text_rect.topLeft())) + self.doc.documentLayout().draw(painter, context) + + painter.restore() + + def apply_highlight(self, painter, option, row): + """ + Look through doc to find the word matched with all text in filters + to highlight it. + """ + cursor = QTextCursor(self.doc) + cursor.beginEditBlock() + fmt = QTextCharFormat() + fmt.setForeground(QColor(self.display_color['highlight'])) + fmt.setFontWeight(700) + done_task = False + for f in self.filters: + highlight_cursor = QTextCursor(self.doc) + while (not highlight_cursor.isNull() and + not highlight_cursor.atEnd()): + highlight_cursor = self.doc.find(f, highlight_cursor) + if not highlight_cursor.isNull(): + highlight_cursor.mergeCharFormat(fmt) + if not done_task and row == self.current_row: + painter.setPen( + QPen(QColor(self.display_color['highlight']), 3)) + painter.drawRect(option.rect) + done_task = True + cursor.endEditBlock() + + def set_filters(self, filters: List[str]): + """ + Set texts to highlight + + Parameters + ---- + filters: list of texts + """ + self.filters = filters + + def set_current_row(self, current_row): + self.current_row = current_row diff --git a/sohstationviewer/view/search_message/search_message_dialog.py b/sohstationviewer/view/search_message/search_message_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..2766d4064083a53d1def28acda7ea9c7e9d32ba7 --- /dev/null +++ b/sohstationviewer/view/search_message/search_message_dialog.py @@ -0,0 +1,710 @@ +import sys +import os +from pathlib import PosixPath, Path +from typing import Dict, List, Tuple, Callable, Union, Optional + +from PySide2 import QtGui, QtCore, QtWidgets +from PySide2.QtWidgets import QStyle + +from sohstationviewer.view.search_message.highlight_delegate import ( + HighlightDelegate) +from sohstationviewer.view.util.functions import ( + get_soh_messages_for_view, log_str) +from sohstationviewer.view.util.color import set_color_mode +from sohstationviewer.view.util.enums import LogType + + +class SearchMessageDialog(QtWidgets.QWidget): + """ + GUI: + + "Search SOH Lines" tab: to list all lines that includes searched text + with its channel + + "Processing Logs" tab: to list all processing logs with its log type + and color of each line depends on its log type. + + Each SOH LOG channel has its own tab + For the last two types of tabs, user can use the buttons in the tool bar + to process the following tasks: + + The 1st button: The current table rolls back to the current selected + line. + + Type a searched text, the searched text will be highlighted through + the table and the current table scrolls to the first line that + contains the searched text. + + The 2nd button: The current table rolls to the next line that + contains the searched text. + + The 3rd button: The current table rolls to the previous line that + contains the searched text. + + The 4th button: Save the content of the table to a text file + Interaction: When user click on a clickable data point on a SOH channel + of RT130, SOH tab will be focus and the line corresponding to the + data point will be highlighted and rolled to view. + + Attributes + ---- + SCREEN_SIZE_SCALAR_X : float + Specifies how to scale the width of the window, in relation to total + screen size. + SCREEN_SIZE_SCALAR_Y : float + Specifies how to scale the height of the window, in relation to total + screen size. + """ + SCREEN_SIZE_SCALAR_X = 0.40 + SCREEN_SIZE_SCALAR_Y = 0.50 + + def __init__(self, home_path: str = '.'): + super().__init__() + # Get screen dimensions + geom = QtGui.QGuiApplication.screens()[0].availableGeometry() + self.setGeometry(10, 10, + geom.size().width() * self.SCREEN_SIZE_SCALAR_X, + geom.size().height() * self.SCREEN_SIZE_SCALAR_Y + ) + self.setWindowTitle("Search Messages") + + """ + images_path: path to the folder containing images + """ + self.images_path: PosixPath = Path( + home_path).resolve().joinpath('images') + + # -------------------- PRE-DEFINE WIDGETS ---------------------------- + """ + search_box: QLineEdit - text box to enter a string to search along the + current table. + """ + self.search_box: QtWidgets.QLineEdit = None + """ + tab_widget: QTabWidget - widget to set up tabs for tables. + All soh tables in this widget will be deleted before setting up new + tables for new data reading because channels may be different for + each data set. + """ + self.tab_widget: QtWidgets.QTabWidget = None + """ + info_display: QTextEdit - text box to show info of selected line + """ + self.info_display: QtWidgets.QTextEdit = None + """ + filter_soh_lines_table: QTableWidget - table to display all lines that + contains search text + """ + self.filter_soh_lines_table: QtWidgets.QTableWidget = None + """ + processing_log_table: QTableWidget - table to display processing log + """ + self.processing_log_table: QtWidgets.QTableWidget = None + """ + current_table: QTableWidget - The active table (widget of a tab for + this dialog) + """ + self.current_table: QtWidgets.QTableWidget = None + """ + selected_item: QTableWidgetItem - The last selected cell widget in a + table. There is only one selected_item over all tables + """ + self.selected_item: QtWidgets.QTableWidgetItem = None + """ + active_table_for_new_dataset: QTableWidget - table to be set active + when a new dataset is load + """ + self.active_table_for_new_dataset: QtWidgets.QTableWidget = None + """ + save_button: QToolButton - Button to save log messages on current + tab in a text file + """ + self.save_button: QtWidgets.QToolButton = None + """ + delegate: HighlightDelegate - delegate that help format current table + items that contain search_text + """ + self.delegate: HighlightDelegate = None + # --------------------- PRE-DEFINED OTHER ATTRIBUTES ---------------- + """ + search_rowidx: int - The last row index found during searching + """ + self.search_rowidx: int = 0 + """ + search_text: str - text to search for + """ + self.search_text: str = "" + """ + display_color: {type: color,} - color map for setting background color + for different types of processing log or + "state of health"/"Events:" labels + """ + self.display_color: Dict[str, str] = set_color_mode("W") + """ + processing_logs: [(message, type),] - record of processing progress + """ + self.processing_logs: List[(str, str)] = [] + """ + soh_dict: {chan_id: [str,]} - dict of list of soh message lines + for each soh channel + """ + self.soh_dict: Dict[str, List[str]] = {} + """ + soh_tables_dict: {chan_id: QTableWidget,} - dict of table for + each soh channel + """ + self.soh_tables_dict: Dict[str, QtWidgets.QTableWidget] = {} + + self.setup_ui() + + def setup_ui(self): + """ + Set up GUI includes: + + A search box to enter words for searching + + A toolbar allow user to go back to last selected, search next, + search previous, save file + + Tabs each of which is a table to show message for processing + log or a state-of-health channel's message + + A text box to show selected line's text + """ + # ------------------------- search box ------------------------ + self.search_box = QtWidgets.QLineEdit() + self.search_box.setTextMargins(7, 7, 7, 7) + self.search_box.setFixedHeight(40) + self.search_box.setClearButtonEnabled(True) + self.search_box.setPlaceholderText("Enter a string to search") + self.search_box.textChanged.connect(self.on_search_text_changed) + # -------------- Create tool buttons ------------------------- + + navigation = QtWidgets.QToolBar('Navigation') + self.add_nav_button( + navigation, + QtGui.QIcon( + self.images_path.joinpath('to_selected.png').as_posix()), + 'Scroll to selected line of the current table', + self.scroll_to_selected) + self.add_nav_button( + navigation, + self.style().standardIcon(QStyle.SP_ArrowBack), + 'Scroll to previous search text', + self.scroll_to_previous_search_text) + self.add_nav_button( + navigation, + self.style().standardIcon(QStyle.SP_ArrowForward), + 'Scroll to next search text', + self.scroll_to_next_search_text) + self.save_button = self.add_nav_button( + navigation, + self.style().standardIcon(QStyle.SP_DialogSaveButton), + 'Save messages in current tab to text file', + self.save_messages) + + # --------- display info message ------------------ + self.info_display = QtWidgets.QLineEdit() + self.info_display.setTextMargins(7, 7, 7, 7) + self.info_display.setFixedHeight(40) + self.info_display.setReadOnly(True) + + # --------- set layout ------------------------------------- + main_layout = QtWidgets.QVBoxLayout() + self.setLayout(main_layout) + main_layout.setSpacing(10) + main_layout.setContentsMargins(5, 5, 5, 5) + + main_layout.addWidget(self.search_box) + + main_layout.addWidget(navigation) + + tab_widget_layout = QtWidgets.QVBoxLayout() + main_layout.addLayout(tab_widget_layout) + + self.tab_widget = QtWidgets.QTabWidget() + tab_widget_layout.addWidget(self.tab_widget) + self.tab_widget.currentChanged.connect(self.set_current_tab) + + self.filter_soh_lines_table = self.create_table(0, 2) + self.tab_widget.addTab( + self.filter_soh_lines_table, 'Filter SOH Lines') + self.processing_log_table = self.create_table(0, 2, [0]) + self.tab_widget.addTab( + self.processing_log_table, 'Processing Logs') + main_layout.addWidget(self.info_display) + + def setup_logview( + self, key: Union[str, Tuple[str, str]], + soh_messages: Dict[str, Union[List[str], Dict[str, List[str]]]], + processing_logs: List[Tuple[str, str]]): + """ + When a dataset is loaded, + + processing_logs need to be refilled in processing_log_table + + Tabs need to be recreated for soh channels since they are + different for each dataset + + If there is any info in processing_log_table, it will be + activated. Otherwise, the first soh table will be activated + + Parameters + ---- + key: str or (str, str): key to identify the data set + soh_messages: {'TEXT': [str,], key:{chan_id: [str,],},} - info from log + channels, soh messages, text file + processing_logs: [(message, type),] - record of processing progress + """ + for i in range(self.tab_widget.count() - 1, 1, -1): + # delete all soh tabs in self.tab_widget + widget = self.tab_widget.widget(i) + self.tab_widget.removeTab(i) + widget.setParent(None) + + self.soh_tables_dict = {} + + self.add_processing_log_table(processing_logs) + self.add_soh_tables(key, soh_messages) + self.tab_widget.setCurrentWidget(self.active_table_for_new_dataset) + + def add_processing_log_table(self, processing_logs: List[Tuple[str, str]]): + """ + Adding info to Processing Logs table + + Parameters + ---- + processing_logs: [(message, type),] - record of processing progress + """ + + self.processing_logs = processing_logs + if processing_logs == []: + self.active_table_for_new_dataset = None + else: + self.active_table_for_new_dataset = self.processing_log_table + + self.processing_log_table.setRowCount(0) + count = 0 + processing_logs = ( + [("There are no processing logs to display.", LogType.INFO)] + if processing_logs == [] else processing_logs) + for log_line in processing_logs: + self.add_line(self.processing_log_table, text1=count, + text2=log_line[0], log_type=log_line[1]) + + def add_soh_tables( + self, key: Union[str, Tuple[str, str]], + soh_messages: Dict[str, Union[List[str], Dict[str, List[str]]]]): + """ + Adding SOH Channel tables to tab_widget and their info + + Parameters + ---- + key: str or (str, str): key to identify the data set + soh_messages: {'TEXT': [str,], key:{chan_id: [str,],},} - info from log + channels, soh messages, text file + """ + self.soh_dict = get_soh_messages_for_view(key, soh_messages) + for chan_id in self.soh_dict: + self.soh_tables_dict[chan_id] = self.create_table(0, 2, [0]) + if self.active_table_for_new_dataset is None: + self.active_table_for_new_dataset = self.soh_tables_dict[ + chan_id] + self.tab_widget.addTab(self.soh_tables_dict[chan_id], chan_id) + count = 0 + for log_line in self.soh_dict[chan_id]: + self.add_line( + self.soh_tables_dict[chan_id], text1=count, text2=log_line) + count += 1 + + def add_nav_button(self, nav: QtWidgets.QToolBar, + icon: QtGui.QIcon, + tool_tip_text: str, function: Callable[[], None])\ + -> QtWidgets.QToolButton: + """ + Add a QToolButton to Navigation QToolBar including: icon, tool tip, + function to do + + :param nav: Tool bar to add button + :type nav: QToolBar + :param icon_pic: Pix map of the picture of the button + :type icon_pic: QStyle.StandardPixMap + :param tool_tip_text: Description of what the button will do + :type tool_tip_text: str + :param function: The method that perform the button's action + :type function: method + :return: The added button + :rtype: QtWidgets.QToolButton + """ + button = QtWidgets.QToolButton() + button.setIcon(icon) + button.setToolTip(tool_tip_text) + button.clicked.connect(function) + nav.addWidget(button) + return button + + def add_line(self, table: QtWidgets.QTableWidget, + text1: Union[str, int], text2: str, + log_type: LogType = LogType.INFO): + """ + Add a line of message to a row of table and color it according to + log_type (red for error, orange for warning) or blue header of + block of soh messages + Parameters + ---- + table: QTableWidget - table to add row + text1: str/int - index of row for soh tables, log_type for + Processing Logs table, channel id for Search SOH Lines table + text2: str - content of line + log_type: LogType - type of log line + """ + count = table.rowCount() + table.setRowCount(count + 1) + + # Add data index to column 0 + it = QtWidgets.QTableWidgetItem(f'{text1}') + table.setItem(count, 0, it) + + # Add log message to column 1 + it = QtWidgets.QTableWidgetItem(f'{text2}') + if log_type is not None: + # set background color for error/warning processing log + if log_type == LogType.ERROR: + it.setBackground(QtGui.QColor(self.display_color['error'])) + elif log_type == LogType.WARNING: + it.setBackground(QtGui.QColor(self.display_color['warning'])) + + if "state of health" in text2.lower() or text2 == "Events:": + # set background color for header of a block of soh messages + it.setBackground( + QtGui.QColor(self.display_color['state_of_health'])) + it.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + table.setItem(count, 1, it) + + def create_table(self, rows: int, cols: int, hidden: List[int] = [])\ + -> QtWidgets.QTableWidget: + """ + Creates and returns a QTableWidget with the specified number + of rows and columns. + The first column should be the index of the line + The second column should be the text of the line + + Parameters + ---- + rows : int + Number of rows in the created table + cols : int + Number of columns in the created table + hidden : iterable object + An iterable containing >= 0 int's. Each integer should + correspond to the index of a column which should be hidden + from display. + + Returns + ---- + QTableWidget + The constructed QTableWidget + """ + # add 1 extra column to show scroll bar (+ 1) + table = QtWidgets.QTableWidget(rows, cols + 1) + delegate = HighlightDelegate(table, self.display_color) + table.setItemDelegate(delegate) + # Hide header cells + table.verticalHeader().setVisible(False) + table.horizontalHeader().setVisible(False) + + # Expand horizontally + table.horizontalHeader().setStretchLastSection(True) + + table.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeToContents) + + # Hide cell grid + table.setShowGrid(False) + + table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + table.itemClicked.connect(self.on_log_entry_clicked) + + for index in hidden: + table.setColumnHidden(index, True) + + return table + + def search(self, col: int = 1, direction: str = 'next', + start_search: bool = False)\ + -> Optional[Tuple[QtWidgets.QTableWidgetItem, int]]: + """ + Searches all rows of a table for search_text and returns the item that + contains search_text and its row index + + Parameters + ---- + col: int - index of the column to search text from + direction: str - next/previous: show which direction to search + start_search: bool - if this search start from typing to search box + + Returns + ---- + item: QTableWidgetItem, row: int + A QTableWidgetItem is returned if there is an item matching the + query in table, otherwise None is returned + Index of the row where search_text is found + Or None if no text found + """ + self.delegate.set_current_row(-1) + self.info_display.setText('') + self.current_table.viewport().update() + + if self.search_text == '': + return + + if direction == "next": + if not start_search: + self.search_rowidx += 1 + total_rows = self.current_table.rowCount() + search_range = range(self.search_rowidx, total_rows) + else: + search_range = range(self.search_rowidx - 2, -1, -1) + for row in search_range: + item = self.current_table.item(row, col) + if item is None: + continue + if self.search_text.lower() in item.text().lower(): + self.delegate.set_current_row(row) + self.current_table.viewport().update() + message_index = self.current_table.item(item.row(), 0).text() + message_text = self.current_table.item(item.row(), 1).text() + self.info_display.setText( + f"Line {message_index}: {message_text}") + if direction == "next": + row += 1 + return item, row + if not start_search: + opposite_direction = 'previous' if direction == 'next' else 'next' + self.info_display.setText( + f"'{self.search_text}' not found in '{direction}' direction. " + f"Try click '{opposite_direction}' instead." + ) + return + + def _show_log_message(self, item: QtWidgets.QTableWidgetItem): + """ + Showing the given item on the current table by: + + Scrolling to the given item on the current table. + + Set focus on the current table so that the item will + be highlight instead of grey out. + + Parameters + ---- + item : QTableWidgetItem + A valid QTableWidgetIem + """ + self.current_table.scrollToItem(item) + self.current_table.setFocus() + + def show_log_entry_from_data_index(self, data_index: int): + """ + This is called when clicking a clickable data point on a SOH channel + of RT130, data_index, which represent for row index in 'SOH' table, + will be given. This method will: + + set current tab to soh_table_dict['SOH'] + + make a search on soh_table_dict['SOH'] + + scroll to and highlight the row corresponding to the data point + + Parameters + ---- + data_index : int + The index (of the data point that has been loaded from disk) + to be selected and scrolled to. + """ + if 'SOH' not in self.soh_tables_dict: + return + self.tab_widget.setCurrentWidget(self.soh_tables_dict['SOH']) + self.search_text = str(data_index) + self.search_rowidx = 0 + ret = self.search(col=0, start_search=True) + if ret is None: + raise ValueError(f'Not found line: ({data_index})') + it, r = ret + self.on_log_entry_clicked(it) + self._show_log_message(it) + self.setWindowState(QtCore.Qt.WindowState.WindowActive) + self.raise_() + self.activateWindow() + + @QtCore.Slot() + def set_current_tab(self): + """ + When a tab is selected, + + Reset selected_item + + Reset search_text to text in search_box because search text. + may still keep data_index from RT130 SOH data point clicked + interaction. + + Set current_table. + + Redo the search on search_text. + + Disable save button if table is filter_soh_lines_table, or if + table is processing_log_table but have no information. + """ + self.selected_item = None + self.search_text = self.search_box.text() # reset data_index + self.current_table = self.tab_widget.currentWidget() + self.on_search_text_changed(self.search_text) + + if (self.current_table == self.filter_soh_lines_table or + (self.current_table == self.processing_log_table and + self.processing_logs == [( + "There are no processing logs to display.", LogType.INFO)] + )): + self.save_button.setEnabled(False) + else: + self.save_button.setEnabled(True) + + @QtCore.Slot() + def scroll_to_selected(self): + """ + Scroll back to the selected item on the current_table + """ + it = self.selected_item + if not it: + return + self._show_log_message(it) + + @QtCore.Slot() + def scroll_to_previous_search_text(self): + """ + Scroll to the search_text before the last search roll + """ + ret = self.search(direction="previous") + if ret is None: + return + self.selected_item, self.search_rowidx = ret + self.scroll_to_selected() + + @QtCore.Slot() + def scroll_to_next_search_text(self): + """ + Scroll to the search_text after the last search roll + """ + ret = self.search() + if ret is None: + return + self.selected_item, self.search_rowidx = ret + self.scroll_to_selected() + + @QtCore.Slot() + def on_log_entry_clicked(self, item: QtWidgets.QTableWidgetItem): + """ + Set selectRow for item on the current_table, display the info on + info_display and set selected_item = item + + Parameters + ---- + item : QTableWidgetItem + A valid QTableWidgetIem to select + """ + self.current_table.selectRow(item.row()) + + # # Display full text of message + message_index = self.current_table.item(item.row(), 0).text() + message_text = self.current_table.item(item.row(), 1).text() + self.info_display.setText(f"Line {message_index}: {message_text}") + self.selected_item = item + + @QtCore.Slot() + def on_search_text_changed(self, text: str): + """ + When text in search_box is changed, + + Set search_text + + Highlight text in all tables if it is not a search for data_index + + If the current_table is Processing Logs or a SOH channel table, + jump to the first search text. + + If the current_table is Search SOH Lines, filter all SOH lines to + display the lines contain the search_text only. + """ + self.search_text = text + self.delegate = self.current_table.itemDelegate() + # check to highlight text when searching for text in search_box but + # not highlight when searching for data_index + if self.search_box.text() == self.search_text: + self.delegate.set_filters([text]) + self.current_table.viewport().update() + + if self.current_table != self.filter_soh_lines_table: + self._jumpto_search_text_on_current_table() + else: + self._filter_lines_with_search_text_from_soh_messages() + + def _jumpto_search_text_on_current_table(self): + """ + Jump to the first search text by search forward for search_text on the + current_table from top of the table then scroll to that row. + """ + self.search_rowidx = 0 + ret = self.search(start_search=True) + if ret is None: + return + self.selected_item, self.search_rowidx = ret + self.current_table.scrollToItem(self.selected_item) + + def _filter_lines_with_search_text_from_soh_messages(self): + """ + Filter all SOH lines to display the lines contain the search_text only. + """ + self.filter_soh_lines_table.setRowCount(0) + if self.search_text == '': + return + + for chan_id in self.soh_dict: + for line in self.soh_dict[chan_id]: + if self.search_text in line: + self.add_line(self.filter_soh_lines_table, + text1=chan_id, text2=line) + + @QtCore.Slot() + def save_messages(self): + """ + Save text on the current_table to a text file. + """ + tab_index = self.tab_widget.currentIndex() + tab_name = self.tab_widget.tabText(tab_index) + default_name = os.path.join(QtCore.QDir.homePath(), f'{tab_name}.txt') + file_name = QtWidgets.QFileDialog.getSaveFileName( + self, 'Save File', default_name, "text(*.txt)")[0] + if file_name == '': + return + with open(file_name, 'w') as file: + if self.current_table == self.processing_log_table: + file.write('\n'.join(map(log_str, self.processing_logs))) + else: + file.write('\n'.join(self.soh_dict[tab_name])) + + +def main(): + import platform + import os + # Enable Layer-backing for MacOs version >= 11 + # Only needed if using the pyside2 library with version>=5.15. + # Layer-backing is always enabled in pyside6. + os_name, version, *_ = platform.platform().split('-') + # if os_name == 'macOS' and version >= '11': + # mac OSX 11.6 appear to be 10.16 when read with python and still required + # this environment variable + if os_name == 'macOS': + os.environ['QT_MAC_WANTS_LAYER'] = '1' + + app = QtWidgets.QApplication(sys.argv) + app.setStyleSheet( + "QTableView::item:selected {" + " selection-background-color: #93CAFF;}" + ) + soh_messages = { + 'TEXT': ['this is a text message\nThis is a different line'], + '0895': { + 'SOH': ["\n\n**** STATE OF HEALTH: From:2018-02-07T17:48:24.000000Z To:2018-02-07T17:48:24.000000Z" # noqa + "\n\nVCO Correction: 50.0\nTime of exception: 2018:38:17:48:24:0\nMicro sec: 0\nReception Quality: 0\nException Count: 1", # noqa + "\n\n**** STATE OF HEALTH: From:2018-02-07T17:58:35.000000Z To:2018-02-07T17:58:35.000000Z" # noqa + "\n\nVCO Correction: 55.6396484375\nTime of exception: 2018:38:17:58:35:0\nMicro sec: 0\nReception Quality: 0\nException Count: 1"]}} # noqa + + processing_logs = [ + ("info line 1", LogType.INFO), + ("warning line 1", LogType.WARNING), + ("info line 2", LogType.INFO), + ("error line 1", LogType.ERROR) + ] + + wnd = SearchMessageDialog(home_path='../../../') + wnd.setup_logview('0895', soh_messages, processing_logs) + wnd.show() + # wnd.show_log_entry_from_data_index(10) + + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() diff --git a/sohstationviewer/view/ui/main_ui.py b/sohstationviewer/view/ui/main_ui.py index 63756d70d94ee14a020a4beba25829c2eaef6f20..2e1bd1c07da186e8672eeb80efd972af4431623b 100755 --- a/sohstationviewer/view/ui/main_ui.py +++ b/sohstationviewer/view/ui/main_ui.py @@ -1,10 +1,11 @@ -# UI and connectSignals for MainWindow +# UI and connectSignals for main_window from PySide2 import QtCore, QtGui, QtWidgets from sohstationviewer.view.calendar.calendar_widget import CalendarWidget from sohstationviewer.view.plotting.state_of_health_widget import SOHWidget + from sohstationviewer.conf import constants @@ -22,7 +23,7 @@ def add_separation_line(layout): class UIMainWindow(object): def __init__(self): """ - Class that create widgets, menus and connect signals for MainWindow. + Class that create widgets, menus and connect signals for main_window. """ super().__init__() """ @@ -42,14 +43,15 @@ class UIMainWindow(object): self.tracking_info_text_browser = None # =================== top row ======================== """ - cwd_button: QPushButton - Button that helps browse to the directory for - selecting data set + curr_dir_button: QPushButton - Button that helps browse to the + directory for selecting data set """ - self.cwd_button = None + self.curr_dir_button = None """ - cwd_line_edit: QLineEdit - textbox that display the current directory + curr_dir_line_edit: QLineEdit - textbox that display the current + directory """ - self.cwd_line_edit = None + self.curr_dir_line_edit = None """ time_from_date_edit: QDateEdit - to help user select start day to read from the data set @@ -243,14 +245,19 @@ class UIMainWindow(object): self.view_plot_type_action = None # ========================= Help Menu ============================= """ - calendarAction: QAction - Open Calendar Dialog as a helpful tool + calendar_action: QAction - Open Calendar Dialog as a helpful tool """ - self.calendarAction = None + self.calendar_action = None """ about_action: QAction - Open About Dialog to give information about SOHView """ self.about_action = None + """ + doc_action: QAction - Open a display allowing user to browse to the + help documents in the folder Documentation/ + """ + self.doc_action = None def setup_ui(self, main_window): """ @@ -277,6 +284,7 @@ class UIMainWindow(object): main_layout.addWidget(self.tracking_info_text_browser) self.create_menu_bar(main_window) self.connect_signals(main_window) + self.create_shortcuts(main_window) def set_first_row(self, main_layout): """ @@ -290,13 +298,13 @@ class UIMainWindow(object): h_layout.setSpacing(8) main_layout.addLayout(h_layout) - self.cwd_button = QtWidgets.QPushButton( + self.curr_dir_button = QtWidgets.QPushButton( "Main Data Directory", self.central_widget) - h_layout.addWidget(self.cwd_button) + h_layout.addWidget(self.curr_dir_button) - self.cwd_line_edit = QtWidgets.QLineEdit( + self.curr_dir_line_edit = QtWidgets.QLineEdit( self.central_widget) - h_layout.addWidget(self.cwd_line_edit, 1) + h_layout.addWidget(self.curr_dir_line_edit, 1) h_layout.addSpacing(40) @@ -388,15 +396,21 @@ class UIMainWindow(object): search_grid.addWidget(self.replot_button, 1, 3, 1, 1) + color_tip_fmt = ('Set the background color of the plot ' + ' to {0}') background_layout = QtWidgets.QHBoxLayout() # background_layout.setContentsMargins(0, 0, 0, 0) left_layout.addLayout(background_layout) background_layout.addWidget(QtWidgets.QLabel('Background: ')) self.background_black_radio_button = QtWidgets.QRadioButton( 'B', self.central_widget) + self.background_black_radio_button.setToolTip( + color_tip_fmt.format('black')) background_layout.addWidget(self.background_black_radio_button) self.background_white_radio_button = QtWidgets.QRadioButton( 'W', self.central_widget) + self.background_white_radio_button.setToolTip( + color_tip_fmt.format('white')) background_layout.addWidget(self.background_white_radio_button) add_separation_line(left_layout) @@ -477,16 +491,25 @@ class UIMainWindow(object): submit_layout.setSpacing(5) left_layout.addLayout(submit_layout) self.read_button = QtWidgets.QPushButton('Read', self.central_widget) + self.read_button.setToolTip('Read selected files') submit_layout.addWidget(self.read_button) self.stop_button = QtWidgets.QPushButton('Stop', self.central_widget) + self.stop_button.setToolTip('Halt ongoing read') submit_layout.addWidget(self.stop_button) self.save_plot_button = QtWidgets.QPushButton( 'Save plot', self.central_widget) + self.save_plot_button.setToolTip('Save plots to disk') submit_layout.addWidget(self.save_plot_button) self.info_list_widget = QtWidgets.QListWidget(self.central_widget) left_layout.addWidget(self.info_list_widget, 1) + def create_shortcuts(self, main_window): + seq = QtGui.QKeySequence('Ctrl+F') + chdir_shortcut = QtWidgets.QShortcut(seq, main_window) + chdir_shortcut.activated.connect( + main_window.change_current_directory) + def create_menu_bar(self, main_window): """ Setting up menu bar @@ -505,6 +528,7 @@ class UIMainWindow(object): self.create_file_menu(main_window, file_menu) self.create_command_menu(main_window, command_menu) self.create_option_menu(main_window, option_menu) + self.create_database_menu(main_window, database_menu) self.create_help_menu(main_window, help_menu) @@ -603,14 +627,17 @@ class UIMainWindow(object): :param main_window: QMainWindow - main GUI for user to interact with :param menu: QMenu - Help Menu """ - self.calendarAction = QtWidgets.QAction( + self.calendar_action = QtWidgets.QAction( 'Calendar', main_window) - menu.addAction(self.calendarAction) + menu.addAction(self.calendar_action) self.about_action = QtWidgets.QAction( 'About', main_window) menu.addAction(self.about_action) + self.doc_action = QtWidgets.QAction('Documentation', main_window) + menu.addAction(self.doc_action) + def connect_signals(self, main_window): """ Connect widgets what they do @@ -657,18 +684,21 @@ class UIMainWindow(object): # Form # Help - self.calendarAction.triggered.connect(main_window.open_calendar) + self.calendar_action.triggered.connect(main_window.open_calendar) + self.doc_action.triggered.connect(self.open_help_browser) def connect_widget_signals(self, main_window): main_window.current_directory_changed.connect( - self.cwd_line_edit.setText) + self.curr_dir_line_edit.setText) + # first Row self.time_from_date_edit.setCalendarWidget(CalendarWidget(main_window)) self.time_from_date_edit.setDate(QtCore.QDate.fromString( constants.DEFAULT_START_TIME, QtCore.Qt.ISODate )) - self.cwd_button.clicked.connect(main_window.change_current_directory) + self.curr_dir_button.clicked.connect( + main_window.change_current_directory) self.time_to_date_edit.setCalendarWidget(CalendarWidget(main_window)) self.time_to_date_edit.setDate(QtCore.QDate.currentDate()) @@ -683,3 +713,4 @@ class UIMainWindow(object): self.prefer_soh_chan_button.clicked.connect( main_window.open_channel_preferences) self.read_button.clicked.connect(main_window.read_selected_files) + self.stop_button.clicked.connect(main_window.stop_load_data) diff --git a/sohstationviewer/view/util/color.py b/sohstationviewer/view/util/color.py index 216dec14c46cf0be5d11c03f02f7d1da092ef8a3..76a29a3442e6bc41132ac6b0e746508f28c40b2f 100644 --- a/sohstationviewer/view/util/color.py +++ b/sohstationviewer/view/util/color.py @@ -14,7 +14,7 @@ clr = {"B": "#000000", "C": "#00FFFF", "G": "#00FF00", "M": "#FF00FF", "E": "#DFDFDF", "A": "#8F8F8F", "K": "#3F3F3F", "U": "#0070FF", "N": "#007F00", "S": "#7F0000", "y": "#7F7F00", "u": "#ADD8E6", "s": "#FA8072", "p": "#FFB6C1", "g": "#90EE90", "r": "#EFEFEF", - "P": "#AA22FF", "b": "#0000FF"} + "P": "#AA22FF", "b": "#0000FF", "o": "#f7e5a8"} # This is just if the program wants to let the user know what the possibilities # are. @@ -24,7 +24,7 @@ clr_desc = {"B": "black", "C": "cyan", "G": "green", "M": "magenta", "N": "dark green", "S": "dark red", "y": "dark yellow", "u": "light blue", "s": "salmon", "p": "light pink", "g": "light green", "r": "very light gray", "P": "purple", - "b": "dark blue"} + "b": "dark blue", "o": "light orange"} def set_color_mode(mode): @@ -41,6 +41,10 @@ def set_color_mode(mode): display_color["time_ruler"] = clr["Y"] display_color["zoom_marker"] = clr["O"] display_color["warning"] = clr["O"] + display_color["error"] = clr["R"] + display_color["state_of_health"] = clr["u"] + display_color["highlight"] = clr['P'] + display_color["highlight_background"] = clr['u'] elif mode == "W": display_color["background"] = clr["W"] display_color["basic"] = clr["B"] @@ -49,4 +53,8 @@ def set_color_mode(mode): display_color["time_ruler"] = clr["U"] display_color["zoom_marker"] = clr["O"] display_color["warning"] = clr["O"] + display_color["error"] = clr["s"] + display_color["state_of_health"] = clr["u"] + display_color["highlight"] = clr['P'] + display_color["highlight_background"] = clr['u'] return display_color diff --git a/sohstationviewer/view/util/enums.py b/sohstationviewer/view/util/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..a7749fed9038da3eeef6f157205e059318974c4a --- /dev/null +++ b/sohstationviewer/view/util/enums.py @@ -0,0 +1,7 @@ +import enum + + +class LogType(enum.Enum): + INFO = 0 + WARNING = 1 + ERROR = 2 diff --git a/sohstationviewer/view/util/functions.py b/sohstationviewer/view/util/functions.py new file mode 100644 index 0000000000000000000000000000000000000000..c99075ab74fa409b367e4dd71d491f4aad9f4663 --- /dev/null +++ b/sohstationviewer/view/util/functions.py @@ -0,0 +1,164 @@ +from pathlib import Path +from typing import Dict, List, Tuple, Union +from sohstationviewer.view.util.enums import LogType +from sohstationviewer.conf import constants as const + + +def is_doc_file(file: Path, include_table_of_contents: bool = False)\ + -> bool: + """ + Check if file is a document which is an '.help.md' file + + :param file: Absolute path to file + :type file: Path + :param include_table_of_contents: if Table of Contents file is considered + as doc file or not + :type include_table_of_contents: bool + :return: True if file is a document, False otherwise + :rtype: bool + """ + if not file.is_file(): + return False + if not file.name.endswith('.help.md'): + return False + if not include_table_of_contents and file.name == const.TABLE_CONTENTS: + return False + return True + + +def create_search_results_file(base_path: Path, search_text: str)\ + -> Path: + """ + Create 'Search Results.md' file if not exist. + Write file content which includes all links to '.help.md' files of which + content contains search_text, excluding Table of Contents file. + Format of each link is [name](URL) + + :param base_path: directory where document files are located + :type base_path: Path + :param search_text: text to search in each file + :type search_text: str + :return: path to search through file + :rtype: Path + """ + all_files = [f for f in list(base_path.iterdir()) if is_doc_file(f)] + + search_header = "# Search results\n\n" + search_results = "" + for file in sorted(all_files): + with open(file) as f: + content = f.read() + if search_text in content: + # space in URL must be replace with %20 + url_name = file.name.replace(" ", "%20") + base_file_name = file.stem.split('.help')[0] + try: + display_file_name = base_file_name.split(" _ ")[1] + except IndexError: + display_file_name = base_file_name + search_results += (f"+ [{display_file_name}]" + f"({url_name})\n\n") + if search_results == "": + search_notfound = f"Text '{search_text}' not found." + search_results = search_header + search_notfound + else: + search_found = (f"Text '{search_text}' found in the following files:" + f"\n\n---------------------------\n\n") + search_results = search_header + search_found + search_results + + search_through_file = Path(base_path).joinpath(const.SEARCH_RESULTS) + with open(search_through_file, "w") as f: + f.write(search_results) + return search_through_file + + +def create_table_of_content_file(base_path: Path) -> None: + """ + Creating Table of Contents which includes all links to '.help.md' files. + Format of each link is [name](URL) + This function is added to __main__. So run functions.py to create "Table + of Contents" file. + + :param base_path: directory where document files are located + :type base_path: Path + """ + all_files = [f for f in list(base_path.iterdir()) + if is_doc_file(f, include_table_of_contents=True)] + + header = ( + "# SOH Station Viewer Documentation\n\n" + "Welcome to the SOH Station Viewer documentation. Here you will find " + "usage guides and other useful information in navigating and using " + "this software.\n\n" + "On the left-hand side you will find a list of currently available" + " help topics.\n\n" + "The home button can be used to return to this page at any time.\n\n" + "# Table of Contents\n\n") + links = "" + + for file in sorted(all_files): + # space in URL must be replace with %20 + url_name = file.name.replace(" ", "%20") + base_file_name = file.stem.split('.help')[0] + try: + display_file_name = base_file_name.split(" _ ")[1] + except IndexError: + display_file_name = base_file_name + links += f"+ [{display_file_name}]({url_name})\n\n" + + contents = header + links + + contents_table_file = Path(base_path).joinpath(const.TABLE_CONTENTS) + with open(contents_table_file, "w") as f: + f.write(contents) + print(f"{contents_table_file.absolute().as_posix()} has been created.") + + +def get_soh_messages_for_view( + key: Union[str, Tuple[str, str]], + soh_messages: Dict[str, Union[List[str], Dict[str, List[str]]]]) ->\ + Dict[str, List[str]]: + """ + Convert SOH message of the selected key and TEXT log to dict of list of str + to display + + :param key: key of selected data set: station_id + or (experiment_number, serial) + :type key: str/ (str, str) + :param soh_messages: {'TEXT': log_text, key:{chan_id: sos_messages,},} + :type soh_messages: Dict + :return: {'TEXT'/chan_id: [str,] + :rtype: Dict + """ + + soh_message_view = {} + + if 'TEXT' in soh_messages.keys() and soh_messages['TEXT'] != []: + soh_message_view['TEXT'] = [] + for msg_list in soh_messages['TEXT']: + for msg in msg_list.split('\n'): + soh_message_view['TEXT'].append(msg) + + for chan_id in soh_messages[key]: + soh_message_view[chan_id] = [] + + for msg_lines in soh_messages[key][chan_id]: + for msg in msg_lines.split('\n'): + soh_message_view[chan_id].append(msg) + return soh_message_view + + +def log_str(log_info: Tuple[str, LogType]) -> str: + """ + Convert log_info to string that showing log line in saved file + :param log_info: tuple include text and type of a log line + :type log_info: Tuple[str, LogType] + :return: line of log to save in file + :rtype: str + """ + log_text, log_type = log_info + return f"{log_type.name}: {log_text}" + + +if __name__ == '__main__': + create_table_of_content_file(Path('../../../documentation')) diff --git a/tests/test_controller/test_processing.py b/tests/test_controller/test_processing.py index dc79d6fa912e7da56e5b43262f621833f9d41676..3ee8345a7119abfb9201ee7b2a4fee883ffe2e96 100644 --- a/tests/test_controller/test_processing.py +++ b/tests/test_controller/test_processing.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest import TestCase from unittest.mock import patch +from contextlib import redirect_stdout +import io from sohstationviewer.controller.processing import ( loadData, @@ -10,7 +12,7 @@ from sohstationviewer.controller.processing import ( detectDataType, getDataTypeFromFile ) -from sohstationviewer.database.extractData import signatureChannels +from sohstationviewer.database.extract_data import get_signature_channels from PySide2 import QtWidgets from sohstationviewer.model.mseed.mseed import MSeed from sohstationviewer.model.reftek.reftek import RT130 @@ -121,6 +123,30 @@ class TestLoadDataAndReadChannels(TestCase): self.assertIsNone( loadData(self.mseed_dtype, self.widget_stub, [rt130_dir])) + def test_load_data_data_traceback_error(self): + """ + Test basic functionality of loadData - when there is an error + on loading data, the traceback info will be printed out + """ + f = io.StringIO() + with redirect_stdout(f): + self.assertIsNone(loadData('RT130', None, [q330_dir])) + output = f.getvalue() + self.assertIn( + f"WARNING: Dir {q330_dir} " + f"can't be read due to error: Traceback", + output + ) + with redirect_stdout(f): + self.assertIsNone( + loadData(self.mseed_dtype, None, [rt130_dir])) + output = f.getvalue() + self.assertIn( + f"WARNING: Dir {rt130_dir} " + f"can't be read due to error: Traceback", + output + ) + def test_read_channels_mseed_dir(self): """ Test basic functionality of loadData - the given directory contains @@ -288,7 +314,7 @@ class TestGetDataTypeFromFile(TestCase): '92EB/0/000000000_00000000') expected_data_type = ('RT130', '_') self.assertTupleEqual( - getDataTypeFromFile(rt130_file, signatureChannels()), + getDataTypeFromFile(rt130_file, get_signature_channels()), expected_data_type ) @@ -299,7 +325,7 @@ class TestGetDataTypeFromFile(TestCase): """ test_file = NamedTemporaryFile() self.assertIsNone( - getDataTypeFromFile(test_file.name, signatureChannels())) + getDataTypeFromFile(test_file.name, get_signature_channels())) def test_mseed_data(self): """ @@ -315,7 +341,7 @@ class TestGetDataTypeFromFile(TestCase): centaur_data_type = ('Centaur', 'GEL') pegasus_data_type = ('Pegasus', 'VE1') - sig_chan = signatureChannels() + sig_chan = get_signature_channels() self.assertTupleEqual(getDataTypeFromFile(q330_file, sig_chan), q330_data_type) @@ -332,6 +358,6 @@ class TestGetDataTypeFromFile(TestCase): empty_name_file = '' non_existent_file = 'non_existent_dir' with self.assertRaises(FileNotFoundError): - getDataTypeFromFile(empty_name_file, signatureChannels()) + getDataTypeFromFile(empty_name_file, get_signature_channels()) with self.assertRaises(FileNotFoundError): - getDataTypeFromFile(non_existent_file, signatureChannels()) + getDataTypeFromFile(non_existent_file, get_signature_channels()) diff --git a/tests/test_database/test_extract_data.py b/tests/test_database/test_extract_data.py index 735c0548359f2444cbac63a65d8e8451bafdd2ac..8cd0ab3c039eeaf6453a1743c8c2a433165f24d5 100644 --- a/tests/test_database/test_extract_data.py +++ b/tests/test_database/test_extract_data.py @@ -1,19 +1,19 @@ import unittest -from sohstationviewer.database.extractData import ( - getChanPlotInfo, - getWFPlotInfo, - getChanLabel, - signatureChannels, - getColorDef, - getColorRanges, +from sohstationviewer.database.extract_data import ( + get_chan_plot_info, + get_wf_plot_info, + get_chan_label, + get_signature_channels, + get_color_def, + get_color_ranges, ) class TestExtractData(unittest.TestCase): def test_get_chan_plot_info_good_channel_and_data_type(self): """ - Test basic functionality of getChanPlotInfo - channel and data type + Test basic functionality of get_chan_plot_info - channel and data type combination exists in database table `Channels` """ expected_result = {'channel': 'SOH/Data Def', @@ -25,13 +25,13 @@ class TestExtractData(unittest.TestCase): 'label': 'SOH/Data Def', 'fixPoint': 0, 'valueColors': '0:W|1:C'} - self.assertDictEqual(getChanPlotInfo('SOH/Data Def', 'RT130'), + self.assertDictEqual(get_chan_plot_info('SOH/Data Def', 'RT130'), expected_result) def test_get_chan_plot_info_data_type_is_unknown(self): """ - Test basic functionality of getChanPlotInfo - data type is the string - 'Unknown'. + Test basic functionality of get_chan_plot_info - data type is the + string 'Unknown'. """ # Channel does not exist in database expected_result = {'channel': 'DEFAULT', @@ -43,7 +43,7 @@ class TestExtractData(unittest.TestCase): 'label': 'DEFAULT-Bad Channel ID', 'fixPoint': '0', 'valueColors': None} - self.assertDictEqual(getChanPlotInfo('Bad Channel ID', 'Unknown'), + self.assertDictEqual(get_chan_plot_info('Bad Channel ID', 'Unknown'), expected_result) # Channel exist in database @@ -55,13 +55,15 @@ class TestExtractData(unittest.TestCase): 'convertFactor': 1, 'label': 'LCE-PhaseError', 'fixPoint': 0, - 'valueColors': 'L:W'} - self.assertDictEqual(getChanPlotInfo('LCE', 'Unknown'), + 'valueColors': 'L:W|D:Y'} + self.assertDictEqual(get_chan_plot_info('LCE', 'Unknown'), + expected_result) + self.assertDictEqual(get_chan_plot_info('LCE', 'Unknown'), expected_result) def test_get_chan_plot_info_bad_channel_or_data_type(self): """ - Test basic functionality of getChanPlotInfo - channel and data type + Test basic functionality of get_chan_plot_info - channel and data type combination does not exist in database table Channels and data type is not the string 'Unknown'. """ @@ -79,74 +81,76 @@ class TestExtractData(unittest.TestCase): # Data type has None value. None value comes from # controller.processing.detectDataType. expected_result['label'] = 'DEFAULT-SOH/Data Def' - self.assertDictEqual(getChanPlotInfo('SOH/Data Def', None), + self.assertDictEqual(get_chan_plot_info('SOH/Data Def', None), expected_result) # Channel and data type are empty strings expected_result['label'] = 'DEFAULT-' - self.assertDictEqual(getChanPlotInfo('', ''), expected_result) + self.assertDictEqual(get_chan_plot_info('', ''), expected_result) # Channel exists in database but data type does not expected_result['label'] = 'DEFAULT-SOH/Data Def' - self.assertDictEqual(getChanPlotInfo('SOH/Data Def', 'Bad Data Type'), - expected_result) + self.assertDictEqual( + get_chan_plot_info('SOH/Data Def', 'Bad Data Type'), + expected_result + ) # Data type exists in database but channel does not expected_result['label'] = 'DEFAULT-Bad Channel ID' - self.assertDictEqual(getChanPlotInfo('Bad Channel ID', 'RT130'), + self.assertDictEqual(get_chan_plot_info('Bad Channel ID', 'RT130'), expected_result) # Both channel and data type exists in database but not their # combination expected_result['label'] = 'DEFAULT-SOH/Data Def' - self.assertDictEqual(getChanPlotInfo('SOH/Data Def', 'Q330'), + self.assertDictEqual(get_chan_plot_info('SOH/Data Def', 'Q330'), expected_result) def test_get_wf_plot_info(self): """ - Test basic functionality of getWFPlotInfo - ensures returned dictionary - contains all the needed key. Bad channel IDs cases are handled in tests - for getChanLabel. + Test basic functionality of get_wf_plot_info - ensures returned + dictionary contains all the needed key. Bad channel IDs cases are + handled in tests for get_chan_label. """ - result = getWFPlotInfo('CH1') + result = get_wf_plot_info('CH1') expected_keys = ('param', 'plotType', 'valueColors', 'height', 'label', 'unit', 'channel') self.assertTupleEqual(tuple(result.keys()), expected_keys) def test_get_chan_label_good_channel_id(self): """ - Test basic functionality of getChanLabel - channel ID ends in one + Test basic functionality of get_chan_label - channel ID ends in one of the keys in conf.dbSettings.dbConf['seisLabel'] or starts with 'DS' """ # Channel ID does not start with 'DS' - self.assertEqual(getChanLabel('CH1'), 'CH1-NS') - self.assertEqual(getChanLabel('CH2'), 'CH2-EW') + self.assertEqual(get_chan_label('CH1'), 'CH1-NS') + self.assertEqual(get_chan_label('CH2'), 'CH2-EW') # Channel ID starts with 'DS' - self.assertEqual(getChanLabel('DS-TEST-CHANNEL'), 'DS-TEST-CHANNEL') + self.assertEqual(get_chan_label('DS-TEST-CHANNEL'), 'DS-TEST-CHANNEL') def test_get_chan_label_bad_channel_id(self): """ - Test basic functionality of getChanLabel - channel ID does not end in + Test basic functionality of get_chan_label - channel ID does not end in one of the keys in conf.dbSettings.dbConf['seisLabel'] or is the empty string. """ - self.assertRaises(KeyError, getChanLabel, 'CHG') - self.assertRaises(IndexError, getChanLabel, '') + self.assertRaises(KeyError, get_chan_label, 'CHG') + self.assertRaises(IndexError, get_chan_label, '') - def test_signature_channels(self): - """Test basic functionality of signatureChannels""" - self.assertIsInstance(signatureChannels(), dict) + def test_get_signature_channels(self): + """Test basic functionality of get_signature_channels""" + self.assertIsInstance(get_signature_channels(), dict) def test_get_color_def(self): - """Test basic functionality of getColorDef""" - colors = getColorDef() + """Test basic functionality of get_color_def""" + colors = get_color_def() expected_colors = ['K', 'U', 'C', 'G', 'Y', 'R', 'M', 'E'] self.assertListEqual(colors, expected_colors) def test_get_color_ranges(self): - """Test basic functionality of getColorDef""" - names, all_counts, all_display_strings = getColorRanges() + """Test basic functionality of get_color_ranges""" + names, all_counts, all_display_strings = get_color_ranges() num_color_def = 7 expected_names = ['antarctica', 'low', 'med', 'high'] diff --git a/tests/test_model/test_handling_data.py b/tests/test_model/test_handling_data.py deleted file mode 100644 index 935a25af25c5c71bda10b292b039b356718d04be..0000000000000000000000000000000000000000 --- a/tests/test_model/test_handling_data.py +++ /dev/null @@ -1,289 +0,0 @@ -from pathlib import Path -from tempfile import TemporaryDirectory - -from unittest import TestCase -from unittest.mock import patch - -import numpy as np - -from sohstationviewer.conf import constants as const -from sohstationviewer.model.handling_data import ( - downsample, - trim_downsample_WFChan, - trim_waveform_data, - downsample_waveform_data -) - -ORIGINAL_CHAN_SIZE_LIMIT = const.CHAN_SIZE_LIMIT -ORIGINAL_RECAL_SIZE_LIMIT = const.RECAL_SIZE_LIMIT - - -class TestTrimWfData(TestCase): - def setUp(self) -> None: - self.channel_data = {} - self.traces_info = [] - self.channel_data['tracesInfo'] = self.traces_info - - for i in range(100): - trace_size = 100 - start_time = i * trace_size - trace = {} - trace['startTmEpoch'] = start_time - trace['endTmEpoch'] = start_time + trace_size - 1 - self.traces_info.append(trace) - self.start_time = 2500 - self.end_time = 7500 - - def test_data_is_trimmed_neither_start_nor_end_time_is_trace_start_or_end_time(self): # noqa: E501 - self.start_time = 2444 - self.end_time = 7444 - trimmed_traces_list = trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - - self.assertTrue( - trimmed_traces_list[0]['startTmEpoch'] <= self.start_time) - self.assertTrue( - trimmed_traces_list[0]['endTmEpoch'] > self.start_time - ) - trimmed_traces_list.pop(0) - trimmed_traces_list.pop() - is_left_trimmed = all(trace['startTmEpoch'] > self.start_time - for trace in trimmed_traces_list) - is_right_trimmed = all(trace['endTmEpoch'] <= self.end_time - for trace in trimmed_traces_list) - self.assertTrue(is_left_trimmed and is_right_trimmed) - - def test_data_out_of_range(self): - with self.subTest('test_start_time_later_than_data_end_time'): - self.start_time = 12500 - self.end_time = 17500 - self.assertFalse( - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, True) - ) - with self.subTest('test_end_time_earlier_than_data_start_time'): - self.start_time = -7500 - self.end_time = -2500 - self.assertFalse( - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, True) - ) - - def test_no_data(self): - self.channel_data['tracesInfo'] = [] - with self.assertRaises(IndexError): - trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - - def test_end_time_earlier_than_start_time(self): - self.start_time, self.end_time = self.end_time, self.start_time - trimmed_traces_list = trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - self.assertListEqual(trimmed_traces_list, []) - - def test_data_does_not_need_to_be_trimmed(self): - with self.subTest('test_start_time_earlier_than_trace_earliest_time'): - self.start_time = -2500 - self.end_time = 7500 - trimmed_traces_list = trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - self.assertEqual(len(trimmed_traces_list), 76) - with self.subTest('test_end_time_later_than_trace_latest_time'): - self.start_time = 2500 - self.end_time = 12500 - trimmed_traces_list = trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - self.assertEqual(len(trimmed_traces_list), 75) - with self.subTest('test_data_contained_in_time_range'): - self.start_time = self.traces_info[0]['startTmEpoch'] - self.end_time = self.traces_info[-1]['endTmEpoch'] - trimmed_traces_list = trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - self.assertEqual(len(trimmed_traces_list), len(self.traces_info)) - - -class TestDownsampleWaveformData(TestCase): - def no_file_memmap(self, file_path: Path, **kwargs): - # Data will look the same as times. This has two benefits: - # - It is a lot easier to inspect what data remains after trimming - # and downsampling, seeing as the remaining data would be the same - # as the remaining times. - # - It is a lot easier to reproducibly create a test data set. - array_size = 100 - file_idx = int(file_path.name.split('-')[-1]) - start = file_idx * array_size - end = start + array_size - return np.arange(start, end) - - def setUp(self) -> None: - memmap_patcher = patch.object(np, 'memmap', - side_effect=self.no_file_memmap) - self.addCleanup(memmap_patcher.stop) - memmap_patcher.start() - - self.channel_data = {} - self.traces_info = [] - self.channel_data['tracesInfo'] = self.traces_info - self.data_folder = TemporaryDirectory() - for i in range(100): - trace_size = 100 - start_time = i * trace_size - trace = {} - trace['startTmEpoch'] = start_time - trace['endTmEpoch'] = start_time + trace_size - 1 - trace['size'] = trace_size - - times_file_name = Path(self.data_folder.name) / f'times-{i}' - trace['times_f'] = times_file_name - - data_file_name = Path(self.data_folder.name) / f'data-{i}' - trace['data_f'] = data_file_name - - self.traces_info.append(trace) - self.start_time = 2550 - self.end_time = 7550 - self.trimmed_traces_list = trim_waveform_data( - self.channel_data, self.start_time, self.end_time - ) - - @patch('sohstationviewer.model.handling_data.downsample', wraps=downsample) - def test_data_is_downsampled(self, mock_downsample): - const.CHAN_SIZE_LIMIT = 1000 - downsample_waveform_data(self.trimmed_traces_list, - self.start_time, self.end_time) - self.assertTrue(mock_downsample.called) - const.CHAN_SIZE_LIMIT = ORIGINAL_CHAN_SIZE_LIMIT - - def test_all_traces_handled(self): - downsampled_times, downsampled_data = downsample_waveform_data( - self.trimmed_traces_list, - self.start_time, self.end_time - ) - self.assertEqual(len(downsampled_times), 51) - self.assertEqual(len(downsampled_data), 51) - - def test_downsampling_not_needed(self): - downsampled_times, downsampled_data = downsample_waveform_data( - self.trimmed_traces_list, - self.start_time, self.end_time - ) - with self.subTest('test_data_points_outside_time_range_removed'): - self.assertEqual(downsampled_times.pop(0).size, 50) - self.assertEqual(downsampled_times.pop(-1).size, 51) - self.assertEqual(downsampled_data.pop(0).size, 50) - self.assertEqual(downsampled_data.pop(-1).size, 51) - - with self.subTest('test_intermediate_data_points_not_removed'): - self.assertTrue( - all(times.size == 100 for times in downsampled_times) - ) - self.assertTrue( - all(data.size == 100 for data in downsampled_times) - ) - - def test_trace_list_empty(self): - self.trimmed_traces_list = [] - downsampled_times, downsampled_data = downsample_waveform_data( - self.trimmed_traces_list, - self.start_time, self.end_time - ) - self.assertListEqual(downsampled_times, []) - self.assertListEqual(downsampled_data, []) - - def test_end_time_earlier_than_start_time(self): - self.start_time, self.end_time = self.end_time, self.start_time - downsampled_times, downsampled_data = downsample_waveform_data( - self.trimmed_traces_list, - self.start_time, self.end_time - ) - self.assertTrue(all(times.size == 0 for times in downsampled_times)) - self.assertTrue(all(data.size == 0 for data in downsampled_data)) - - -class TestTrimDownsampleWfChan(TestCase): - def no_file_memmap(self, file_path: Path, **kwargs): - # Data will look the same as times. This has two benefits: - # - It is a lot easier to inspect what data remains after trimming - # and downsampling, seeing as the remaining data would be the same - # as the remaining times. - # - It is a lot easier to reproducibly create a test data set. - array_size = 100 - file_idx = int(file_path.name.split('-')[-1]) - start = file_idx * array_size - end = start + array_size - return np.arange(start, end) - - def setUp(self) -> None: - memmap_patcher = patch.object(np, 'memmap', - side_effect=self.no_file_memmap) - self.addCleanup(memmap_patcher.stop) - memmap_patcher.start() - - self.channel_data = {} - self.traces_info = [] - self.channel_data['tracesInfo'] = self.traces_info - self.data_folder = TemporaryDirectory() - for i in range(100): - trace_size = 100 - start_time = i * trace_size - trace = {} - trace['startTmEpoch'] = start_time - trace['endTmEpoch'] = start_time + trace_size - 1 - trace['size'] = trace_size - - times_file_name = Path(self.data_folder.name) / f'times-{i}' - trace['times_f'] = times_file_name - - data_file_name = Path(self.data_folder.name) / f'data-{i}' - trace['data_f'] = data_file_name - - self.traces_info.append(trace) - self.start_time = 2500 - self.end_time = 7500 - - def test_result_is_stored(self): - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, True) - self.assertTrue('times' in self.channel_data) - self.assertGreater(len(self.channel_data['times']), 0) - self.assertTrue('data' in self.channel_data) - self.assertGreater(len(self.channel_data['data']), 0) - - def test_data_small_enough_after_first_trim_flag_is_set(self): - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, True) - self.assertTrue('fulldata' in self.channel_data) - - def test_no_additional_work_if_data_small_enough_after_first_trim(self): - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, True) - current_times = self.channel_data['times'] - current_data = self.channel_data['data'] - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, True) - self.assertIs(current_times, self.channel_data['times']) - self.assertIs(current_data, self.channel_data['data']) - - def test_data_too_large_after_trimming(self): - const.RECAL_SIZE_LIMIT = 1 - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, False) - self.assertTrue('times' not in self.channel_data) - self.assertTrue('data' not in self.channel_data) - const.RECAL_SIZE_LIMIT = ORIGINAL_RECAL_SIZE_LIMIT - - @patch('sohstationviewer.model.handling_data.trim_waveform_data', - wraps=trim_waveform_data) - @patch('sohstationviewer.model.handling_data.downsample_waveform_data', - wraps=downsample_waveform_data) - def test_data_trim_and_downsampled(self, mock_downsample, mock_trim): - trim_downsample_WFChan(self.channel_data, self.start_time, - self.end_time, False) - self.assertTrue(mock_trim.called) - self.assertTrue(mock_downsample.called) diff --git a/tests/test_model/test_handling_data_trim_downsample.py b/tests/test_model/test_handling_data_trim_downsample.py new file mode 100644 index 0000000000000000000000000000000000000000..244588c8f501e1003b14111b9a0760f927239b48 --- /dev/null +++ b/tests/test_model/test_handling_data_trim_downsample.py @@ -0,0 +1,599 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from unittest import TestCase +from unittest.mock import patch + +from obspy.core import UTCDateTime +import numpy as np + +from sohstationviewer.conf import constants as const +from sohstationviewer.model.handling_data import ( + downsample, + chunk_minmax, + trim_downsample_SOHChan, + trim_downsample_WFChan, + trim_waveform_data, + downsample_waveform_data +) + +ORIGINAL_CHAN_SIZE_LIMIT = const.CHAN_SIZE_LIMIT +ORIGINAL_RECAL_SIZE_LIMIT = const.RECAL_SIZE_LIMIT +ZERO_EPOCH_TIME = UTCDateTime(1970, 1, 1, 0, 0, 0).timestamp + + +class TestTrimWfData(TestCase): + def setUp(self) -> None: + self.channel_data = {} + self.traces_info = [] + self.channel_data['tracesInfo'] = self.traces_info + + for i in range(100): + trace_size = 100 + start_time = i * trace_size + trace = {} + trace['startTmEpoch'] = start_time + trace['endTmEpoch'] = start_time + trace_size - 1 + self.traces_info.append(trace) + self.start_time = 2500 + self.end_time = 7500 + + def test_data_is_trimmed_neither_start_nor_end_time_is_trace_start_or_end_time(self): # noqa: E501 + self.start_time = 2444 + self.end_time = 7444 + trimmed_traces_list = trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + + self.assertTrue( + trimmed_traces_list[0]['startTmEpoch'] <= self.start_time) + self.assertTrue( + trimmed_traces_list[0]['endTmEpoch'] > self.start_time + ) + trimmed_traces_list.pop(0) + trimmed_traces_list.pop() + is_left_trimmed = all(trace['startTmEpoch'] > self.start_time + for trace in trimmed_traces_list) + is_right_trimmed = all(trace['endTmEpoch'] <= self.end_time + for trace in trimmed_traces_list) + self.assertTrue(is_left_trimmed and is_right_trimmed) + + def test_data_out_of_range(self): + with self.subTest('test_start_time_later_than_data_end_time'): + self.start_time = 12500 + self.end_time = 17500 + self.assertFalse( + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, True) + ) + with self.subTest('test_end_time_earlier_than_data_start_time'): + self.start_time = -7500 + self.end_time = -2500 + self.assertFalse( + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, True) + ) + + def test_no_data(self): + self.channel_data['tracesInfo'] = [] + with self.assertRaises(IndexError): + trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + + def test_end_time_earlier_than_start_time(self): + self.start_time, self.end_time = self.end_time, self.start_time + trimmed_traces_list = trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + self.assertListEqual(trimmed_traces_list, []) + + def test_data_does_not_need_to_be_trimmed(self): + with self.subTest('test_start_time_earlier_than_trace_earliest_time'): + self.start_time = -2500 + self.end_time = 7500 + trimmed_traces_list = trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + self.assertEqual(len(trimmed_traces_list), 76) + with self.subTest('test_end_time_later_than_trace_latest_time'): + self.start_time = 2500 + self.end_time = 12500 + trimmed_traces_list = trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + self.assertEqual(len(trimmed_traces_list), 75) + with self.subTest('test_data_contained_in_time_range'): + self.start_time = self.traces_info[0]['startTmEpoch'] + self.end_time = self.traces_info[-1]['endTmEpoch'] + trimmed_traces_list = trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + self.assertEqual(len(trimmed_traces_list), len(self.traces_info)) + + +class TestDownsampleWaveformData(TestCase): + def no_file_memmap(self, file_path: Path, **kwargs): + # Data will look the same as times. This has two benefits: + # - It is a lot easier to inspect what data remains after trimming + # and downsampling, seeing as the remaining data would be the same + # as the remaining times. + # - It is a lot easier to reproducibly create a test data set. + array_size = 100 + file_idx = int(file_path.name.split('-')[-1]) + start = file_idx * array_size + end = start + array_size + return np.arange(start, end) + + def setUp(self) -> None: + memmap_patcher = patch.object(np, 'memmap', + side_effect=self.no_file_memmap) + self.addCleanup(memmap_patcher.stop) + memmap_patcher.start() + + self.channel_data = {} + self.traces_info = [] + self.channel_data['tracesInfo'] = self.traces_info + self.data_folder = TemporaryDirectory() + for i in range(100): + trace_size = 100 + start_time = i * trace_size + trace = {} + trace['startTmEpoch'] = start_time + trace['endTmEpoch'] = start_time + trace_size - 1 + trace['size'] = trace_size + + times_file_name = Path(self.data_folder.name) / f'times-{i}' + trace['times_f'] = times_file_name + + data_file_name = Path(self.data_folder.name) / f'data-{i}' + trace['data_f'] = data_file_name + + self.traces_info.append(trace) + self.start_time = 2550 + self.end_time = 7550 + self.trimmed_traces_list = trim_waveform_data( + self.channel_data, self.start_time, self.end_time + ) + + @patch('sohstationviewer.model.handling_data.downsample', wraps=downsample) + def test_data_is_downsampled(self, mock_downsample): + const.CHAN_SIZE_LIMIT = 1000 + downsample_waveform_data(self.trimmed_traces_list, + self.start_time, self.end_time) + self.assertTrue(mock_downsample.called) + const.CHAN_SIZE_LIMIT = ORIGINAL_CHAN_SIZE_LIMIT + + def test_all_traces_handled(self): + downsampled_times, downsampled_data = downsample_waveform_data( + self.trimmed_traces_list, + self.start_time, self.end_time + ) + self.assertEqual(len(downsampled_times), 51) + self.assertEqual(len(downsampled_data), 51) + + def test_downsampling_not_needed(self): + downsampled_times, downsampled_data = downsample_waveform_data( + self.trimmed_traces_list, + self.start_time, self.end_time + ) + with self.subTest('test_data_points_outside_time_range_removed'): + self.assertEqual(downsampled_times.pop(0).size, 50) + self.assertEqual(downsampled_times.pop(-1).size, 51) + self.assertEqual(downsampled_data.pop(0).size, 50) + self.assertEqual(downsampled_data.pop(-1).size, 51) + + with self.subTest('test_intermediate_data_points_not_removed'): + self.assertTrue( + all(times.size == 100 for times in downsampled_times) + ) + self.assertTrue( + all(data.size == 100 for data in downsampled_times) + ) + + def test_trace_list_empty(self): + self.trimmed_traces_list = [] + downsampled_times, downsampled_data = downsample_waveform_data( + self.trimmed_traces_list, + self.start_time, self.end_time + ) + self.assertListEqual(downsampled_times, []) + self.assertListEqual(downsampled_data, []) + + def test_end_time_earlier_than_start_time(self): + self.start_time, self.end_time = self.end_time, self.start_time + downsampled_times, downsampled_data = downsample_waveform_data( + self.trimmed_traces_list, + self.start_time, self.end_time + ) + self.assertTrue(all(times.size == 0 for times in downsampled_times)) + self.assertTrue(all(data.size == 0 for data in downsampled_data)) + + @patch('sohstationviewer.model.handling_data.downsample') + def test_arguments_sent_to_downsample(self, mock_downsample): + mock_downsample.return_value = (1, 2, 3) + const.CHAN_SIZE_LIMIT = 1000 + downsample_waveform_data(self.trimmed_traces_list, + self.start_time, self.end_time) + + positional_args, named_args = mock_downsample.call_args + self.assertEqual(len(positional_args), 2) + + +class TestDownsample(TestCase): + def setUp(self) -> None: + patcher = patch('sohstationviewer.model.handling_data.chunk_minmax') + self.addCleanup(patcher.stop) + self.mock_chunk_minmax = patcher.start() + self.times = np.arange(1000) + self.data = np.arange(1000) + self.log_idx = np.arange(1000) + + def test_first_downsample_step_remove_enough_points(self): + req_points = 999 + downsample(self.times, self.data, rq_points=req_points) + self.assertFalse(self.mock_chunk_minmax.called) + + def test_first_downsample_step_remove_enough_points_with_logidx(self): + req_points = 999 + downsample(self.times, self.data, self.log_idx, rq_points=req_points) + self.assertFalse(self.mock_chunk_minmax.called) + + def test_second_downsample_step_required(self): + req_points = 1 + downsample(self.times, self.data, rq_points=req_points) + self.assertTrue(self.mock_chunk_minmax.called) + times, data, _, rq_points = self.mock_chunk_minmax.call_args[0] + self.assertIsNot(times, self.times) + self.assertIsNot(data, self.data) + self.assertEqual(rq_points, req_points) + + def test_second_downsample_step_required_with_logidx(self): + req_points = 1 + downsample(self.times, self.data, self.log_idx, rq_points=req_points) + self.assertTrue(self.mock_chunk_minmax.called) + times, data, log_idx, rq_points = self.mock_chunk_minmax.call_args[0] + self.assertIsNot(times, self.times) + self.assertIsNot(data, self.data) + self.assertIsNot(log_idx, self.log_idx) + self.assertEqual(rq_points, req_points) + + def test_requested_points_greater_than_data_size(self): + req_points = 10000 + times, data, _ = downsample( + self.times, self.data, rq_points=req_points) + self.assertFalse(self.mock_chunk_minmax.called) + # Check that we did not do any processing on the times and data arrays. + # This ensures that we don't do two unneeded copy operations. + self.assertIs(times, self.times) + self.assertIs(data, self.data) + + def test_requested_points_greater_than_data_size_with_logidx(self): + req_points = 10000 + times, data, log_idx = downsample( + self.times, self.data, self.log_idx, rq_points=req_points) + self.assertFalse(self.mock_chunk_minmax.called) + # Check that we did not do any processing on the times and data arrays. + # This ensures that we don't do two unneeded copy operations. + self.assertIs(times, self.times) + self.assertIs(data, self.data) + self.assertIs(log_idx, self.log_idx) + + def test_requested_points_is_zero(self): + req_points = 0 + downsample(self.times, self.data, rq_points=req_points) + self.assertTrue(self.mock_chunk_minmax.called) + times, data, _, rq_points = self.mock_chunk_minmax.call_args[0] + self.assertIsNot(times, self.times) + self.assertIsNot(data, self.data) + self.assertEqual(rq_points, req_points) + + def test_requested_points_is_zero_with_logidx(self): + req_points = 0 + downsample(self.times, self.data, self.log_idx, rq_points=req_points) + self.assertTrue(self.mock_chunk_minmax.called) + times, data, log_idx, rq_points = self.mock_chunk_minmax.call_args[0] + self.assertIsNot(times, self.times) + self.assertIsNot(data, self.data) + self.assertIsNot(log_idx, self.log_idx) + self.assertEqual(rq_points, req_points) + + def test_empty_times_and_data(self): + req_points = 1000 + self.times = np.empty((0, 0)) + self.data = np.empty((0, 0)) + times, data, _ = downsample( + self.times, self.data, rq_points=req_points) + self.assertFalse(self.mock_chunk_minmax.called) + # Check that we did not do any processing on the times and data arrays. + # This ensures that we don't do two unneeded copy operations. + self.assertIs(times, self.times) + self.assertIs(data, self.data) + + def test_empty_times_and_data_with_logidx(self): + req_points = 1000 + self.times = np.empty((0, 0)) + self.data = np.empty((0, 0)) + self.log_idx = np.empty((0, 0)) + times, data, log_idx = downsample( + self.times, self.data, self.log_idx, rq_points=req_points) + self.assertFalse(self.mock_chunk_minmax.called) + # Check that we did not do any processing on the times and data arrays. + # This ensures that we don't do two unneeded copy operations. + self.assertIs(times, self.times) + self.assertIs(data, self.data) + self.assertIs(log_idx, self.log_idx) + + +class TestChunkMinmax(TestCase): + def setUp(self): + self.times = np.arange(1000) + self.data = np.arange(1000) + self.log_idx = np.arange(1000) + + def test_data_size_is_multiple_of_requested_points(self): + req_points = 100 + times, data, log_idx = chunk_minmax( + self.times, self.data, self.log_idx, req_points) + self.assertEqual(times.size, req_points) + self.assertEqual(data.size, req_points) + self.assertEqual(log_idx.size, req_points) + + @patch('sohstationviewer.model.handling_data.downsample', wraps=downsample) + def test_data_size_is_not_multiple_of_requested_points( + self, mock_downsample): + req_points = 102 + chunk_minmax(self.times, self.data, self.log_idx, req_points) + self.assertTrue(mock_downsample.called) + + def test_requested_points_too_small(self): + small_req_points_list = [0, 1] + for req_points in small_req_points_list: + with self.subTest(f'test_requested_points_is_{req_points}'): + times, data, log_idx = chunk_minmax( + self.times, self.data, self.log_idx, rq_points=req_points) + self.assertEqual(times.size, 0) + self.assertEqual(data.size, 0) + self.assertEqual(data.size, 0) + + +class TestTrimDownsampleSohChan(TestCase): + @staticmethod + def downsample(times, data, log_indexes=None, rq_points=0): + return times, data, log_indexes + + def setUp(self) -> None: + self.channel_info = {} + self.org_trace = { + 'times': np.arange(1000), + 'data': np.arange(1000) + } + self.channel_info['orgTrace'] = self.org_trace + self.start_time = 250 + self.end_time = 750 + self.first_time = False + + patcher = patch('sohstationviewer.model.handling_data.downsample') + self.addCleanup(patcher.stop) + self.mock_downsample = patcher.start() + self.mock_downsample.side_effect = self.downsample + + def num_points_outside_time_range(self, start_time, end_time): + return len([data_point + for data_point in self.org_trace['times'] + if not start_time <= data_point <= end_time]) + + def test_start_time_later_than_times_data(self): + self.start_time = 250 + self.end_time = 1250 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertGreaterEqual(self.channel_info['times'].min(), + self.start_time) + self.assertEqual( + self.org_trace['times'].size - self.channel_info['times'].size, + self.num_points_outside_time_range(self.start_time, self.end_time) + ) + + def test_end_time_earlier_than_times_data(self): + self.start_time = -250 + self.end_time = 750 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertLessEqual(self.channel_info['times'].max(), + self.end_time) + self.assertEqual( + self.org_trace['times'].size - self.channel_info['times'].size, + self.num_points_outside_time_range(self.start_time, self.end_time) + ) + + def test_start_time_earlier_than_times_data(self): + self.start_time = -250 + self.end_time = 750 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertEqual(self.channel_info['times'].min(), + self.channel_info['orgTrace']['times'].min()) + self.assertEqual( + self.org_trace['times'].size - self.channel_info['times'].size, + self.num_points_outside_time_range(self.start_time, self.end_time) + ) + + def test_end_time_later_than_times_data(self): + self.start_time = 250 + self.end_time = 1250 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertEqual(self.channel_info['times'].max(), + self.channel_info['orgTrace']['times'].max()) + self.assertEqual( + self.org_trace['times'].size - self.channel_info['times'].size, + self.num_points_outside_time_range(self.start_time, self.end_time) + ) + + def test_times_data_contained_in_time_range(self): + self.start_time = -250 + self.end_time = 1250 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + np.testing.assert_array_equal(self.channel_info['times'], + self.org_trace['times']) + + def test_time_range_is_the_same_as_times_data(self): + self.start_time = ZERO_EPOCH_TIME + self.end_time = 999 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + np.testing.assert_array_equal(self.channel_info['times'], + self.org_trace['times']) + + def test_time_range_does_not_overlap_times_data(self): + self.start_time = 2000 + self.end_time = 3000 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertEqual(self.channel_info['times'].size, 0) + self.assertEqual(self.channel_info['data'].size, 0) + + def test_data_is_downsampled(self): + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertTrue(self.mock_downsample.called) + + def test_processed_data_is_stored_in_appropriate_location(self): + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + expected_keys = ('orgTrace', 'times', 'data') + self.assertTupleEqual(tuple(self.channel_info.keys()), + expected_keys) + + @patch('sohstationviewer.model.handling_data.downsample') + def test_arguments_sent_to_downsample(self, mock_downsample): + mock_downsample.return_value = (1, 2, 3) + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + + positional_args, named_args = mock_downsample.call_args + self.assertEqual(len(positional_args), 2) + + +class TestTrimDownsampleSohChanWithLogidx(TestCase): + def setUp(self) -> None: + self.channel_info = {} + self.org_trace = { + 'times': np.arange(1000), + 'data': np.arange(1000), + 'logIdx': np.arange(1000) + } + self.channel_info['orgTrace'] = self.org_trace + self.start_time = 250 + self.end_time = 750 + self.first_time = False + + def test_time_range_does_not_overlap_times_data(self): + self.start_time = 2000 + self.end_time = 3000 + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + self.assertEqual(self.channel_info['times'].size, 0) + self.assertEqual(self.channel_info['data'].size, 0) + self.assertEqual(self.channel_info['logIdx'].size, 0) + + def test_processed_data_is_stored_in_appropriate_location(self): + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + expected_keys = ('orgTrace', 'times', 'data', 'logIdx') + self.assertTupleEqual(tuple(self.channel_info.keys()), + expected_keys) + + @patch('sohstationviewer.model.handling_data.downsample') + def test_arguments_sent_to_downsample(self, mock_downsample): + mock_downsample.return_value = (1, 2, 3) + trim_downsample_SOHChan(self.channel_info, self.start_time, + self.end_time, self.first_time) + + positional_args, named_args = mock_downsample.call_args + self.assertEqual(len(positional_args), 3) + + +class TestTrimDownsampleWfChan(TestCase): + def no_file_memmap(self, file_path: Path, **kwargs): + # Data will look the same as times. This has two benefits: + # - It is a lot easier to inspect what data remains after trimming + # and downsampling, seeing as the remaining data would be the same + # as the remaining times. + # - It is a lot easier to reproducibly create a test data set. + array_size = 100 + file_idx = int(file_path.name.split('-')[-1]) + start = file_idx * array_size + end = start + array_size + return np.arange(start, end) + + def setUp(self) -> None: + memmap_patcher = patch.object(np, 'memmap', + side_effect=self.no_file_memmap) + self.addCleanup(memmap_patcher.stop) + memmap_patcher.start() + + self.channel_data = {} + self.traces_info = [] + self.channel_data['tracesInfo'] = self.traces_info + self.data_folder = TemporaryDirectory() + for i in range(100): + trace_size = 100 + start_time = i * trace_size + trace = {} + trace['startTmEpoch'] = start_time + trace['endTmEpoch'] = start_time + trace_size - 1 + trace['size'] = trace_size + + times_file_name = Path(self.data_folder.name) / f'times-{i}' + trace['times_f'] = times_file_name + + data_file_name = Path(self.data_folder.name) / f'data-{i}' + trace['data_f'] = data_file_name + + self.traces_info.append(trace) + self.start_time = 2500 + self.end_time = 7500 + + def test_result_is_stored(self): + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, True) + self.assertTrue('times' in self.channel_data) + self.assertGreater(len(self.channel_data['times']), 0) + self.assertTrue('data' in self.channel_data) + self.assertGreater(len(self.channel_data['data']), 0) + + def test_data_small_enough_after_first_trim_flag_is_set(self): + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, True) + self.assertTrue('fulldata' in self.channel_data) + + def test_no_additional_work_if_data_small_enough_after_first_trim(self): + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, True) + current_times = self.channel_data['times'] + current_data = self.channel_data['data'] + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, True) + self.assertIs(current_times, self.channel_data['times']) + self.assertIs(current_data, self.channel_data['data']) + + def test_data_too_large_after_trimming(self): + const.RECAL_SIZE_LIMIT = 1 + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, False) + self.assertTrue('times' not in self.channel_data) + self.assertTrue('data' not in self.channel_data) + const.RECAL_SIZE_LIMIT = ORIGINAL_RECAL_SIZE_LIMIT + + @patch('sohstationviewer.model.handling_data.trim_waveform_data', + wraps=trim_waveform_data) + @patch('sohstationviewer.model.handling_data.downsample_waveform_data', + wraps=downsample_waveform_data) + def test_data_trim_and_downsampled(self, mock_downsample, mock_trim): + trim_downsample_WFChan(self.channel_data, self.start_time, + self.end_time, False) + self.assertTrue(mock_trim.called) + self.assertTrue(mock_downsample.called) diff --git a/tests/test_view/__init__.py b/tests/test_view/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_view/test_util_functions.py b/tests/test_view/test_util_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..3b9c894ec77ebc4aadbff4522dc3c9555d57950a --- /dev/null +++ b/tests/test_view/test_util_functions.py @@ -0,0 +1,210 @@ +import io +from contextlib import redirect_stdout +from pathlib import Path +import tempfile +from unittest import TestCase + +from sohstationviewer.view.util.functions import ( + get_soh_messages_for_view, log_str, is_doc_file, + create_search_results_file, create_table_of_content_file) + +from sohstationviewer.view.util.enums import LogType +from sohstationviewer.conf import constants as const + + +class TestGetSOHMessageForView(TestCase): + + def test_no_or_empty_textlog(self): + soh_msg_channels = {"ACE": ["test1\ntest2", "test3"], + "LOG": ["test4"]} + soh_msg_for_view = {'ACE': ['test1', 'test2', 'test3'], + 'LOG': ['test4']} + + with self.subTest('test_no_TEXT_str_dataset_key'): + soh_messages = {"key1": soh_msg_channels} + ret = get_soh_messages_for_view("key1", soh_messages) + self.assertNotIn('TEXT', list(ret.keys())) + self.assertEqual(ret, soh_msg_for_view) + + with self.subTest('test_empty_TEXT_tupple_dataset_key'): + soh_messages = {"TEXT": [], ("key1", "key2"): soh_msg_channels} + ret = get_soh_messages_for_view(("key1", "key2"), soh_messages) + self.assertNotIn('TEXT', list(ret.keys())) + self.assertEqual(ret, soh_msg_for_view) + + # no key "TEXT", dataset has no channels + with self.subTest('test_no_TEXT_no_SOH_channels_for_dataset'): + soh_messages = {"key1": {}} + ret = get_soh_messages_for_view("key1", soh_messages) + self.assertEqual(ret, {}) + + def test_some_empty_soh_message(self): + soh_messages = {"TEXT": ['text1', 'text2\ntext3'], + "key1": {"ACE": ["test1\ntest2", "test3"], + "LOG": []}} + # channel LOG is empty + ret = get_soh_messages_for_view("key1", soh_messages) + self.assertEqual(ret, + {'TEXT': ['text1', 'text2', 'text3'], + 'ACE': ['test1', 'test2', 'test3'], + 'LOG': []}) + + +class TestLogStr(TestCase): + def test_log_str(self): + log = ('info line 1', LogType.INFO) + ret = log_str(log) + self.assertEqual(ret, 'INFO: info line 1') + + +class TestIsDocFile(TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.TemporaryDirectory() + + def _run_is_doc_file(self, filename, include_table_of_contents=False): + test_file = Path(self.temp_dir.name).joinpath(filename) + with open(test_file, 'w'): + pass + return is_doc_file( + test_file, include_table_of_contents=include_table_of_contents) + + def test_not_md_file(self): + self.assertFalse(self._run_is_doc_file("doc.md")) + + def test_table_of_contents_file(self): + self.assertFalse(self._run_is_doc_file( + "./" + const.TABLE_CONTENTS, + include_table_of_contents=False)) + + self.assertTrue(self._run_is_doc_file( + "./" + const.TABLE_CONTENTS, + include_table_of_contents=True)) + + def test_doc_file(self): + self.assertTrue(self._run_is_doc_file('doc.help.md')) + + +class TestCreateSearchResultFile(TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.TemporaryDirectory() + cls.temp_dir_path = Path(cls.temp_dir.name) + with open(cls.temp_dir_path.joinpath('file1.help.md'), 'w') as file1: + file1.write('exist1') + with open(cls.temp_dir_path.joinpath( + '01 _ file2.help.md'), 'w') as file2: + file2.write('exist2') + with open(cls.temp_dir_path.joinpath('file3.md'), 'w') as file3: + file3.write('exist') + with open(cls.temp_dir_path.joinpath(const.SEARCH_RESULTS), 'w'): + pass + with open(cls.temp_dir_path.joinpath(const.TABLE_CONTENTS), 'w'): + pass + + def test_search_text_in_no_files(self): + search_result_filename = create_search_results_file( + self.temp_dir_path, 'non_exist') + self.assertEqual(search_result_filename.name, const.SEARCH_RESULTS) + with open(search_result_filename, 'r') as file: + content = file.read() + self.assertEqual( + content, + "# Search results\n\nText 'non_exist' not found.") + + def test_search_text_in_one_file(self): + search_result_filename = create_search_results_file( + self.temp_dir_path, 'exist1') + self.assertEqual(search_result_filename.name, const.SEARCH_RESULTS) + with open(search_result_filename, 'r') as file: + content = file.read() + self.assertEqual( + content, + "# Search results\n\n" + "Text 'exist1' found in the following files:\n\n" + "---------------------------\n\n" + "+ [file1](file1.help.md)\n\n") + + def test_search_text_in_all_files(self): + search_result_filename = create_search_results_file( + self.temp_dir_path, 'exist') + self.assertEqual(search_result_filename.name, const.SEARCH_RESULTS) + with open(search_result_filename, 'r') as file: + content = file.read() + self.assertEqual( + content, + "# Search results\n\n" + "Text 'exist' found in the following files:\n\n" + "---------------------------\n\n" + "+ [file2](01%20_%20file2.help.md)\n\n" + "+ [file1](file1.help.md)\n\n") + + def test_empty_search_text(self): + # This case is excluded in help_view + search_result_filename = create_search_results_file( + self.temp_dir_path, '') + self.assertEqual(search_result_filename.name, const.SEARCH_RESULTS) + with open(search_result_filename, 'r') as file: + content = file.read() + self.assertEqual( + content, + "# Search results\n\n" + "Text '' found in the following files:\n\n" + "---------------------------\n\n" + "+ [file2](01%20_%20file2.help.md)\n\n" + "+ [file1](file1.help.md)\n\n") + + def test_no_search_result_file_exist(self): + search_result_file_path = self.temp_dir_path.joinpath( + const.SEARCH_RESULTS) + search_result_file_path.unlink() # remove file + search_result_filename = create_search_results_file( + self.temp_dir_path, 'exist2') + self.assertEqual(search_result_filename.name, const.SEARCH_RESULTS) + with open(search_result_filename, 'r') as file: + content = file.read() + self.assertEqual( + content, + "# Search results\n\n" + "Text 'exist2' found in the following files:\n\n" + "---------------------------\n\n" + "+ [file2](01%20_%20file2.help.md)\n\n") + + +class TestCreateTableOfContentFile(TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.TemporaryDirectory() + cls.temp_dir_path = Path(cls.temp_dir.name) + with open(cls.temp_dir_path.joinpath('file1.help.md'), 'w') as file1: + file1.write('exist1') + with open(cls.temp_dir_path.joinpath( + '01 _ file2.help.md'), 'w') as file2: + file2.write('exist2') + with open(cls.temp_dir_path.joinpath('file3.md'), 'w') as file3: + file3.write('exist') + with open(cls.temp_dir_path.joinpath(const.SEARCH_RESULTS), 'w'): + pass + with open(cls.temp_dir_path.joinpath(const.TABLE_CONTENTS), 'w'): + pass + + def test_create_table_of_contents_file(self): + table_contents_path = self.temp_dir_path.joinpath(const.TABLE_CONTENTS) + f = io.StringIO() + with redirect_stdout(f): + create_table_of_content_file(self.temp_dir_path) + output = f.getvalue() + self.assertEqual( + f"{table_contents_path.as_posix()} has been created.", + output.strip()) + self.assertIn(table_contents_path, list(self.temp_dir_path.iterdir())) + with open(table_contents_path, 'r') as file: + content = file.read() + self.assertTrue(content.endswith( + "# Table of Contents\n\n" + "+ [Table of Contents](01%20_%20Table%20of%20Contents.help.md)" + "\n\n" + "+ [file2](01%20_%20file2.help.md)\n\n" + "+ [file1](file1.help.md)\n\n", + ) + )