diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e327ad33bd245b6561f3aec6dca6d6d64af4603d..3b97ea24effb9c811710df7028511e74a3f65fdd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,24 +31,24 @@ flake8: - flake8 --exclude sohstationviewer/view/ui,sohstationviewer/controller/core/ sohstationviewer - flake8 tests -python3.7: - image: python:3.7 +python3.8: + image: python:3.8 tags: - passoft stage: Build Env and Test script: - python -m unittest -python3.8: - image: python:3.8 +python3.9: + image: python:3.9 tags: - passoft stage: Build Env and Test script: - python -m unittest -python3.9: - image: python:3.9 +python3.10: + image: python:3.10 tags: - passoft stage: Build Env and Test diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..b5cb497879f5519fe5ed9761da3463249f5bddc2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include HISTORY.rst +include README.rst +include sohstationviewer/database/soh.db + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/setup.py b/setup.py index 339530a3b58b92191dc5402a8bd69ec0bc37adcd..957a26f83a193bc7f84312a692a53f5a00d616ac 100644 --- a/setup.py +++ b/setup.py @@ -20,9 +20,9 @@ setup( 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Natural Language :: English', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', ], description="Visualize State-of-Health packets from raw data recorded by " "different type of dataloggers.", @@ -38,7 +38,8 @@ setup( setup_requires=[], extras_require={ 'dev': [ - 'flake8' + 'flake8', + 'tox' ] }, license="GNU General Public License v3", @@ -47,7 +48,7 @@ setup( keywords='sohstationviewer', name='sohstationviewer', packages=find_packages(include=['sohstationviewer']), - url='https://git.passcal.nmt.edu/passoft/sohstationviewer', - version='2021.167', + url='https://git.passcal.nmt.edu/software_public/passoft/sohstationviewer', + version='2023.1.0.0', zip_safe=False, ) diff --git a/sohstationviewer/conf/constants.py b/sohstationviewer/conf/constants.py index 086c7f1d0689ba679dec2887bda285f14fc32450..d3ed42976c10be9c0d62b21e9b62ea6e693cad0c 100644 --- a/sohstationviewer/conf/constants.py +++ b/sohstationviewer/conf/constants.py @@ -1,6 +1,4 @@ -import sys -if sys.version_info.minor >= 8: - from typing import Literal +from typing import Literal # waveform pattern WF_1ST = 'A-HLM-V' @@ -74,11 +72,10 @@ Z_ORDER = {'AXIS_SPINES': 0, 'CENTER_LINE': 1, 'LINE': 2, 'GAP': 3, 'DOT': 3} # Distance from 'Hour' label to timestamp bar HOUR_TO_TMBAR_D = 100 +# day total limit for all tps channels to stay in one tab +DAY_LIMIT_FOR_TPS_IN_ONE_TAB = 180 # about half of a year # ================================================================= # # TYPING CONSTANT # ================================================================= # -if sys.version_info.minor >= 8: - ColorMode = Literal['B', 'W'] -else: - ColorMode = str +ColorMode = Literal['B', 'W'] diff --git a/sohstationviewer/controller/processing.py b/sohstationviewer/controller/processing.py index 05b42c809ce343d2722bb5ac2424b24f9e469116..3220254468498e0f04e9a8e63a481a0acca27639 100644 --- a/sohstationviewer/controller/processing.py +++ b/sohstationviewer/controller/processing.py @@ -7,7 +7,7 @@ import os import json import re from pathlib import Path -from typing import List, Optional, Dict, Tuple, Union +from typing import List, Optional, Dict, Tuple, Union, BinaryIO from PySide2.QtCore import QEventLoop, Qt from PySide2.QtGui import QCursor @@ -25,11 +25,19 @@ from sohstationviewer.model.mseed_data.record_reader_helper import \ from sohstationviewer.database.extract_data import get_signature_channels from sohstationviewer.controller.util import ( - validate_file, display_tracking_info, check_chan + validate_file, display_tracking_info, check_chan, ) from sohstationviewer.view.util.enums import LogType +# The data header is contained in field 2 of the fixed section of the header of +# a SEED data record. Seeing as an MSEED file is pretty much a collection of +# SEED data records, we know that the only valid data headers in an MSEED file +# are D, R, Q, or M. +# This information can be found in the description of the "Fixed Section of +# Data Header" in chapter 8 of the SEED V2.4 manual. +VALID_MSEED_DATA_HEADERS = {b'D', b'R', b'Q', b'M'} + def _read_mseed_chanids(path2file: Path, is_multiplex: bool, @@ -242,53 +250,47 @@ def get_data_type_from_file( is_multiplex: bool = None ) -> Optional[Tuple[Optional[str], bool]]: """ - + Exclude files for waveform data to improve performance - + Loop through each record for file - If MSeedRecordReader gives Error; check if the file is RT130, report - data_type is RT130 or else, return to continue checking on another - file. - If there're more than one channels in a file, this file is multiplex. - If found signature channel, report the data_type of the file. - :param path2file: absolute path to processed file - :param sign_chan_data_type_dict: dict of unique chan for data - type - :param is_multiplex: if the file is multiplex - :return: detected data type, channel from which data type is detected - """ - wf_chan_posibilities = ['FH', 'FN', # ≥ 1000 to < 5000 - 'GH', 'GL', # ≥ 1000 to < 5000 - 'DH', 'DL', # ≥ 250 to < 1000 - 'CH', 'CN', # ≥ 250 to < 1000 - 'EH', 'EL', 'EP', # ≥ 80 - 'SH', 'SL', 'SP', # ≥ 10 to < 80 - 'HH', 'HN', # ≥ 80 - 'BH', 'BN', # ≥ 10 to < 80 - 'MH', 'MN', 'MP', 'ML', - 'LH', 'LL', 'LP', 'LN', - 'VP', 'VL', 'VL', 'VH', - 'UN', 'UP', 'UL', 'UH'] + Get the data type contained in a given file. The procedure is given below. + - Assume the file is an MSeed file and loop through each record and get the + channel of each record. + + If the file turns out to not be an MSeed file, check if the file is + an RT130 file. If so, the data type is RT130 and the file is not + multiplexed. If not, return None to indicate that this is not a data + file. + + If more than one channels are found in the file, mark the file as + multiplexed. + - If a signature channel is found, the data type of the file has been + determined. Keep looping until the file is marked as multiplexed or until + all records are processed. + + This function has been rewritten for improved performance. The performance + is required because we do not skip waveform files. + :param path2file: path to the given file + :param sign_chan_data_type_dict: dict that maps each signature channel to + its corresponding data type + :param is_multiplex: whether the file is multiplexed + :return: None if the given file is neither a MSeed nor RT130 file, the + detected data type and whether the given file is multiplexed otherwise + """ file = open(path2file, 'rb') chans_in_stream = set() data_type = None + while 1: is_eof = (file.read(1) == b'') if is_eof: break file.seek(-1, 1) - current_record_start = file.tell() + try: - record = MSeedRecordReader(file) - except MSeedReadError: + chan = get_next_channel_from_mseed_file(file) + except ValueError: file.close() if reftek.core._is_reftek130(path2file): return 'RT130', False - return + return None - chan = record.record_metadata.channel - if any([wf_pattern in chan for wf_pattern in wf_chan_posibilities]): - # Skip checking waveform files which aren't signature channels - return None, False if is_multiplex is None: chans_in_stream.add(chan) if len(chans_in_stream) > 1: @@ -298,7 +300,45 @@ def get_data_type_from_file( if is_multiplex: file.close() return data_type, is_multiplex - move_to_next_record(file, current_record_start, record) file.close() is_multiplex = True if len(chans_in_stream) > 1 else False return data_type, is_multiplex + + +def get_next_channel_from_mseed_file(mseed_file: BinaryIO) -> str: + """ + Get the channel of the current record in a mseed file and move to the next + record. Also determines the byte order of the mseed file if that + information is not known. + + This function has been written with performance in mind. As such, it only + does a cursory check of whether the given file handle is that of an MSeed + file. + :param mseed_file: an MSeed file + :return: the channel of the current record of the given mseed file and + the byte order of the mseed file + """ + fixed_header = mseed_file.read(48) + blockette_1000 = mseed_file.read(8) + data_header = fixed_header[6:7] + # If the data header we read from a data record is not one of the valid + # ones, we know for sure that the file that contains the data record is not + # MSEED. + if data_header not in VALID_MSEED_DATA_HEADERS: + raise ValueError('Data header is not valid.') + blockette_type = blockette_1000[:2] + # Check that the blockette type is 1000. Because we do know the byte order + # of the file, we check the raw bytes directly instead of converting them + # into the integer they represent. + if blockette_type != b'\x03\xe8' and blockette_type != b'\xe8\x03': + raise ValueError('First blockette is not a blockette 1000.') + + channel = fixed_header[15:18].decode('ASCII') + + # The record length is stored in one byte, so byte order does not matter + # when decoding it. We choose big-endian here because it makes the code a + # bit shorter. + record_length_exponent = int.from_bytes(blockette_1000[6:7], 'big') + record_size = 2 ** record_length_exponent + mseed_file.seek(record_size - 56, 1) + return channel diff --git a/sohstationviewer/database/extract_data.py b/sohstationviewer/database/extract_data.py index 61db7005868c162a8b342676d93eae596e0baf7e..55997e90a683cc8d119fa26aebc419ebb0abe3ff 100755 --- a/sohstationviewer/database/extract_data.py +++ b/sohstationviewer/database/extract_data.py @@ -28,7 +28,7 @@ def get_chan_plot_info(org_chan_id: str, data_type: str, # Seeing as we only need one of these columns for a color mode, we only # pull the needed valueColors column from the database. value_colors_column = 'valueColors' + color_mode - o_sql = (f"SELECT channel, plotType, height, unit, linkedChan," + o_sql = (f"SELECT channel, plotType, height, unit," f" convertFactor, label, fixPoint, " f"{value_colors_column} AS valueColors " f"FROM Channels as C, Parameters as P") diff --git a/sohstationviewer/database/soh.db b/sohstationviewer/database/soh.db index ffa19fed38dd599c3f76f1c8a2b07dbe1527768d..4674efe8717e532b15c6018436d1834b1730edb9 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 index 7e8c600c39de6132b0f555dc727578b6bd5bd2aa..f4d4275c28d93e676b809db408b7ea13536cdf31 100644 --- a/sohstationviewer/model/data_loader.py +++ b/sohstationviewer/model/data_loader.py @@ -31,11 +31,14 @@ class DataLoaderWorker(QtCore.QObject): is_multiplex: Optional[bool], list_of_dir: List[Path], list_of_rt130_paths: List[Path], req_wf_chans: Union[List[str], List[int]] = [], - req_soh_chans: List[str] = [], read_start: float = 0, - gap_minimum: Optional[float] = None, + req_soh_chans: List[str] = [], + read_start: float = 0, read_end: float = constants.HIGHEST_INT, + gap_minimum: Optional[float] = None, include_mp123: bool = False, include_mp456: bool = False, - rt130_waveform_data_req: bool = False, parent_thread=None): + rt130_waveform_data_req: bool = False, + rt130_log_files: List[Path] = [], + parent_thread=None): super().__init__() self.data_type = data_type self.tracking_box = tracking_box @@ -49,7 +52,8 @@ class DataLoaderWorker(QtCore.QObject): self.read_end = read_end self.include_mp123 = include_mp123 self.include_mp456 = include_mp456 - self. rt130_waveform_data_req = rt130_waveform_data_req + self.rt130_waveform_data_req = rt130_waveform_data_req + self.rt130_log_files = rt130_log_files self.parent_thread = parent_thread # display_tracking_info updates a QtWidget, which can only be done in # the read. Since self.run runs in a background thread, we need to use @@ -86,6 +90,7 @@ class DataLoaderWorker(QtCore.QObject): include_mp123zne=self.include_mp123, include_mp456uvw=self.include_mp456, rt130_waveform_data_req=self.rt130_waveform_data_req, + rt130_log_files=self.rt130_log_files, creator_thread=self.parent_thread, notification_signal=self.notification, pause_signal=self.button_dialog @@ -141,7 +146,8 @@ class DataLoader(QtCore.QObject): read_end: float = constants.HIGHEST_INT, include_mp123: bool = False, include_mp456: bool = False, - rt130_waveform_data_req: bool = False): + rt130_waveform_data_req: bool = False, + rt130_log_files: List[Path] = []): """ Initialize the data loader. Construct the thread and worker and connect them together. Separated from the actual loading of the data to allow @@ -158,6 +164,8 @@ class DataLoader(QtCore.QObject): :param read_end: the time after which no data is read :param include_mp123: if mass position channels 1,2,3 are requested :param include_mp456: if mass position channels 4,5,6 are requested + :param rt130_log_files: list of paths to RT130 log files selected by + the user """ if self.running: # TODO: implement showing an error window @@ -180,6 +188,7 @@ class DataLoader(QtCore.QObject): include_mp123=include_mp123, include_mp456=include_mp456, rt130_waveform_data_req=rt130_waveform_data_req, + rt130_log_files=rt130_log_files, parent_thread=self.thread ) diff --git a/sohstationviewer/model/general_data/general_data.py b/sohstationviewer/model/general_data/general_data.py index 36c33da88cb47de13146566a27808aded3e28675..99bae81271528c5fe4869d1392354d165d003888 100644 --- a/sohstationviewer/model/general_data/general_data.py +++ b/sohstationviewer/model/general_data/general_data.py @@ -50,6 +50,7 @@ class GeneralData(): include_mp123zne: bool = False, include_mp456uvw: bool = False, rt130_waveform_data_req: bool = False, + rt130_log_files: List[Path] = [], creator_thread: Optional[QtCore.QThread] = None, notification_signal: Optional[QtCore.Signal] = None, pause_signal: Optional[QtCore.Signal] = None, @@ -70,6 +71,7 @@ class GeneralData(): :param include_mp123zne: if mass position channels 1,2,3 are requested :param include_mp456uvw: if mass position channels 4,5,6 are requested :param rt130_waveform_data_req: flag for RT130 to read waveform data + :param rt130_log_files: list of paths to log files chosen by the user :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 @@ -91,6 +93,7 @@ class GeneralData(): self.include_mp123zne = include_mp123zne self.include_mp456uvw = include_mp456uvw self.rt130_waveform_data_req = rt130_waveform_data_req + self.rt130_log_files = rt130_log_files self.on_unittest = on_unittest if creator_thread is None: err_msg = ( diff --git a/sohstationviewer/model/reftek_data/log_info.py b/sohstationviewer/model/reftek_data/log_info.py index 066435044a99c665c77cf6115ae8b58300f27c55..77d3d999e1c3d8a5df77f45f085f88fb79b069bd 100644 --- a/sohstationviewer/model/reftek_data/log_info.py +++ b/sohstationviewer/model/reftek_data/log_info.py @@ -466,6 +466,11 @@ class LogInfo(): epoch = self.simple_read(line)[1] if epoch: self.add_chan_info('GPS Lk/Unlk', epoch, 1, idx) + elif "EXTERNAL CLOCK CYCLE" in line: + # GPS Clock Power + epoch = self.simple_read(line)[1] + if epoch: + self.add_chan_info('GPS Lk/Unlk', epoch, 0, idx) elif any(x in line for x in ["EXTERNAL CLOCK POWER IS TURNED ON", "EXTERNAL CLOCK WAKEUP", @@ -488,11 +493,6 @@ class LogInfo(): if epoch: self.add_chan_info('GPS On/Off/Err', epoch, -1, idx) - elif "EXTERNAL CLOCK CYCLE" in line: - epoch = self.simple_read(line)[1] - if epoch: - self.add_chan_info('GPS Clock Power', epoch, 1, idx) - # ================= VERSIONS ============================== elif any(x in line for x in ["REF TEK", "CPU SOFTWARE"]): cpu_ver = self.read_cpu_ver(line) diff --git a/sohstationviewer/model/reftek_data/reftek.py b/sohstationviewer/model/reftek_data/reftek.py index 4255dd801f615f8458e0dfbfa3d258d8ab3942ed..15d3e067b49edd7216ae305b83cdc786b9407fe0 100755 --- a/sohstationviewer/model/reftek_data/reftek.py +++ b/sohstationviewer/model/reftek_data/reftek.py @@ -8,15 +8,20 @@ import numpy as np from obspy.core import Stream from sohstationviewer.conf import constants +from sohstationviewer.model.reftek_data.reftek_reader.log_file_reader import ( + LogFileReader, process_mass_poss_line, +) from sohstationviewer.view.util.enums import LogType -from sohstationviewer.model.general_data.general_data import \ - GeneralData, ThreadStopped, ProcessingDataError +from sohstationviewer.model.general_data.general_data import ( + GeneralData, ThreadStopped, ProcessingDataError, +) from sohstationviewer.model.general_data.general_data_helper import read_text from sohstationviewer.model.reftek_data.reftek_helper import ( check_reftek_header, read_reftek_stream, - retrieve_gaps_from_stream_header) + retrieve_gaps_from_stream_header, +) from sohstationviewer.model.reftek_data.reftek_reader import core, soh_packet from sohstationviewer.model.reftek_data.log_info import LogInfo @@ -26,6 +31,7 @@ class RT130(GeneralData): read and process reftek file into object with properties can be used to plot SOH data, mass position data, waveform data and gaps """ + def __init__(self, *args, **kwarg): self.EH = {} super().__init__(*args, **kwarg) @@ -49,8 +55,9 @@ class RT130(GeneralData): gaps_by_key_chan: gap list for each key/chan_id to separate data at gaps, overlaps """ - self.gaps_by_key_chan: Dict[Union[str, Tuple[str, str]], - Dict[str, List[List[int]]]] = {} + DataKey = Union[str, Tuple[str, str]] + GapsByChannel = Dict[str, List[List[int]]] + self.gaps_by_key_chan: Dict[DataKey, GapsByChannel] = {} """ found_data_streams: list of data streams found to help inform user why the selected data streams don't show up @@ -62,7 +69,12 @@ class RT130(GeneralData): def processing_data(self): if self.creator_thread.isInterruptionRequested(): raise ThreadStopped() - self.read_folders() + # We separate the reading of log files and real data sets because their + # formats are very different. + if self.rt130_log_files: + self.read_log_files() + else: + self.read_folders() self.selected_key = self.select_key() if self.creator_thread.isInterruptionRequested(): @@ -102,6 +114,64 @@ class RT130(GeneralData): # this happens when there is text or ascii only in the data self.data_time[key] = [self.read_start, self.read_end] + def read_log_files(self): + """ + Read data from self.rt130_log_files and store it in self.log_data + """ + for log_file in self.rt130_log_files: + reader = LogFileReader(log_file) + reader.read() + file_key = (reader.station_code, reader.experiment_number) + self.populate_cur_key_for_all_data(file_key) + # We are creating the value for both keys 'SOH' and 'EHET' in this + # method (unlike how RT130 is usually read), so we only need to do + # one check. + if 'EHET' not in self.log_data[file_key]: + self.log_data[file_key]['EHET'] = [(1, reader.eh_et_lines)] + self.log_data[file_key]['SOH'] = [(1, reader.soh_lines)] + else: + # Just in case we are reading multiple files with the same key. + + # We are going to assume that the log files are read in order. + # That makes dealing with multiple files a lot easier. We can + # always sort the processed SOH data if this assumption is + # wrong. + key_file_count = self.log_data[file_key]['EHET'][-1][0] + 1 + self.log_data[file_key]['EHET'].append( + (key_file_count, reader.eh_et_lines) + ) + self.log_data[file_key]['SOH'].append( + (key_file_count, reader.soh_lines) + ) + self.process_mass_pos_log_lines(file_key, reader.masspos_lines) + + def process_mass_pos_log_lines(self, key: Tuple[str, str], + masspos_lines: List[str]): + """ + Process mass-position log lines and store the result in + self.masspos_data. + + :param key: the current data set key + :param masspos_lines: the mass-position lines to process + """ + # Mass-position channels is suffixed by a number from 1 to 6. + processed_masspos_data = process_mass_poss_line(masspos_lines) + for i, (times, data) in enumerate(processed_masspos_data, start=1): + if len(data) == 0: + continue + masspos_chan = f'MassPos{i}' + trace = {'startTmEpoch': times[0], 'endTmEpoch': times[-1], + 'data': data, 'times': times} + if masspos_chan not in self.mass_pos_data[key]: + self.mass_pos_data[key][masspos_chan] = ( + {'tracesInfo': []} + ) + self.mass_pos_data[key][masspos_chan]['samplerate'] = 0 + trace['startTmEpoch'] = times[0] + trace['endTmEpoch'] = times[-1] + traces = self.mass_pos_data[key][masspos_chan]['tracesInfo'] + traces.append(trace) + def read_folders(self) -> None: """ Read data from list_of_dir or list_of_rt130_paths for soh, @@ -200,6 +270,15 @@ class RT130(GeneralData): cur_key = (d['unit_id'].decode(), f"{d['experiment_number']}") self.populate_cur_key_for_all_data(cur_key) + current_packet = soh_packet.SOHPacket.from_data(d) + if getattr(current_packet, 'from_old_firmware', False): + old_firmware_message = ( + 'It looks like this data set comes from an RT130 with ' + 'firmware version earlier than 2.9.0.', + LogType.INFO + ) + if old_firmware_message not in self.processing_log: + self.processing_log.append(old_firmware_message) logs = soh_packet.SOHPacket.from_data(d).__str__() if 'SOH' not in self.log_data[cur_key]: self.log_data[cur_key]['SOH'] = [] @@ -245,7 +324,7 @@ class RT130(GeneralData): """ ind_ehet = [ind for ind, val in enumerate(rt130._data["packet_type"]) - if val in [b"EH"]] # only need event header + if val in [b"EH"]] # only need event header nbr_dt_samples = sum( [rt130._data[ind]["number_of_samples"] for ind in range(0, len(rt130._data)) diff --git a/sohstationviewer/model/reftek_data/reftek_reader/log_file_reader.py b/sohstationviewer/model/reftek_data/reftek_reader/log_file_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..c7819874d14de1d65516932dcd097a552988d0ab --- /dev/null +++ b/sohstationviewer/model/reftek_data/reftek_reader/log_file_reader.py @@ -0,0 +1,293 @@ +from pathlib import Path +from typing import List, Literal, Optional, Dict, Callable, Tuple + +import numpy as np +from obspy import UTCDateTime + +# The possible formats for a log file. +LogFileFormat = Literal['rt2ms', 'logpeek', 'sohstationviewer'] +# These packets can be found in section 4 of the RT130 record documentation. +RT130_PACKETS = ['SH', 'SC', 'OM', 'DS', 'AD', 'CD', 'FD', 'EH', 'ET'] +# The lists of event lines, SOH lines, and mass-position lines in a packet. +SeparatedPacketLines = Tuple[List[str], List[str], List[str]] + + +def detect_log_packet_format(packet: List[str]) -> Optional[LogFileFormat]: + """ + Detect the format of a log file packet. The format can be either rt2ms', + logpeek's, or SOHStationViewer's. + + :param packet: a packet extracted from a log file. + :return: the format of packet. Can be either 'rt2ms', 'logpeek', or + 'sohstationviewer'. + """ + # We want to take advantage of the metadata written by the various programs + # as much as possible. + if packet[0].startswith('logpeek'): + return 'logpeek' + elif packet[0].startswith('rt2ms'): + return 'rt2ms' + elif packet[0].startswith('sohstationviewer'): + return 'sohstationviewer' + + packet_start = packet[0] + + # The first line of a packet in a log file generated by rt2ms starts with + # a 2-letter packet type. That is then followed by an empty space and the + # string 'exp'. + if packet_start[:2] in RT130_PACKETS and packet_start[3:6] == 'exp': + return 'rt2ms' + + # SOHStationViewer stores all events' info at the end of its log file, and + # so if we see the line + # Events: + # at the start of a packet, we know the log file is from SOHStationViewer. + if packet_start.startswith('Events:'): + return 'sohstationviewer' + # Unlike logpeek, we are writing the mass position in its own packet. So, + # a packet that starts with mass position data would be an SOHStationViewer + # packet. + if packet_start.startswith('LPMP'): + return 'sohstationviewer' + + # Logpeek write its events' info and mass-position data right after an SH + # packet. + packet_end = packet[-1] + packet_end_with_event_info = (packet_end.startswith('DAS') or + packet_end.startswith('WARNING')) + packet_end_with_mass_pos = packet_end.startswith('LPMP') + packet_end_special = packet_end_with_event_info or packet_end_with_mass_pos + if packet_start.startswith('State of Health') and packet_end_special: + return 'logpeek' + + +def parse_log_packet_unknown_format(packet: List[str]) -> SeparatedPacketLines: + """ + Parse a log packet assuming that the format of the log file is unknown. In + this case, all the lines in the packet are SOH lines. + :param packet: list of lines in the packet + :return: the lists of event lines, SOH lines, and mass-position lines in + packet + """ + eh_et_lines = [] + soh_lines = packet + masspos_lines = [] + return eh_et_lines, soh_lines, masspos_lines + + +def parse_log_packet_logpeek(packet: List[str]) -> SeparatedPacketLines: + """ + Parse a log packet assuming that the log file comes from logpeek. In this + case, a packet can be composed of SOH lines, event info lines, and + mass-position lines. + :param packet: list of lines in the packet + :return: the lists of event lines, SOH lines, and mass-position lines in + packet + """ + eh_et_lines = [] + soh_lines = [] + masspos_lines = [] + for line in packet: + if line.startswith('DAS: '): + eh_et_lines.append(line) + elif line.startswith('LPMP'): + masspos_lines.append(line) + else: + soh_lines.append(line) + + return eh_et_lines, soh_lines, masspos_lines + + +def parse_log_packet_rt2ms(packet: List[str]) -> SeparatedPacketLines: + """ + Parse a log packet assuming that the log file comes from rt2ms. In this + case, SOH data and event info are stored in separate packets. The first + line of a packet is a header that contains some metadata. + :param packet: list of lines in the packet + :return: the lists of event lines, SOH lines, and mass-position lines in + packet + """ + eh_et_lines = [] + soh_lines = [] + masspos_lines = [] + if packet[0].startswith('EH') or packet[0].startswith('ET'): + # The event info is summarized in the last line of an event info packet + eh_et_lines = [packet[-1]] + else: + # The header is not counted as an SOH line. + soh_lines = packet[1:] + + return eh_et_lines, soh_lines, masspos_lines + + +def parse_log_packet_sohstationviewer(packet: List[str] + ) -> SeparatedPacketLines: + """ + Parse a log packet assuming that the log file comes from sohstationviewer. + In this case, the file is composed mainly of SOH packets, with the event + info lines being written at the end of the file. + :param packet: list of lines in the packet + :return: the lists of event lines, SOH lines, and mass-position lines in + packet + """ + eh_et_lines = [] + soh_lines = [] + masspos_lines = [] + if packet[0].startswith('Events:'): + eh_et_lines = packet[1:] + else: + soh_lines = packet + return eh_et_lines, soh_lines, masspos_lines + + +class LogFile: + """ + Iterator over a log file. + """ + def __init__(self, file_path: Path): + self.file_path = file_path + self.file = open(file_path) + + def __iter__(self): + return self + + def __next__(self) -> List[str]: + line = self.file.readline() + if line == '': + self.file.close() + raise StopIteration + # The log packets are separated by empty lines, so we know we have + # reached the next packet when we find a non-empty line. + while line == '\n': + line = self.file.readline() + packet = [] + # We have to check that we are not at the end of the file as well. + while line != '\n' and line != '': + packet.append(line) + line = self.file.readline() + if line == '': + break + # If there are more than one blank lines at the end of a log file, the + # last packet will be empty. This causes problem if the log file came + # from rt2ms or SOHStationViewer. + if not packet: + self.file.close() + raise StopIteration + return packet + + def __del__(self): + """ + Close the file handle when this iterator is garbage collected just to + be absolutely sure that no memory is leaked. + """ + self.file.close() + + +# A function that take in a log file packet and separate it into event info +# lines, SOH lines, and mass-position lines. +Parser = Callable[[List[str]], SeparatedPacketLines] +# Mapping each log packet type to its corresponding parser. +PACKET_PARSERS: Dict[Optional[LogFileFormat], Parser] = { + None: parse_log_packet_unknown_format, + 'sohstationviewer': parse_log_packet_sohstationviewer, + 'rt2ms': parse_log_packet_rt2ms, + 'logpeek': parse_log_packet_logpeek +} + + +def get_experiment_number(soh_lines: List[str]): + """ + Get the experiment number from the list of SOH lines in a packet. + + :param soh_lines: the list of SOH lines from a packet + :return: the experiment number if the packet has the correct format, None + otherwise + """ + # The experiment number only exists in SC packets. + if not soh_lines[0].startswith('Station Channel Definition'): + return None + + # The experiment number can be in the first (rt2ms) or second (logpeek, + # SOHStationViewer) line after the header. These lines are indented, so we + # have to strip them of whitespace. + if soh_lines[1].strip().startswith('Experiment Number ='): + experiment_number_line = soh_lines[1].split() + elif soh_lines[2].strip().startswith('Experiment Number ='): + experiment_number_line = soh_lines[2].split() + else: + return None + + # If the experiment number is not recorded, we know that it will be 0. In + # order to not have too many return statements, we add 0 to the experiment + # line instead of returning it immediately. + if len(experiment_number_line) < 4: + experiment_number_line.append('0') + + return experiment_number_line[-1] + + +class LogFileReader: + """ + Class that reads a log file. + """ + def __init__(self, file_path: Path): + self.file_path = file_path + self.log_file_type: Optional[LogFileFormat] = None + self.eh_et_lines: List[str] = [] + self.soh_lines: List[str] = [] + self.masspos_lines: List[str] = [] + self.station_code: Optional[str] = None + self.experiment_number: Optional[str] = None + + def read(self) -> None: + """ + Read the log file. + """ + log_file = LogFile(self.file_path) + for packet in log_file: + if self.log_file_type is None: + self.log_file_type = detect_log_packet_format(packet) + + parser = PACKET_PARSERS[self.log_file_type] + eh_et_lines, soh_lines, masspos_lines = parser(packet) + if self.station_code is None and soh_lines: + # All header lines contain the station code at the end. + self.station_code = soh_lines[0].split(' ')[-1].strip() + if self.experiment_number is None and soh_lines: + found_experiment_number = get_experiment_number(soh_lines) + self.experiment_number = found_experiment_number + self.eh_et_lines.extend(eh_et_lines) + self.soh_lines.extend(soh_lines) + # We need to add a new line between two blocks of SOH lines to + # separate them. This makes it so that we don't have to manually + # separate the SOH lines blocks during processing. + self.soh_lines.append('\n') + self.masspos_lines.extend(masspos_lines) + + +def process_mass_poss_line(masspos_lines: List[str] + ) -> List[Tuple[np.ndarray, np.ndarray]]: + """ + Process a list of mass-position lines into a list of mass-position data, + sorted by the channel suffix + :param masspos_lines: a list of mass-position log lines + :return: a list of mass-position data, sorted by the channel suffix + """ + # There can be 6 mass-position channels. + mass_pos_data = [] + for masspos_num in range(1, 7): + current_lines = [line.split() + for line + in masspos_lines + if int(line.split()[2]) == masspos_num] + data = np.asarray([line[3] for line in current_lines], dtype=float) + time_format = '%Y:%j:%H:%M:%S.%f' + times = np.array( + # strptime requires the microsecond component to have 6 digits, but + # a mass-position log lines only have 3 digits for microsecond. So, + # we have to pad the time with 0s. + [UTCDateTime.strptime(line[1] + '000', time_format).timestamp + for line in current_lines] + ) + mass_pos_data.append((times, data)) + return mass_pos_data diff --git a/sohstationviewer/model/reftek_data/reftek_reader/soh_packet.py b/sohstationviewer/model/reftek_data/reftek_reader/soh_packet.py index 7900f68abf02a98e7ea0e572ed132703d2bb11f6..eb9515d5d61f9bc1e0f6295682c7a7fed9807d82 100644 --- a/sohstationviewer/model/reftek_data/reftek_reader/soh_packet.py +++ b/sohstationviewer/model/reftek_data/reftek_reader/soh_packet.py @@ -788,6 +788,7 @@ class FDPacket(SOHPacket): def __init__(self, data: np.ndarray) -> None: # Filter description payload self._data = data + self.from_old_firmware = False payload = self._data["payload"] start_fd = 0 for name, (length, converter) in FD_PAYLOAD.items(): @@ -796,10 +797,23 @@ class FDPacket(SOHPacket): try: data = converter(data.tobytes()) except ValueError: - msg = ("FD packet, wrong conversion routine for input " - "variable: {}".format(name)) + if name == 'implement_time': + msg = (f"FD packet, failed to convert input variable: " + f"{name}. It looks like this data set comes " + f"from an RT130 with firmware version earlier " + f"than 2.9.0." + ) + self.from_old_firmware = True + # We have to set FDPacket.implement_time to None + # because setting it to an empty string causes + # FDPacket.time_tag to fail, which renders the data set + # unreadable. + data = None + else: + msg = (f"FD packet, wrong conversion routine for input" + f" variable: {name}") + data = '' warnings.warn(msg) - data = '' setattr(self, name, data) start_fd = start_fd + length # Detailed info for filter block(s) (fb) diff --git a/sohstationviewer/view/main_window.py b/sohstationviewer/view/main_window.py index 93064bebf4a546a4bb6ee06e9c3e54cca6c975f0..afb1f1c54266969e8942732e42ea810d6d596d10 100755 --- a/sohstationviewer/view/main_window.py +++ b/sohstationviewer/view/main_window.py @@ -39,6 +39,9 @@ from sohstationviewer.view.util.functions import ( check_chan_wildcards_format, check_masspos, ) from sohstationviewer.view.channel_prefer_dialog import ChannelPreferDialog +from sohstationviewer.view.create_muti_buttons_dialog import ( + create_multi_buttons_dialog +) from sohstationviewer.controller.processing import detect_data_type from sohstationviewer.controller.util import ( @@ -85,6 +88,10 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): """ self.selected_rt130_paths: List[str] = [] """ + rt130_log_files: list of log files to be read + """ + self.rt130_log_files: List[Path] = [] + """ data_type: str - type of data set """ self.data_type: str = 'Unknown' @@ -296,6 +303,7 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.set_open_files_list_texts() # self.search_button.setEnabled(not is_from_data_card_checked) + self.log_checkbox.setEnabled(not is_from_data_card_checked) self.search_line_edit.setEnabled(not is_from_data_card_checked) # QLineEdit does not change its color when it is disabled unless # there is text inside, so we have to do it manually. @@ -311,6 +319,17 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): if not is_from_data_card_checked and self.search_line_edit.text(): self.filter_folder_list(self.search_line_edit.text()) + @QtCore.Slot() + def on_log_file_checkbox_toggled(self, is_checked): + self.open_files_list.clear() + self.set_open_files_list_texts() + + # We only disable the from data card checkbox because it has behavior + # that conflicts with the log file checkbox. Other widgets are kept + # enabled because they don't break what the log file checkbox does. + self.from_data_card_check_box.setEnabled(not is_checked) + self.filter_folder_list(self.search_line_edit.text()) + @QtCore.Slot() def all_wf_chans_clicked(self): if self.all_wf_chans_check_box.isChecked(): @@ -482,8 +501,16 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): """ self.list_of_dir = [''] self.selected_rt130_paths = [] + self.rt130_log_files = [] root_dir = Path(self.curr_dir_line_edit.text()) - if self.rt130_das_dict != {}: + if self.log_checkbox.isChecked(): + for item in self.open_files_list.selectedItems(): + log_abspath = root_dir.joinpath(item.file_path) + self.rt130_log_files.append(log_abspath) + # Log files can only come from programs that work with RT130 data. + self.data_type = 'RT130' + self.is_multiplex = False + elif self.rt130_das_dict != {}: # create selected_rt130_paths from the selected rt130 das names for item in self.open_files_list.selectedItems(): # each item.file_path is a rt130 das name for this case @@ -508,24 +535,55 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): else: self.list_of_dir = [root_dir.joinpath('sdata')] else: - # When "From Memory Card" checkbox is checked, sub directories of the - # current dir (root_dir) will be displayed in File List box. + # When "From Memory Card" checkbox is checked, sub directories of + # the current dir (root_dir) will be displayed in File List box. # User can select one or more sub-directories to process from. self.list_of_dir = [ root_dir.joinpath(item.text()) for item in self.open_files_list.selectedItems()] + has_data_sdata = False + for folder in self.list_of_dir: + has_data_sdata = check_data_sdata(folder) + if has_data_sdata: + break + if has_data_sdata: + if len(self.list_of_dir) > 1: + msg = ("More than one folders are selected. At least one " + "of them has sub-folders data/ and sdata/.\n\n" + "It confuses SOH View.\n\n" + "SOH View will read only one folder with " + "sub-folder data/ and sdata/.") + raise Exception(msg) + else: + msg = ("The selected folder contains 2 data folders:\n" + "'data/', 'sdata/'.\n\n" + "Please select one of them to read data from.") + result = create_multi_buttons_dialog( + msg, ['data/', 'sdata/'], has_abort=True) + if result == 0: + self.list_of_dir = [ + self.list_of_dir[0].joinpath('data')] + elif result == 1: + self.list_of_dir = [ + self.list_of_dir[0].joinpath('sdata')] + else: + raise Exception('Process has been cancelled by user.') + if self.list_of_dir == []: msg = "No directories have been selected." raise Exception(msg) - if self.rt130_das_dict == {}: + # Log files don't have a data type that can be detected, so we don't + # detect the data type if we are reading them. + if self.rt130_das_dict == {} and not self.log_checkbox.isChecked(): self.data_type, self.is_multiplex = detect_data_type( self.list_of_dir) def clear_plots(self): self.plotting_widget.clear() self.waveform_dlg.plotting_widget.clear() - self.tps_dlg.plotting_widget.clear() + for tps_widget in self.tps_dlg.tps_widget_dict.values(): + tps_widget.clear() def cancel_loading(self): display_tracking_info(self.tracking_info_text_browser, @@ -627,24 +685,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.cancel_loading() return - """ - temporary skip check_size for it take too long. - dir_size = sum(get_dir_size(str(dir))[0] for dir in self.dir_names) - if dir_size > constants.BIG_FILE_SIZE: - data_too_big_dialog = QMessageBox() - data_too_big_dialog.setText('Chosen data set is very big. It ' - 'might take a while to finish reading ' - 'and plotting everything.') - data_too_big_dialog.setInformativeText('Do you want to proceed?') - data_too_big_dialog.setStandardButtons(QMessageBox.Yes | - QMessageBox.Abort) - data_too_big_dialog.setDefaultButton(QMessageBox.Abort) - data_too_big_dialog.setIcon(QMessageBox.Question) - ret = data_too_big_dialog.exec() - if ret == QMessageBox.Abort: - self.cancel_loading() - return - """ self.req_soh_chans = self.get_requested_soh_chan() if self.req_soh_chans is None: self.cancel_loading() @@ -680,7 +720,8 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): read_end=self.end_tm, include_mp123=self.mass_pos_123zne_check_box.isChecked(), include_mp456=self.mass_pos_456uvw_check_box.isChecked(), - rt130_waveform_data_req=rt130_waveform_data_req + rt130_waveform_data_req=rt130_waveform_data_req, + rt130_log_files=self.rt130_log_files ) self.data_loader.worker.finished.connect(self.data_loaded) @@ -802,7 +843,8 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.is_plotting_soh = True self.plotting_widget.set_colors(self.color_mode) self.waveform_dlg.plotting_widget.set_colors(self.color_mode) - self.tps_dlg.plotting_widget.set_colors(self.color_mode) + for tps_widget in self.tps_dlg.tps_widget_dict.values(): + tps_widget.set_colors(self.color_mode) self.gps_dialog.set_colors(self.color_mode) d_obj = self.data_object @@ -835,7 +877,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): if self.tps_check_box.isChecked(): self.is_plotting_tps = True - peer_plotting_widgets.append(self.tps_dlg.plotting_widget) self.tps_dlg.set_data( self.data_type, ','.join([str(d) for d in self.list_of_dir])) self.tps_dlg.show() @@ -843,12 +884,14 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): # we can't simply reset all flags. Instead, we use an intermediate # method that check whether all plots have been stopped before # resetting the is_stopping flag. - tps_widget = self.tps_dlg.plotting_widget - tps_widget.stopped.connect(self.reset_is_plotting_tps) - tps_widget.stopped.connect(self.check_if_all_stopped) - self.tps_dlg.plotting_widget.plot_channels( + for tps_widget in self.tps_dlg.tps_widget_dict.values(): + tps_widget.stopped.connect(self.reset_is_plotting_tps) + tps_widget.stopped.connect(self.check_if_all_stopped) + self.tps_dlg.plot_channels( d_obj, sel_key, self.start_tm, self.end_tm) + for tps_widget in self.tps_dlg.tps_widget_dict.values(): + peer_plotting_widgets.append(tps_widget) self.add_action_to_forms_menu('TPS Plot', self.tps_dlg) else: self.tps_dlg.hide() @@ -872,8 +915,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.plotting_widget.set_peer_plotting_widgets(peer_plotting_widgets) self.waveform_dlg.plotting_widget.set_peer_plotting_widgets( peer_plotting_widgets) - self.tps_dlg.plotting_widget.set_peer_plotting_widgets( - peer_plotting_widgets) + for tps_widget in self.tps_dlg.tps_widget_dict.values(): + tps_widget.set_peer_plotting_widgets( + peer_plotting_widgets) processing_log = (self.processing_log + d_obj.processing_log + @@ -945,7 +989,15 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.sdata_radio_button.setEnabled(False) try: self.rt130_das_dict = rt130_find_cf_dass(path) - if len(self.rt130_das_dict) != 0: + if self.log_checkbox.isChecked(): + for dent in pathlib.Path(path).iterdir(): + # Currently, we only read file that has the extension .log. + # Dealing with general file is a lot more difficult and is + # not worth the time it takes to do so. + if dent.is_file() and dent.name.endswith('.log'): + self.open_files_list.addItem(FileListItem(dent)) + + elif len(self.rt130_das_dict) != 0: for rt130_das in self.rt130_das_dict: self.open_files_list.addItem(FileListItem(rt130_das)) @@ -959,7 +1011,6 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): # Baler/B44 memory stick self.data_radio_button.setEnabled(True) self.sdata_radio_button.setEnabled(True) - else: for dent in pathlib.Path(path).iterdir(): if not dent.is_dir() or dent.name.startswith('.'): @@ -1029,8 +1080,9 @@ class MainWindow(QtWidgets.QMainWindow, UIMainWindow): self.waveform_dlg.plotting_widget.thread_pool.waitForDone() if self.is_plotting_tps: - self.tps_dlg.plotting_widget.request_stop() - self.tps_dlg.plotting_widget.thread_pool.waitForDone() + for tps_widget in self.tps_dlg.tps_widget_dict.values(): + tps_widget.request_stop() + tps_widget.thread_pool.waitForDone() # close all remaining windows for window in QtWidgets.QApplication.topLevelWidgets(): diff --git a/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py b/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py index bb4271bb6de7cae5982cec29ae7d0032a4d5ea47..5212a06ae50b7da367fbda14c3ab5cc5de0cab30 100644 --- a/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py +++ b/sohstationviewer/view/plotting/plotting_widget/multi_threaded_plotting_widget.py @@ -155,9 +155,6 @@ class MultiThreadedPlottingWidget(PlottingWidget): f"{', '.join( not_plot_chans)}") self.processing_log.append((msg, LogType.WARNING)) - if is_plotting_data1: - self.move_soh_channels_with_link_to_the_end(chan_order) - for chan_id in chan_order: if 'chan_db_info' not in plotting_data[chan_id]: continue @@ -170,12 +167,6 @@ class MultiThreadedPlottingWidget(PlottingWidget): channel_processor.finished.connect(self.process_channel) channel_processor.stopped.connect(self.has_stopped) - def move_soh_channels_with_link_to_the_end(self, chan_order): - """ - This only need for soh channels - """ - pass - def plot_channels(self, d_obj, key, start_tm, end_tm, time_ticks_total, pref_order=[]): """ diff --git a/sohstationviewer/view/plotting/plotting_widget/plotting.py b/sohstationviewer/view/plotting/plotting_widget/plotting.py index f0906d6b61693049e8ab6c93b2127358c1795ed3..f0249f0f566d7d2330ac7441c04c0acaae0ec6b0 100644 --- a/sohstationviewer/view/plotting/plotting_widget/plotting.py +++ b/sohstationviewer/view/plotting/plotting_widget/plotting.py @@ -1,5 +1,5 @@ # class with all plotting functions -from typing import Dict, Optional +from typing import Dict import numpy as np from matplotlib.axes import Axes @@ -50,31 +50,24 @@ class Plotting: return ax def plot_multi_color_dots_base( - self, c_data: Dict, chan_db_info: Dict, ax: Optional[Axes], - linked_ax: Optional[Axes], equal_upper: bool = True): + self, c_data: Dict, chan_db_info: Dict, equal_upper: bool = True): """ plot dots in center with colors defined by valueColors in database: Color codes are defined in colorSettings and limitted in 'valColRE' in dbSettings.py + :param c_data: data of the channel which includes down-sampled - data in keys 'times' and 'data'. + (if needed) data in keys 'times' and 'data'. :param chan_db_info: info of channel from DB - :param ax: axes to plot channel - :param linked_ax: axes of another channel - linked to this channel => both channels' will be plotted on the - same axes :param equal_upper: if True, plot_from_value_color_equal_on_upper_bound will be used otherwise, plot_from_value_color_equal_on_lower_bound will be use :return: ax in which the channel is plotted """ - if linked_ax is not None: - ax = linked_ax - if ax is None: - plot_h = self.plotting_axes.get_height(chan_db_info['height']) - ax = self.plotting_axes.create_axes( - self.parent.plotting_bot, plot_h, - has_min_max_lines=False) + plot_h = self.plotting_axes.get_height(chan_db_info['height']) + ax = self.plotting_axes.create_axes( + self.parent.plotting_bot, plot_h, + has_min_max_lines=False) if equal_upper: points_list, colors = \ get_categorized_data_from_value_color_equal_on_upper_bound( @@ -101,14 +94,32 @@ class Plotting: ax, sample_no_list=[None, total_samples, None], sample_no_colors=sample_no_colors, sample_no_pos=[None, 0.5, None], - chan_db_info=chan_db_info, linked_ax=linked_ax) + chan_db_info=chan_db_info) ax.x_center = c_data['times'][0] ax.chan_db_info = chan_db_info return ax - def plot_tri_colors(self, c_data, chan_db_info, chan_id, - ax, linked_ax): + def plot_multi_color_dots_equal_on_upper_bound( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: + """ + Use plot_multi_color_dots_base() to plot channel in which colors are + identified by plot_from_value_color_equal_on_upper_bound + """ + return self.plot_multi_color_dots_base( + c_data, chan_db_info, equal_upper=True) + + def plot_multi_color_dots_equal_on_lower_bound( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: + """ + Use plot_multi_color_dots_base() to plot channel in which colors are + identified by plot_from_value_color_equal_on_lower_bound + """ + return self.plot_multi_color_dots_base( + c_data, chan_db_info, equal_upper=False) + + def plot_tri_colors( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: """ Plot 3 different values in 3 lines with 3 different colors according to valueColors: @@ -116,27 +127,19 @@ class Plotting: value = -1 => plot on line y=-1 with M color value = 0 => plot on line y=0 with R color value = 1 => plot on line y=1 with Y color - Color codes are defined in colorSettings and limitted in 'valColRE' + Color codes are defined in colorSettings and limited in 'valColRE' in dbSettings.py :param c_data: data of the channel which includes down-sampled - data in keys 'times' and 'data'. Refer to DataTypeModel.__init__. - soh_data[key][chan_id] - :param chan_db_info: dict - info of channel from DB + (if needed) data in keys 'times' and 'data'. + :param chan_db_info: info of channel from DB :param chan_id: name of channel - :param ax: axes to draw plot of channel - :param linked_ax: axes of another channel - linked to this channel => both channels' will be plotted on the - same axes :return ax: axes of the channel """ - if linked_ax is not None: - ax = linked_ax - if ax is None: - plot_h = self.plotting_axes.get_height(chan_db_info['height']) - ax = self.plotting_axes.create_axes( - self.parent.plotting_bot, plot_h, - has_min_max_lines=False) + plot_h = self.plotting_axes.get_height(chan_db_info['height']) + ax = self.plotting_axes.create_axes( + self.parent.plotting_bot, plot_h, + has_min_max_lines=False) value_colors = chan_db_info['valueColors'].split('|') @@ -173,56 +176,31 @@ class Plotting: ax, sample_no_list=total_sample_list, sample_no_colors=sample_no_colors, sample_no_pos=[0.05, 0.5, 0.95], - chan_db_info=chan_db_info, linked_ax=linked_ax) + chan_db_info=chan_db_info) ax.chan_db_info = chan_db_info return ax - def plot_multi_color_dots_equal_on_upper_bound( - self, c_data, chan_db_info, chan_id, ax, linked_ax): - """ - Use plot_multi_color_dots_base() to plot channel in which colors are - identified by plot_from_value_color_equal_on_upper_bound - """ - return self.plot_multi_color_dots_base( - c_data, chan_db_info, ax, linked_ax, equal_upper=True) - - def plot_multi_color_dots_equal_on_lower_bound( - self, c_data, chan_db_info, chan_id, ax, linked_ax): - """ - Use plot_multi_color_dots_base() to plot channel in which colors are - identified by plot_from_value_color_equal_on_lower_bound - """ - return self.plot_multi_color_dots_base( - c_data, chan_db_info, ax, linked_ax, equal_upper=False) - - def plot_up_down_dots(self, c_data, chan_db_info, chan_id, ax, linked_ax): + def plot_up_down_dots( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: """ Plot channel with 2 different values, one above, one under center line. Each value has corresponding color defined in valueColors in database. Ex: 1:Y|0:R means value == 1 => plot above center line with Y color value == 0 => plot under center line with R color - Color codes are defined in colorSettings + Color codes are defined in colorSettings. - :param c_data: dict - data of the channel which includes down-sampled - data in keys 'times' and 'data'. Refer to DataTypeModel.__init__. - soh_data[key][chan_id] - :param chan_db_info: dict - info of channel from DB - :param chan_id: str - name of channel - :param ax: matplotlib.axes.Axes - axes to draw plot of channel - :param linked_ax: matplotlib.axes.Axes/None - axes of another channel - linked to this channel => both channels' will be plotted on the - same axes - :return ax: matplotlib.axes.Axes - axes of the channel + :param c_data: data of the channel which includes down-sampled + (if needed) data in keys 'times' and 'data'. + :param chan_db_info: info of channel from DB + :param chan_id: name of channel + :return ax: axes of the channel """ - if linked_ax is not None: - ax = linked_ax - if ax is None: - plot_h = self.plotting_axes.get_height(chan_db_info['height']) - ax = self.plotting_axes.create_axes( - self.parent.plotting_bot, plot_h, - has_min_max_lines=False) + plot_h = self.plotting_axes.get_height(chan_db_info['height']) + ax = self.plotting_axes.create_axes( + self.parent.plotting_bot, plot_h, + has_min_max_lines=False) val_cols = chan_db_info['valueColors'].split('|') # up/down has 2 values: 0, 1 which match with index of points_list @@ -254,7 +232,7 @@ class Plotting: sample_no_list=[len(points_list[0]), None, len(points_list[1])], sample_no_colors=[clr[colors[0]], None, clr[colors[1]]], sample_no_pos=[0.25, None, 0.75], - chan_db_info=chan_db_info, linked_ax=linked_ax) + chan_db_info=chan_db_info) # x_bottom, x_top are the times of data points to be displayed at # bottom or top of the plot @@ -264,27 +242,23 @@ class Plotting: ax.chan_db_info = chan_db_info return ax - def plot_time_dots(self, c_data, chan_db_info, chan_id, ax, linked_ax): + def plot_time_dots( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: """ Plot times only :param c_data: dict - data of the channel which includes down-sampled data in keys 'times' and 'data'. Refer to DataTypeModel.__init__. soh_data[key][chan_id] - :param chan_db_info: dict - info of channel from DB - :param chan_id: str - name of channel - :param ax: matplotlib.axes.Axes - axes to draw plot of channel - :param linked_ax: matplotlib.axes.Axes/None - axes of another channel - linked to this channel => both channels' will be plotted on the - same axes - :return ax: matplotlib.axes.Axes - axes of the channel + :param c_data: data of the channel which includes down-sampled + (if needed) data in keys 'times' and 'data'. + :param chan_db_info: info of channel from DB + :param chan_id: name of channel + :return ax: axes of the channel """ - if linked_ax is not None: - ax = linked_ax - if ax is None: - plot_h = self.plotting_axes.get_height(chan_db_info['height']) - ax = self.plotting_axes.create_axes( - self.parent.plotting_bot, plot_h) + plot_h = self.plotting_axes.get_height(chan_db_info['height']) + ax = self.plotting_axes.create_axes( + self.parent.plotting_bot, plot_h) color = 'W' if chan_db_info['valueColors'] not in [None, 'None', '']: @@ -295,7 +269,7 @@ class Plotting: ax, sample_no_list=[None, total_x, None], sample_no_colors=[None, clr[color], None], sample_no_pos=[None, 0.5, None], - chan_db_info=chan_db_info, linked_ax=linked_ax) + chan_db_info=chan_db_info) for x in x_list: ax.plot(x, [0] * len(x), marker='s', markersize=1.5, @@ -306,38 +280,31 @@ class Plotting: ax.chan_db_info = chan_db_info return ax - def plot_lines_dots(self, c_data, chan_db_info, chan_id, - ax, linked_ax, info=''): + def plot_lines_dots( + self, c_data: Dict, chan_db_info: Dict, chan_id: str, info: str = '' + ) -> Axes: """ Plot lines with dots at the data points. Colors of dot and lines are defined in valueColors in database. - Ex: L:G|D:W means + Ex: L:G|D:W|Z:C means Lines are plotted with color G Dots are plotted with color W + Additional dot with value Zero in color C (for channel GPS Lk/Unlk) If D is not defined, dots won't be displayed. If L is not defined, lines will be plotted with color G Color codes are defined in colorSettings - :param c_data: dict - data of the channel which includes down-sampled - data in keys 'times' and 'data'. Refer to DataTypeModel.__init__. - soh_data[key][chan_id] or DataTypeModel.__init__. - waveform_data[key]['read_data'][chan_id] for waveform data - :param chan_db_info: dict - info of channel from DB - :param chan_id: str - name of channel - :param ax: matplotlib.axes.Axes - axes to draw plot of channel - :param linked_ax: matplotlib.axes.Axes/None - axes of another channel - linked to this channel => both channels' will be plotted on the - same axes - :param info: str - additional info to be displayed on sub-title under + :param c_data: data of the channel which includes down-sampled + (if needed) data in keys 'times' and 'data'. + :param chan_db_info: info of channel from DB + :param chan_id: name of channel + :param info: additional info to be displayed on sub-title under main-title - :return ax: matplotlib.axes.Axes - axes of the channel + :return ax: axes of the channel """ - if linked_ax is not None: - ax = linked_ax - if ax is None: - plot_h = self.plotting_axes.get_height(chan_db_info['height']) - ax = self.plotting_axes.create_axes( - self.parent.plotting_bot, plot_h) + plot_h = self.plotting_axes.get_height(chan_db_info['height']) + ax = self.plotting_axes.create_axes( + self.parent.plotting_bot, plot_h) x_list, y_list = c_data['times'], c_data['data'] @@ -358,24 +325,44 @@ class Plotting: d_color = l_color if chan_id == 'GPS Lk/Unlk': + z_color = colors['Z'] sample_no_list = [] ax.x_bottom = x_list[0][np.where(y_list[0] == -1)[0]] sample_no_list.append(ax.x_bottom.size) - sample_no_list.append(None) + ax.x_center = x_list[0][np.where(y_list[0] == 0)[0]] + sample_no_list.append(ax.x_center.size) ax.x_top = x_list[0][np.where(y_list[0] == 1)[0]] sample_no_list.append(ax.x_top.size) - sample_no_colors = [clr[d_color], None, clr[d_color]] - sample_no_pos = [0.05, None, 0.95] + sample_no_colors = [clr[d_color], clr[z_color], clr[d_color]] + sample_no_pos = [0.05, 0.5, 0.95] + top_bottom_index = np.where(y_list[0] != 0)[0] + + # for plotting top & bottom + x_list = [x_list[0][top_bottom_index]] + y_list = [y_list[0][top_bottom_index]] + + ax.myPlot = ax.plot(ax.x_center, [0] * ax.x_center.size, + marker='s', + markersize=1.5, + linestyle='', + zorder=constants.Z_ORDER['DOT'], + mfc=clr[z_color], + mec=clr[z_color], + picker=True, pickradius=3) + info = "GPS Clock Power" else: sample_no_list = [None, sum([len(x) for x in x_list]), None] sample_no_colors = [None, clr[d_color], None] sample_no_pos = [None, 0.5, None] + ax.x_center = x_list[0] + ax.y_list = y_list[0] + self.plotting_axes.set_axes_info( ax, sample_no_list=sample_no_list, sample_no_colors=sample_no_colors, sample_no_pos=sample_no_pos, chan_db_info=chan_db_info, - info=info, y_list=y_list, linked_ax=linked_ax) + info=info, y_list=y_list) for x, y in zip(x_list, y_list): if not has_dot and sample_no_list[1] > 1: @@ -397,53 +384,39 @@ class Plotting: mec=clr[d_color], picker=True, pickradius=3) - if chan_id != 'GPS Lk/Unlk': - ax.x_center = x_list[0] - ax.y_list = y_list[0] - ax.chan_db_info = chan_db_info return ax - def plot_lines_s_rate(self, c_data, chan_db_info, chan_id, ax, linked_ax): + def plot_lines_s_rate( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: """ Plot line only for waveform data channel (seismic data). Sample rate unit will be displayed - :param c_data: dict - data of the channel which includes down-sampled - data in keys 'times' and 'data'. Refer to DataTypeModel.__init__. - waveform_data[key]['read_data'][chan_id] - :param chan_db_info: dict - info of channel from DB - :param chan_id: str - name of channel - :param ax: matplotlib.axes.Axes - axes to draw plot of channel - :param linked_ax: matplotlib.axes.Axes/None - axes of another channel - linked to this channel => both channels' will be plotted on the - same axes - :return ax: matplotlib.axes.Axes - axes of the channel + :param c_data: data of the channel which includes down-sampled + (if needed) data in keys 'times' and 'data'. + :param chan_db_info: info of channel from DB + :param chan_id: name of channel + :return ax: axes of the channel """ if c_data['samplerate'] >= 1.0: info = "%dsps" % c_data['samplerate'] else: info = "%gsps" % c_data['samplerate'] - return self.plot_lines_dots(c_data, chan_db_info, chan_id, - ax, linked_ax, info=info) + return self.plot_lines_dots(c_data, chan_db_info, chan_id, info=info) - def plot_lines_mass_pos(self, c_data, chan_db_info, chan_id, - ax, linked_ax): + def plot_lines_mass_pos( + self, c_data: Dict, chan_db_info: Dict, chan_id: str) -> Axes: """ Plot multi-color dots with grey line for mass position channel. Use get_masspos_value_colors() to get value_colors map based on Menu - MP Coloring selected from Main Window. - :param c_data: dict - data of the channel which includes down-sampled - data in keys 'times' and 'data'. Refer to DataTypeModel.__init__. - mass_pos_data[key][chan_id] - :param chan_db_info: dict - info of channel from DB - :param chan_id: str - name of channel - :param ax: matplotlib.axes.Axes - axes to draw plot of channel - :param linked_ax: matplotlib.axes.Axes/None - axes of another channel - linked to this channel => both channels' will be plotted on the - same axes - :return ax: matplotlib.axes.Axes - axes of the channel + :param c_data: data of the channel which includes down-sampled + (if needed) data in keys 'times' and 'data'. + :param chan_db_info: info of channel from DB + :param chan_id: name of channel + :return ax: axes of the channel """ value_colors = get_masspos_value_colors( self.main_window.mass_pos_volt_range_opt, chan_id, @@ -452,14 +425,12 @@ class Plotting: if value_colors is None: return - - if ax is None: - plot_h = self.plotting_axes.get_height(chan_db_info['height']) - ax = self.plotting_axes.create_axes( - self.parent.plotting_bot, plot_h) - + plot_h = self.plotting_axes.get_height(chan_db_info['height']) + ax = self.plotting_axes.create_axes( + self.parent.plotting_bot, plot_h) x_list, y_list = c_data['times'], c_data['data'] total_x = sum([len(x) for x in x_list]) + self.plotting_axes.set_axes_info( ax, sample_no_list=[None, total_x, None], sample_no_colors=[None, clr['W'], None], diff --git a/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py b/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py index 75ed0d3f13d17c5d5229f3f652865a57f15f87a3..04a812b5ed321c653be948a122173c5dd4c57e70 100644 --- a/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py +++ b/sohstationviewer/view/plotting/plotting_widget/plotting_axes.py @@ -167,7 +167,7 @@ class PlottingAxes: return return ax.text( 1.005, pos_y, - sample_no, + str(sample_no), horizontalalignment='left', verticalalignment='center', rotation='horizontal', @@ -183,8 +183,7 @@ class PlottingAxes: label: Optional[str] = None, info: str = '', y_list: Optional[np.ndarray] = None, - chan_db_info: Optional[Dict] = None, - linked_ax: Optional[Axes] = None): + chan_db_info: Optional[Dict] = None): """ Draw plot's title, sub title, sample total label, center line, y labels for a channel. @@ -199,25 +198,15 @@ class PlottingAxes: :param label: title of the plot. If None, show chan_db_info['label'] :param info: additional info to show in sub title which is smaller and under title on the left side - :param y: y values of the channel for min/max labels, min/max lines + :param y_list: y values of the channel for min/max labels, lines :param chan_db_info: info of channel from database - :param linked_ax: - if linked_ax is None, this is a main channel, label of channel will - be displayed with title's format, on top right of plot. - if linked_ax is not None, this is a channel using main channel's - axes, label of channel will be displayed with sub title's - format - under main title. """ if label is None: label = chan_db_info['label'] - - title_ver_alignment = 'center' - # set info in subtitle under title - if linked_ax is not None: - info = label + pos_y = 0.4 if info != '': ax.text( - -0.15, 0.2, + -0.15, 0.4, info, horizontalalignment='left', verticalalignment='top', @@ -226,23 +215,20 @@ class PlottingAxes: color=self.parent.display_color['sub_basic'], size=self.parent.font_size ) - title_ver_alignment = 'top' - - if linked_ax is None: - # set title on left side - color = self.parent.display_color['plot_label'] - if label.startswith("DEFAULT"): - color = self.parent.display_color["warning"] - ax.text( - -0.15, 0.6, - label, - horizontalalignment='left', - verticalalignment=title_ver_alignment, - rotation='horizontal', - transform=ax.transAxes, - color=color, - size=self.parent.font_size + 2 * self.parent.ratio_w - ) + pos_y = 0.6 + # set title on left side + color = self.parent.display_color['plot_label'] + if label.startswith("DEFAULT"): + color = self.parent.display_color["warning"] + ax.text( + -0.15, pos_y, + label, + horizontalalignment='left', + rotation='horizontal', + transform=ax.transAxes, + color=color, + size=self.parent.font_size + 2 * self.parent.ratio_w + ) # set samples' total on right side # bottom @@ -255,9 +241,6 @@ class PlottingAxes: ax.top_total_point_lbl = self.create_sample_no_label( ax, sample_no_pos[2], sample_no_list[2], sample_no_colors[2]) - if linked_ax is not None: - ax.set_yticks([]) - return if y_list is None: # draw center line ax.plot([self.parent.min_x, self.parent.max_x], diff --git a/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py b/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py index a4c66b28cb3675f99b60ce74db0ac335a82ea34d..760dbd0c26fa57b488fd47cf7dbc20cab48c1cc0 100755 --- a/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py +++ b/sohstationviewer/view/plotting/plotting_widget/plotting_widget.py @@ -6,7 +6,8 @@ import numpy as np import matplotlib.text from matplotlib import pyplot as pl from matplotlib.transforms import Bbox -from PySide2.QtCore import QTimer, Qt +from PySide2.QtCore import QTimer, Qt, QSize +from PySide2.QtGui import QResizeEvent from PySide2 import QtCore, QtWidgets from PySide2.QtWidgets import QWidget, QApplication, QTextBrowser @@ -240,31 +241,36 @@ class PlottingWidget(QtWidgets.QScrollArea): # ======================================================================= # # EVENT # ======================================================================= # - def resizeEvent(self, event): + def resizeEvent(self, event: QResizeEvent): """ OVERRIDE Qt method. When plottingWidget's viewport is resized along with parent window - (even opening MainWindow triggers resizeEvent too), self.ratio_w, - self.plotting_w, self.plotting_l will be recalculated so that the - drawn channels fit inside the width of the new viewport. - If self.plot_total == 0, when there is no channels drawn, height - is set to the height of viewport to cover the whole viewport since - viewport and widget may be in different colors. + (event opening MainWindow triggers resizeEvent too), call + set_size() to fit all components of the channel inside the width + of the new viewport. - :param event: QResizeEvent - resize event + :param event: resize event """ - geo = self.maximumViewportSize() + self.set_size(self.maximumViewportSize()) + return super(PlottingWidget, self).resizeEvent(event) - # set view size fit with the scroll's view port size - self.main_widget.setFixedWidth(geo.width()) - self.ratio_w = geo.width() / self.width_base_px - self.font_size = self.ratio_w * self.base_font_size + def set_size(self, view_port_size: QSize) -> None: + """ + Set the widget's width fit the width of geo so user don't have to + scroll the horizontal bar to view the channels in side the widget. + Recalculate ratio_w, plotting_w, self.plotting_l to plot channels and + their labels fit inside the widget width. + When there is no channels, height will be set to the height of + the viewport to cover the whole viewport. + :param view_port_size: size of viewport + """ + # set view size fit with the scroll's viewport size + self.main_widget.setFixedWidth(view_port_size.width()) + self.ratio_w = view_port_size.width() / self.width_base_px self.plotting_w = self.ratio_w * self.width_base self.plotting_l = self.ratio_w * self.plotting_l_base if self.plot_total == 0: - self.main_widget.setFixedHeight(geo.height()) - - return super(PlottingWidget, self).resizeEvent(event) + self.main_widget.setFixedHeight(view_port_size.height()) def get_timestamp(self, event): """ diff --git a/sohstationviewer/view/plotting/state_of_health_widget.py b/sohstationviewer/view/plotting/state_of_health_widget.py index fe34d3a5f06bdae8e8fa40746ad580515e727a55..29945f3af0ce3b8426e03dccb8ca590c33e1ac22 100644 --- a/sohstationviewer/view/plotting/state_of_health_widget.py +++ b/sohstationviewer/view/plotting/state_of_health_widget.py @@ -68,46 +68,8 @@ class SOHWidget(MultiThreadedPlottingWidget): return chan_db_info = c_data['chan_db_info'] plot_type = chan_db_info['plotType'] - - linked_ax = None - try: - if chan_db_info['linkedChan'] not in [None, 'None', '']: - linked_ax = self.plotting_data1[chan_db_info[ - 'linkedChan']]['ax'] - except KeyError: - # linkedChan not point to an actual channel - # which is when the channel in linkedChan doesn't have data - # or the channel with likedChan is plotted first - # (the later is prevented by - # move_soh_channels_with_link_to_the_end()) - pass ax = getattr(self.plotting, plot_functions[plot_type][1])( - c_data, chan_db_info, chan_id, None, linked_ax) + c_data, chan_db_info, chan_id) c_data['ax'] = ax - if linked_ax is None: - # to prevent same ax is appended to self.axes twice when there is - # linkedChan for the channel - ax.chan = chan_id - self.axes.append(ax) - - def move_soh_channels_with_link_to_the_end(self, chan_order): - """ - In order to plot a channel (channel A) that is linked with another - channel (channel B), we need to plot B before we plot A. Because the - order of the channel in the data is not predetermined, we need to - manually move A to the end of chan_order. This is, of course, - assuming that channel link is at most one level deep. - """ - channels_to_move = [] - - for channel, chan_data in self.plotting_data1.items(): - try: - linked_channel = chan_data['chan_db_info']['linkedChan'] - if linked_channel not in ['', 'None', None]: - chan_order.remove(channel) - channels_to_move.append(channel) - except KeyError: - continue - - for channel in channels_to_move: - chan_order.append(channel) + ax.chan = chan_id + self.axes.append(ax) diff --git a/sohstationviewer/view/plotting/time_power_square/time_power_squared_dialog.py b/sohstationviewer/view/plotting/time_power_square/time_power_squared_dialog.py index 3d2b0fff2c18c1456b10f03ebd38d5469cd76edb..6382032e9cbf572deb12cded33ea401821b3f0ca 100755 --- a/sohstationviewer/view/plotting/time_power_square/time_power_squared_dialog.py +++ b/sohstationviewer/view/plotting/time_power_square/time_power_squared_dialog.py @@ -1,486 +1,22 @@ # Display time-power-squared values for waveform data -from math import sqrt -from typing import List, Tuple, Union +from typing import Union, Tuple, Dict, List -import numpy as np from PySide2 import QtWidgets, QtCore +from PySide2.QtCore import QEventLoop, Qt +from PySide2.QtGui import QCursor +from PySide2.QtWidgets import QApplication, QTabWidget -from matplotlib.axes import Axes -from sohstationviewer.conf import constants as const -from sohstationviewer.controller.plotting_data import ( - get_title, get_day_ticks, format_time, -) -from sohstationviewer.controller.util import ( - display_tracking_info, add_thousand_separator, -) from sohstationviewer.database.extract_data import ( - get_color_def, get_color_ranges, get_seismic_chan_label, + get_color_def, get_color_ranges ) -from sohstationviewer.model.general_data.general_data import GeneralData -from sohstationviewer.view.util.enums import LogType -from sohstationviewer.view.util.color import clr -from sohstationviewer.view.plotting.plotting_widget import plotting_widget from sohstationviewer.view.plotting.time_power_square.\ - time_power_squared_processor import TimePowerSquaredProcessor + time_power_squared_widget import TimePowerSquaredWidget +from sohstationviewer.controller.util import display_tracking_info +from sohstationviewer.model.general_data.general_data import GeneralData from sohstationviewer.view.plotting.time_power_square.\ - time_power_squared_helper import ( - get_start_5mins_of_diff_days, find_tps_tm_idx) - - -class TimePowerSquaredWidget(plotting_widget.PlottingWidget): - stopped = QtCore.Signal() - """ - Widget to display time power square data for waveform channels - """ - def __init__(self, *args, **kwarg): - """ - rulers: [matplotlib.lines.Line2D,] - list of squares on each waveform - channels to highlight the five-minute at the mouse click on - TimePowerSquaredWidget or the five-minute corresponding to the - time at the mouse click on other plotting widgets - """ - self.rulers = [] - """ - zoom_marker1s: [matplotlib.lines.Line2D,] - list of line markers to - mark the five-minute corresponding to the start of the zoom area - """ - self.zoom_marker1s = [] - """ - zoom_marker2s: [matplotlib.lines.Line2D,] - list of line markers to - mark the five-minute corresponding to the end of the zoom area - """ - self.zoom_marker2s = [] - """ - every_day_5_min_list: [[288 of floats], ] - the list of all starts - of five minutes for every day in which each day has 288 of - 5 minutes. - """ - self.start_5mins_of_diff_days = [] - """ - tps_t: float - prompt's time on tps's chart to help rulers on other - plotting widgets to identify their location - """ - self.tps_t = 0 - - self.tps_processors: List[TimePowerSquaredProcessor] = [] - # The list of all channels that are processed. - self.channels = [] - # The list of channels that have been processed. - self.processed_channels = [] - # To prevent user to use ruler or zoom_markers while plotting - self.is_working = False - # The post-processing step does not take too much time so there is no - # need to limit the number of threads that can run at once. - self.thread_pool = QtCore.QThreadPool() - self.finished_lock = QtCore.QMutex() - - super().__init__(*args, **kwarg) - - def plot_channels(self, d_obj: GeneralData, - key: Union[str, Tuple[str, str]], - start_tm: float, end_tm: float): - """ - Recursively plot each TPS channels for waveform_data. - :param d_obj: object of data - :param key: data set's key - :param start_tm: requested start time to read - :param end_tm: requested end time to read - """ - self.zoom_marker1_shown = False - self.is_working = True - self.set_key = key - self.plotting_data1 = d_obj.waveform_data[key] - self.plot_total = len(self.plotting_data1) - - self.plotting_bot = const.BOTTOM - self.plotting_bot_pixel = const.BOTTOM_PX - self.processed_channels = [] - self.channels = [] - self.tps_processors = [] - - start_msg = 'Plotting TPS data...' - display_tracking_info(self.tracking_box, start_msg) - self.processing_log = [] # [(message, type)] - self.gap_bar = None - self.min_x = max(d_obj.data_time[key][0], start_tm) - self.max_x = min(d_obj.data_time[key][1], end_tm) - - self.date_mode = self.main_window.date_format.upper() - if self.plotting_data1 == {}: - title = "NO WAVEFORM DATA TO DISPLAY TPS." - self.processing_log.append( - ("No WAVEFORM data to display TPS.", LogType.INFO)) - else: - title = get_title(key, self.min_x, self.max_x, self.date_mode) - - self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.) - self.plotting_axes.set_title(title, y=5, v_align='bottom') - - if self.plotting_data1 == {}: - self.is_working = False - self.draw() - self.clean_up('NO DATA') - return - - self.start_5mins_of_diff_days = get_start_5mins_of_diff_days( - self.min_x, self.max_x) - for chan_id in self.plotting_data1: - c_data = self.plotting_data1[chan_id] - if 'tps_data' not in c_data: - self.channels.append(chan_id) - channel_processor = TimePowerSquaredProcessor( - chan_id, c_data, self.min_x, self.max_x, - self.start_5mins_of_diff_days - ) - channel_processor.signals.finished.connect(self.channel_done) - channel_processor.signals.stopped.connect(self.channel_done) - self.tps_processors.append(channel_processor) - - # Because the widget determine if processing is done by comparing the - # lists of scheduled and finished channels, if a channel runs fast - # enough that it finishes before any other channel can be scheduled, - # it will be the only channel executed. To prevent this, we tell the - # threadpool to only start running the processors once all channels - # have been scheduled. - for processor in self.tps_processors: - self.thread_pool.start(processor) - - @QtCore.Slot() - def channel_done(self, chan_id: str): - """ - Slot called when a TPS processor is finished. Plot the TPS data of - channel chan_id if chan_id is not an empty string and add chan_id to - the list of processed of channels. If the list of processed channels - is the same as the list of all channels, notify the user that the - plotting is finished and add finishing touches to the plot. - - If chan_id is the empty string, notify the user that the plotting has - been stopped. - - :param chan_id: the name of the channel whose TPS data was processed. - If the TPS plot is stopped before it is finished, this will be the - empty string - """ - self.finished_lock.lock() - if chan_id != '': - ax = self.plot_channel(self.plotting_data1[chan_id], chan_id) - self.axes.append(ax) - self.processed_channels.append(chan_id) - if len(self.processed_channels) == len(self.channels): - self.clean_up(chan_id) - self.finished_lock.unlock() - - def clean_up(self, chan_id): - """ - Clean up after all available waveform channels have been stopped or - plotted. The cleanup steps are as follows. - Display a finished message - Add finishing touches to the plot - Emit the stopped signal of the widget - """ - - if chan_id == '': - msg = 'TPS plot stopped.' - else: - msg = 'TPS plot finished.' - if chan_id != 'NO DATA': - self.done() - - display_tracking_info(self.tracking_box, msg) - self.stopped.emit() - - def done(self): - """Add finishing touches to the plot and display it on the screen.""" - self.set_legend() - # Set view size fit with the given data - if self.main_widget.geometry().height() < self.plotting_bot_pixel: - self.main_widget.setFixedHeight(self.plotting_bot_pixel) - self.set_lim_markers() - self.draw() - self.is_working = False - - def plot_channel(self, c_data: str, chan_id: str) -> Axes: - """ - 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 draws each 5 minute with the color corresponding to - value and create ruler, zoom_marker1, and zoom_marker2 for the channel. - - :param c_data: dict - data of waveform channel which includes keys - 'times' and 'data'. Refer to general_data/data_structures.MD - :param chan_id: str - name of channel - :return ax: axes of the channel - """ - - total_days = c_data['tps_data'].shape[0] - plot_h = self.plotting_axes.get_height( - total_days/1.5, bw_plots_distance=0.003, pixel_height=12.1) - ax = self.create_axes(self.plotting_bot, plot_h) - ax.spines[['right', 'left', 'top', 'bottom']].set_visible(False) - ax.text( - -0.12, 1, - f"{get_seismic_chan_label(chan_id)} {c_data['samplerate']}sps", - horizontalalignment='left', - verticalalignment='top', - rotation='horizontal', - transform=ax.transAxes, - color=self.display_color['plot_label'], - size=self.font_size + 2 - ) - - zoom_marker1 = ax.plot( - [], [], marker='|', markersize=5, - markeredgecolor=self.display_color['zoom_marker'])[0] - self.zoom_marker1s.append(zoom_marker1) - - zoom_marker2 = ax.plot( - [], [], marker='|', markersize=5, - markeredgecolor=self.display_color['zoom_marker'])[0] - self.zoom_marker2s.append(zoom_marker2) - - ruler = ax.plot( - [], [], marker='s', markersize=4, - markeredgecolor=self.display_color['time_ruler'], - markerfacecolor='None')[0] - self.rulers.append(ruler) - - x = np.array([i for i in range(const.NO_5M_DAY)]) - 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) - # (- dayIdx): each day is a line, increase from top to bottom - ax.scatter(x, [- dayIdx] * len(x), marker='s', - c=color_set, s=3) - # extra to show highlight square - ax.set_ylim(-(c_data['tps_data'].shape[0] + 1), 1) - - return ax - - def set_legend(self): - """ - Plot one dot for each color and assign label to it. The dots are - plotted outside of xlim to not show up in plotting area. xlim is - set so that it has some extra space to show full hightlight square - of the ruler. - ax.legend will create one label for each dot. - """ - # set height of legend and distance bw legend and upper ax - plot_h = self.plotting_axes.get_height( - 21, bw_plots_distance=0.004, pixel_height=12) - ax = self.plotting_axes.canvas.figure.add_axes( - [self.plotting_l, self.plotting_bot, self.plotting_w, plot_h], - picker=True - ) - ax.axis('off') - ax.patch.set_alpha(0) - c_labels = self.parent.sel_col_labels - clrs = self.parent.color_def # colordef - for idx in range(len(c_labels)): - # draw a dot out of xlim so it isn't displayed in plotting area - ax.scatter([300], [1], - c=clr[clrs[idx]], - s=0.1, - label=c_labels[idx], - edgecolor=self.display_color['basic'], - alpha=0.8, - zorder=1, - picker=True) - # extra to show highlight square - ax.set_xlim(-2, const.NO_5M_DAY + 1) - ax.legend(loc="upper left", framealpha=0.2, - markerscale=25, - labelcolor=self.display_color['basic']) - - def get_color_set(self, y, square_counts, colors): - """ - Create array of color (col) according to value of y compare with - square_counts (square count range) - - :param y: np.array of float - tps values for all 5-minutes of a day - :param square_counts: [int, ] - list of square count ranges - :param colors: [str,] - list of color codes based on colorSettings.clr - :return: np.array of color hex for each 5-minutes of a day - """ - return ( - np.where( - y == square_counts[0], clr[colors[0]], np.where( - y < square_counts[1], clr[colors[1]], np.where( - y < square_counts[2], clr[colors[2]], np.where( - y < square_counts[3], clr[colors[3]], np.where( - y < square_counts[4], clr[colors[4]], np.where( - y < square_counts[5], clr[colors[5]], np.where( # noqa: E501 - y < square_counts[6], clr[colors[6]], clr[colors[7]] # noqa: E501 - ))))))) - ) - - def create_axes(self, plot_b, plot_h): - """ - Create axes for 288 of 5m in a day in which minor tick for every hour, - major tick for every 4 hour - - :param plot_b: float - bottom of the plot - :param plot_h: float - height of the plot - :return ax: matplotlib.axes.Axes - axes of tps of a waveform channel - """ - ax = self.plotting_axes.canvas.figure.add_axes( - [self.plotting_l, plot_b, self.plotting_w, plot_h], - picker=True - ) - ax.spines['right'].set_visible(False) - ax.spines['left'].set_visible(False) - ax.xaxis.grid(True, which='major', - color=self.display_color['basic'], linestyle='-') - ax.xaxis.grid(True, which='minor', - color=self.display_color['sub_basic'], linestyle='-') - ax.set_yticks([]) - - times, major_times, major_time_labels = get_day_ticks() - ax.set_xticks(times, minor=True) - ax.set_xticks(major_times) - ax.set_xticklabels(major_time_labels, fontsize=self.font_size, - color=self.display_color['basic']) - # extra to show highlight square - ax.set_xlim(-2, const.NO_5M_DAY + 1) - ax.patch.set_alpha(0) - return ax - - def on_pick_event(self, event): - """ - When a plot is select, the corresponding point on each plot will - be highlighted and their time and counts will be displayed. - :param event: pick event - event when object of canvas is selected. - The event happens before button_press_event. - """ - if event.mouseevent.name == 'scroll_event': - return - if event.mouseevent.button in ('up', 'down'): - return - info_str = "" - if event.artist in self.axes: - xdata = event.mouseevent.xdata - if xdata is None: - return - 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 value on the plot - - # refer to description in plot_channel 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.start_5mins_of_diff_days[ - day_index, five_min_index] - format_t = format_time(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}:" - f"{add_thousand_separator(sqrt(data))}") - info_str += " (counts)</pre>" - display_tracking_info(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): - """ - Ctrl + cmd: base on xdata to find indexes of x an y - in self.each_day_5_min_list to display ruler for each channel, then - set time for each ruler in self.rulers to place them in correct - position. - - :param xdata: float - time value in other plot - """ - self.zoom_marker1_shown = False - x_idx, y_idx = find_tps_tm_idx(xdata, self.start_5mins_of_diff_days) - for rl in self.rulers: - rl.set_data(x_idx, y_idx) - - def on_shift_click(self, xdata): - """ - Shift + right click on other plot widget, this function will be called - to show marker for place when it is zoomed. - On the fist of zoom_marker, make ruler disappeared, set min_x. - On the second of zoom_maker, call set_lim_markers to mark the new - limit. - - :param xdata: float - time value in other plot - """ - - if not self.zoom_marker1_shown: - self.set_rulers_invisible() - self.min_x = xdata - self.zoom_marker1_shown = True - else: - [self.min_x, self.max_x] = sorted( - [self.min_x, xdata]) - self.set_lim_markers() - self.zoom_marker1_shown = False - - def set_rulers_invisible(self): - """ - Clear data for self.rulers to make them disappeared. - """ - for rl in self.rulers: - rl.set_data([], []) - - def set_lim_markers(self): - """ - Find x index (which index in five minutes of a day) and - y index (which day) of self.min_x and self.min_y, and set data for - all markers in self.zoom_marker1s and self.zoom_marker2s. - """ - x_idx, y_idx = find_tps_tm_idx(self.min_x, - self.start_5mins_of_diff_days) - for zm1 in self.zoom_marker1s: - zm1.set_data(x_idx, y_idx) - x_idx, y_idx = find_tps_tm_idx(self.max_x, - self.start_5mins_of_diff_days) - for zm2 in self.zoom_marker2s: - zm2.set_data(x_idx, y_idx) - - def request_stop(self): - """Request all running channel processors to stop.""" - for processor in self.tps_processors: - processor.request_stop() - - def replot(self): - """ - Reuse tps_data calculated in the first plotting to replot - with new color range selected. - """ - self.clear() - self.set_colors(self.main_window.color_mode) - self.plotting_bot = const.BOTTOM - title = get_title(self.set_key, self.min_x, self.max_x, self.date_mode) - self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.) - self.plotting_axes.set_title(title, y=0, v_align='bottom') - for chan_id in self.plotting_data1: - c_data = self.plotting_data1[chan_id] - self.plot_channel(c_data, chan_id) - self.done() + time_power_squared_helper import get_start_5mins_of_diff_days +from sohstationviewer.conf.constants import DAY_LIMIT_FOR_TPS_IN_ONE_TAB class TimePowerSquaredDialog(QtWidgets.QWidget): @@ -494,7 +30,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): :param parent: QMainWindow/QWidget - the parent widget """ super().__init__() - self.parent = parent + self.main_window = parent """ data_type: str - type of data being plotted """ @@ -504,7 +40,7 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): """ self.date_format: str = 'YYYY-MM-DD' - self.setGeometry(50, 50, 1200, 700) + self.setGeometry(50, 50, 1200, 800) self.setWindowTitle("TPS Plot") main_layout = QtWidgets.QVBoxLayout() @@ -512,17 +48,20 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): main_layout.setContentsMargins(5, 5, 5, 5) main_layout.setSpacing(0) + """ + tps_widget_dict: dict of TPS widgets in TPS tab + """ + self.tps_widget_dict: Dict[str, TimePowerSquaredWidget] = {} """ tracking_info_text_browser: QTextBrowser - to display info text """ self.info_text_browser = QtWidgets.QTextBrowser(self) """ - plotting_widget: PlottingWidget - the widget to draw time-power-square + plotting_widget: tab that contains widgets to draw time-power-square for each 5-minute of data """ - self.plotting_widget = TimePowerSquaredWidget( - self, self.info_text_browser, "TPS", self.parent) - main_layout.addWidget(self.plotting_widget, 2) + self.plotting_tab = QTabWidget(self) + main_layout.addWidget(self.plotting_tab, 2) bottom_layout = QtWidgets.QHBoxLayout() bottom_layout.addSpacing(20) @@ -531,6 +70,13 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): buttons_layout = QtWidgets.QVBoxLayout() bottom_layout.addLayout(buttons_layout) + # ################ Coordination ################ + # day index of current clicked point + self.day_idx: int = 0 + # five minute index of current clicked point + self.five_minute_idx: int = 0 + # current position of vertical scrollbar when the plot is clicked + self.vertical_scroll_pos: int = 0 # ################ Color range ################# color_layout = QtWidgets.QHBoxLayout() buttons_layout.addLayout(color_layout) @@ -569,13 +115,26 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): self.color_range_choice.setCurrentText('High') color_layout.addWidget(self.color_range_choice) + + """ + every_day_5_min_list: [[288 of floats], ] - the list of all starts + of five minutes for every day in which each day has 288 of + 5 minutes. + """ + self.start_5mins_of_diff_days: List[List[float]] = [] # ##################### Replot button ######################## - self.replot_button = QtWidgets.QPushButton("RePlot", self) - buttons_layout.addWidget(self.replot_button) + self.replot_current_tab_button = QtWidgets.QPushButton( + "RePlot Current Tab", self) + buttons_layout.addWidget(self.replot_current_tab_button) + + self.replot_all_tabs_button = QtWidgets.QPushButton( + "RePlot All Tabs", self) + buttons_layout.addWidget(self.replot_all_tabs_button) # ##################### Save button ########################## - self.save_plot_button = QtWidgets.QPushButton('Save Plot', self) - buttons_layout.addWidget(self.save_plot_button) + self.save_current_tab_button = QtWidgets.QPushButton( + 'Save Current Tab', self) + buttons_layout.addWidget(self.save_current_tab_button) self.info_text_browser.setFixedHeight(60) bottom_layout.addWidget(self.info_text_browser) @@ -602,16 +161,27 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): :param event: QResizeEvent - resize event """ - self.plotting_widget.init_size() + try: + view_port_size = \ + self.plotting_tab.currentWidget().maximumViewportSize() + for tps_widget in self.tps_widget_dict.values(): + tps_widget.set_size(view_port_size) + except AttributeError: + # resizeEvent might be called when there's no currentWidget + pass + return super(TimePowerSquaredDialog, self).resizeEvent(event) def connect_signals(self): """ Connect functions to widgets """ - self.save_plot_button.clicked.connect(self.save_plot) - self.replot_button.clicked.connect(self.plotting_widget.replot) + self.save_current_tab_button.clicked.connect(self.save_current_tab) + self.replot_current_tab_button.clicked.connect(self.replot) + self.replot_all_tabs_button.clicked.connect( + self.replot_all_tabs) self.color_range_choice.currentTextChanged.connect( self.color_range_changed) + self.plotting_tab.currentChanged.connect(self.on_tab_changed) @QtCore.Slot() def color_range_changed(self): @@ -625,8 +195,106 @@ class TimePowerSquaredDialog(QtWidgets.QWidget): self.sel_col_labels = self.color_label[cr_index] @QtCore.Slot() - def save_plot(self): + def save_current_tab(self): """ Save the plotting to a file """ - self.plotting_widget.save_plot('TPS-Plot') + tps_widget = self.plotting_tab.currentWidget() + tps_widget.save_plot(f'{tps_widget.tab_name}-Plot') + + @QtCore.Slot() + def on_tab_changed(self): + """ + When changing to a new tab, move vertical scroll bar to + vertical_scroll_pos which was set when user click on a tps_widget. + """ + tps_widget = self.plotting_tab.currentWidget() + tps_widget.verticalScrollBar().setValue(self.vertical_scroll_pos) + + @QtCore.Slot() + def replot(self): + """ + Apply new settings to the current tps widget and replot. + """ + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + display_tracking_info(self.info_text_browser, + "Start replot current TPS tab.") + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) + tps_widget = self.plotting_tab.currentWidget() + tps_widget.replot() + display_tracking_info(self.info_text_browser, + "Finish replot current TPS tab.") + QApplication.restoreOverrideCursor() + + @QtCore.Slot() + def replot_all_tabs(self): + """ + Apply new settings to all tps tabs and replot + """ + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + + display_tracking_info(self.info_text_browser, + "Start replot all TPS tabs.") + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) + for tps_widget in self.tps_widget_dict.values(): + tps_widget.replot() + display_tracking_info(self.info_text_browser, + "Finish replot all TPS tabs.") + QApplication.restoreOverrideCursor() + + def plot_channels(self, d_obj: GeneralData, + key: Union[str, Tuple[str, str]], + start_tm: float, end_tm: float): + """ + Create tabs to plot data channels, + + If total days <= limit: all channels are plotted in one tab by + sending a data_dict of all channels to create_tps_widget where + they will be plotted using tps_widget.plot_channels() + + If total days > limit: each channel will be plotted in a separate + tab by sending a data_dict of one channel to create_tps_widget + where the channel will be plotted using + tps_widget.plot_channels() + :param d_obj: object of data + :param key: data set's key + :param start_tm: requested start time to read + :param end_tm: requested end time to read + """ + min_x = max(d_obj.data_time[key][0], start_tm) + max_x = min(d_obj.data_time[key][1], end_tm) + self.start_5mins_of_diff_days = get_start_5mins_of_diff_days( + min_x, max_x) + for i in range(self.plotting_tab.count() - 1, 1, -1): + # delete all tps tabs + widget = self.widget(i) + self.removeTab(i) + widget.setParent(None) + self.tps_widget_dict = {} + if len(self.start_5mins_of_diff_days) <= DAY_LIMIT_FOR_TPS_IN_ONE_TAB: + self.create_tps_widget(0, key, 'TPS', d_obj.waveform_data[key]) + else: + for tab_idx, chan_id in enumerate(d_obj.waveform_data[key]): + self.create_tps_widget( + tab_idx, key, chan_id, + {chan_id: d_obj.waveform_data[key][chan_id]}) + + def create_tps_widget(self, tab_idx, key, tab_name, data_dict): + """ + Create a tps widget and add to plotting_tab, then call plot Channels + to plot all channels in data_dict. + :param tab_idx: index of tab to decide to call set_size + :param key: key of the selected data set + :param tab_name: name of the channel that will be plotted in the tab + or 'TPS' if all channels in waveform_data of the selected data set + will be plotted. This is used for name of tab and filename when + saving the plot under the tab + :param data_dict: dict of channels to be plotted + """ + tps_widget = TimePowerSquaredWidget( + key, tab_name, self, self.info_text_browser, + 'TPS', self.main_window + ) + self.plotting_tab.addTab(tps_widget, tab_name) + self.tps_widget_dict[tab_name] = tps_widget + if tab_idx > 0: + tps_widget.set_size(self.view_port_size) + tps_widget.plot_channels(data_dict, key, self.start_5mins_of_diff_days) diff --git a/sohstationviewer/view/plotting/time_power_square/time_power_squared_widget.py b/sohstationviewer/view/plotting/time_power_square/time_power_squared_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..b82e7cb8fe591ff9ad18b4ad829308cb0c7fe491 --- /dev/null +++ b/sohstationviewer/view/plotting/time_power_square/time_power_squared_widget.py @@ -0,0 +1,488 @@ +from math import sqrt +from typing import List, Tuple, Union, Dict + +import numpy as np +from PySide2 import QtCore + +from matplotlib.axes import Axes +from matplotlib.lines import Line2D + +from sohstationviewer.conf import constants as const +from sohstationviewer.controller.plotting_data import ( + get_title, get_day_ticks, format_time, +) +from sohstationviewer.controller.util import ( + display_tracking_info, add_thousand_separator, +) +from sohstationviewer.database.extract_data import ( + get_seismic_chan_label, +) +from sohstationviewer.view.util.enums import LogType +from sohstationviewer.view.util.color import clr +from sohstationviewer.view.plotting.plotting_widget import plotting_widget +from sohstationviewer.view.plotting.time_power_square.\ + time_power_squared_processor import TimePowerSquaredProcessor +from sohstationviewer.view.plotting.time_power_square.\ + time_power_squared_helper import find_tps_tm_idx + + +class TimePowerSquaredWidget(plotting_widget.PlottingWidget): + stopped = QtCore.Signal() + """ + Widget to display time power square data for waveform channels + """ + def __init__(self, key, tab_name, *args, **kwarg): + self.set_key = key + self.tab_name = tab_name + """ + rulers: list of squares on each waveform + channels to highlight the five-minute at the mouse click on + TimePowerSquaredWidget or the five-minute corresponding to the + time at the mouse click on other plotting widgets + """ + self.rulers: List[Line2D] = [] + """ + zoom_marker1s: list of line markers to + mark the five-minute corresponding to the start of the zoom area + """ + self.zoom_marker1s: List[Line2D] = [] + """ + zoom_marker2s: list of line markers to + mark the five-minute corresponding to the end of the zoom area + """ + self.zoom_marker2s: List[Line2D] = [] + + """ + tps_t: float - prompt's time on tps's chart to help rulers on other + plotting widgets to identify their location + """ + self.tps_t = 0 + + self.tps_processors: List[TimePowerSquaredProcessor] = [] + # The list of all channels that are processed. + self.channels = [] + # The list of channels that have been processed. + self.processed_channels = [] + # To prevent user to use ruler or zoom_markers while plotting + self.is_working = False + # The post-processing step does not take too much time so there is no + # need to limit the number of threads that can run at once. + self.thread_pool = QtCore.QThreadPool() + self.finished_lock = QtCore.QMutex() + + super().__init__(*args, **kwarg) + + def resizeEvent(self, event): + # resizeEvent might not reach to unfocused tabs. + # parent.view_port_size is set to set size for those tabs' components. + self.parent.view_port_size = self.maximumViewportSize() + return super(TimePowerSquaredWidget, self).resizeEvent(event) + + def get_plot_name(self): + """ + Show TPS following tab names if needed in any tracking info message + """ + return (self.tab_name if self.tab_name == 'TPS' + else self.tab_name + " TPS") + + def plot_channels(self, data_dict: Dict, + key: Union[str, Tuple[str, str]], + start_5mins_of_diff_days: List[List[float]]): + """ + Recursively plot each TPS channels for waveform_data. + :param data_dict: dict of all channels to be plotted + :param key: data set's key + :param start_5mins_of_diff_days: the list of starts of all five minutes + of days in which each day has 288 of 5 minutes. + """ + self.zoom_marker1_shown = False + self.is_working = True + self.plotting_data1 = data_dict + self.plot_total = len(self.plotting_data1) + + self.start_5mins_of_diff_days = start_5mins_of_diff_days + self.plotting_bot = const.BOTTOM + self.plotting_bot_pixel = const.BOTTOM_PX + self.processed_channels = [] + self.channels = [] + self.tps_processors = [] + + start_msg = f'Plotting {self.get_plot_name()} ...' + display_tracking_info(self.tracking_box, start_msg) + self.processing_log = [] # [(message, type)] + self.gap_bar = None + + self.date_mode = self.main_window.date_format.upper() + if self.plotting_data1 == {}: + title = "NO WAVEFORM DATA TO DISPLAY TPS." + self.processing_log.append( + ("No WAVEFORM data to display TPS.", LogType.INFO)) + else: + title = get_title(key, self.min_x, self.max_x, self.date_mode) + + self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.) + self.plotting_axes.set_title(title, y=5, v_align='bottom') + + if self.plotting_data1 == {}: + self.is_working = False + self.draw() + self.clean_up('NO DATA') + return + + for chan_id in self.plotting_data1: + c_data = self.plotting_data1[chan_id] + if 'tps_data' not in c_data: + self.channels.append(chan_id) + channel_processor = TimePowerSquaredProcessor( + chan_id, c_data, self.min_x, self.max_x, + self.start_5mins_of_diff_days + ) + channel_processor.signals.finished.connect(self.channel_done) + channel_processor.signals.stopped.connect(self.channel_done) + self.tps_processors.append(channel_processor) + + # Because the widget determine if processing is done by comparing the + # lists of scheduled and finished channels, if a channel runs fast + # enough that it finishes before any other channel can be scheduled, + # it will be the only channel executed. To prevent this, we tell the + # threadpool to only start running the processors once all channels + # have been scheduled. + for processor in self.tps_processors: + self.thread_pool.start(processor) + + @QtCore.Slot() + def channel_done(self, chan_id: str): + """ + Slot called when a TPS processor is finished. Plot the TPS data of + channel chan_id if chan_id is not an empty string and add chan_id to + the list of processed of channels. If the list of processed channels + is the same as the list of all channels, notify the user that the + plotting is finished and add finishing touches to the plot. + + If chan_id is the empty string, notify the user that the plotting has + been stopped. + + :param chan_id: the name of the channel whose TPS data was processed. + If the TPS plot is stopped before it is finished, this will be the + empty string + """ + self.finished_lock.lock() + if chan_id != '': + ax = self.plot_channel(self.plotting_data1[chan_id], chan_id) + self.axes.append(ax) + self.processed_channels.append(chan_id) + if len(self.processed_channels) == len(self.channels): + self.clean_up(chan_id) + self.finished_lock.unlock() + + def clean_up(self, chan_id): + """ + Clean up after all available waveform channels have been stopped or + plotted. The cleanup steps are as follows. + Display a finished message + Add finishing touches to the plot + Emit the stopped signal of the widget + """ + + if chan_id == '': + msg = f'{self.get_plot_name()} stopped.' + else: + msg = f'{self.get_plot_name()} finished.' + if chan_id != 'NO DATA': + self.done() + + display_tracking_info(self.tracking_box, msg) + self.stopped.emit() + + def done(self): + """Add finishing touches to the plot and display it on the screen.""" + self.set_legend() + # Set view size fit with the given data + if self.main_widget.geometry().height() < self.plotting_bot_pixel: + self.main_widget.setFixedHeight(self.plotting_bot_pixel) + self.set_lim_markers() + self.draw() + self.is_working = False + + def plot_channel(self, c_data: str, chan_id: str) -> Axes: + """ + 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 draws each 5 minute with the color corresponding to + value and create ruler, zoom_marker1, and zoom_marker2 for the channel. + + :param c_data: dict - data of waveform channel which includes keys + 'times' and 'data'. Refer to general_data/data_structures.MD + :param chan_id: str - name of channel + :return ax: axes of the channel + """ + + total_days = c_data['tps_data'].shape[0] + plot_h = self.plotting_axes.get_height( + total_days/1.5, bw_plots_distance=0.003, pixel_height=12.1) + ax = self.create_axes(self.plotting_bot, plot_h) + ax.spines[['right', 'left', 'top', 'bottom']].set_visible(False) + ax.text( + -0.12, 1, + f"{get_seismic_chan_label(chan_id)} {c_data['samplerate']}sps", + horizontalalignment='left', + verticalalignment='top', + rotation='horizontal', + transform=ax.transAxes, + color=self.display_color['plot_label'], + size=self.font_size + 2 + ) + + zoom_marker1 = ax.plot( + [], [], marker='|', markersize=5, + markeredgecolor=self.display_color['zoom_marker'])[0] + self.zoom_marker1s.append(zoom_marker1) + + zoom_marker2 = ax.plot( + [], [], marker='|', markersize=5, + markeredgecolor=self.display_color['zoom_marker'])[0] + self.zoom_marker2s.append(zoom_marker2) + + ruler = ax.plot( + [], [], marker='s', markersize=4, + markeredgecolor=self.display_color['time_ruler'], + markerfacecolor='None')[0] + self.rulers.append(ruler) + + x = np.array([i for i in range(const.NO_5M_DAY)]) + 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) + # (- dayIdx): each day is a line, increase from top to bottom + ax.scatter(x, [- dayIdx] * len(x), marker='s', + c=color_set, s=3) + # extra to show highlight square + ax.set_ylim(-(c_data['tps_data'].shape[0] + 1), 1) + + return ax + + def set_legend(self): + """ + Plot one dot for each color and assign label to it. The dots are + plotted outside of xlim to not show up in plotting area. xlim is + set so that it has some extra space to show full hightlight square + of the ruler. + ax.legend will create one label for each dot. + """ + # set height of legend and distance bw legend and upper ax + plot_h = self.plotting_axes.get_height( + 21, bw_plots_distance=0.004, pixel_height=12) + ax = self.plotting_axes.canvas.figure.add_axes( + [self.plotting_l, self.plotting_bot, self.plotting_w, plot_h], + picker=True + ) + ax.axis('off') + ax.patch.set_alpha(0) + c_labels = self.parent.sel_col_labels + clrs = self.parent.color_def # colordef + for idx in range(len(c_labels)): + # draw a dot out of xlim so it isn't displayed in plotting area + ax.scatter([300], [1], + c=clr[clrs[idx]], + s=0.1, + label=c_labels[idx], + edgecolor=self.display_color['basic'], + alpha=0.8, + zorder=1, + picker=True) + # extra to show highlight square + ax.set_xlim(-2, const.NO_5M_DAY + 1) + ax.legend(loc="upper left", framealpha=0.2, + markerscale=25, + labelcolor=self.display_color['basic']) + + def get_color_set(self, y, square_counts, colors): + """ + Create array of color (col) according to value of y compare with + square_counts (square count range) + + :param y: np.array of float - tps values for all 5-minutes of a day + :param square_counts: [int, ] - list of square count ranges + :param colors: [str,] - list of color codes based on colorSettings.clr + :return: np.array of color hex for each 5-minutes of a day + """ + return ( + np.where( + y == square_counts[0], clr[colors[0]], np.where( + y < square_counts[1], clr[colors[1]], np.where( + y < square_counts[2], clr[colors[2]], np.where( + y < square_counts[3], clr[colors[3]], np.where( + y < square_counts[4], clr[colors[4]], np.where( + y < square_counts[5], clr[colors[5]], np.where( # noqa: E501 + y < square_counts[6], clr[colors[6]], clr[colors[7]] # noqa: E501 + ))))))) + ) + + def create_axes(self, plot_b, plot_h): + """ + Create axes for 288 of 5m in a day in which minor tick for every hour, + major tick for every 4 hour + + :param plot_b: float - bottom of the plot + :param plot_h: float - height of the plot + :return ax: matplotlib.axes.Axes - axes of tps of a waveform channel + """ + ax = self.plotting_axes.canvas.figure.add_axes( + [self.plotting_l, plot_b, self.plotting_w, plot_h], + picker=True + ) + ax.spines['right'].set_visible(False) + ax.spines['left'].set_visible(False) + ax.xaxis.grid(True, which='major', + color=self.display_color['basic'], linestyle='-') + ax.xaxis.grid(True, which='minor', + color=self.display_color['sub_basic'], linestyle='-') + ax.set_yticks([]) + + times, major_times, major_time_labels = get_day_ticks() + ax.set_xticks(times, minor=True) + ax.set_xticks(major_times) + ax.set_xticklabels(major_time_labels, fontsize=self.font_size, + color=self.display_color['basic']) + # extra to show highlight square + ax.set_xlim(-2, const.NO_5M_DAY + 1) + ax.patch.set_alpha(0) + return ax + + def on_pick_event(self, event): + """ + When a plot is select, the corresponding point on each plot will + be highlighted and their time and counts will be displayed. + :param event: pick event - event when object of canvas is selected. + The event happens before button_press_event. + """ + if event.mouseevent.name == 'scroll_event': + return + if event.mouseevent.button in ('up', 'down'): + return + info_str = "" + if event.artist in self.axes: + self.parent.vertical_scroll_pos = self.verticalScrollBar().value() + xdata = event.mouseevent.xdata + if xdata is None: + return + # clicked point's x value is the 5m index in a day + self.parent.five_minute_idx = xdata = round(xdata) + # 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 + # clicked point's y value which is the day index + self.parent.day_idx = ydata = round(event.mouseevent.ydata) + # refer to description in plot_channel 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.start_5mins_of_diff_days[ + day_index, five_min_index] + format_t = format_time(self.tps_t, self.date_mode, 'HH:MM:SS') + info_str += f"<pre>{format_t}:" + for tps_widget in self.parent.tps_widget_dict.values(): + for chan_id in tps_widget.plotting_data1: + c_data = tps_widget.plotting_data1[chan_id] + data = c_data['tps_data'][day_index, five_min_index] + info_str += (f" {chan_id}:" + f"{add_thousand_separator(sqrt(data))}") + info_str += " (counts)</pre>" + display_tracking_info(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): + """ + Ctrl + cmd: using parent's five_minute_idx and day_idx to position the + tps widget's ruler. + (equal to find_tps_tm_idx(xdata, self.start_5mins_of_diff_days) + + :param xdata: float - time value in other plot + """ + self.zoom_marker1_shown = False + for rl in self.rulers: + rl.set_data(self.parent.five_minute_idx, self.parent.day_idx) + + def on_shift_click(self, xdata): + """ + Shift + right click on other plot widget, this function will be called + to show marker for place when it is zoomed. + On the fist of zoom_marker, make ruler disappeared, set min_x. + On the second of zoom_maker, call set_lim_markers to mark the new + limit. + + :param xdata: float - time value in other plot + """ + + if not self.zoom_marker1_shown: + self.set_rulers_invisible() + self.min_x = xdata + self.zoom_marker1_shown = True + else: + [self.min_x, self.max_x] = sorted( + [self.min_x, xdata]) + self.set_lim_markers() + self.zoom_marker1_shown = False + + def set_rulers_invisible(self): + """ + Clear data for self.rulers to make them disappeared. + """ + for rl in self.rulers: + rl.set_data([], []) + + def set_lim_markers(self): + """ + Find x index (which index in five minutes of a day) and + y index (which day) of self.min_x and self.min_y, and set data for + all markers in self.zoom_marker1s and self.zoom_marker2s. + """ + x_idx, y_idx = find_tps_tm_idx(self.min_x, + self.start_5mins_of_diff_days) + for zm1 in self.zoom_marker1s: + zm1.set_data(x_idx, y_idx) + x_idx, y_idx = find_tps_tm_idx(self.max_x, + self.start_5mins_of_diff_days) + for zm2 in self.zoom_marker2s: + zm2.set_data(x_idx, y_idx) + + def request_stop(self): + """Request all running channel processors to stop.""" + for processor in self.tps_processors: + processor.request_stop() + + def replot(self): + """ + Reuse tps_data calculated in the first plotting to replot + with new color range selected. + """ + self.clear() + self.set_colors(self.main_window.color_mode) + self.plotting_bot = const.BOTTOM + title = get_title(self.set_key, self.min_x, self.max_x, self.date_mode) + self.timestamp_bar_top = self.plotting_axes.add_timestamp_bar(0.) + self.plotting_axes.set_title(title, y=0, v_align='bottom') + for chan_id in self.plotting_data1: + c_data = self.plotting_data1[chan_id] + self.plot_channel(c_data, chan_id) + self.done() diff --git a/sohstationviewer/view/plotting/waveform_dialog.py b/sohstationviewer/view/plotting/waveform_dialog.py index 46c77a4620bfe7535257f3b9c34c70698c7add50..8d59a462c50998d9f6e2a2a34ae4c25c9b49ba46 100755 --- a/sohstationviewer/view/plotting/waveform_dialog.py +++ b/sohstationviewer/view/plotting/waveform_dialog.py @@ -50,7 +50,7 @@ class WaveformWidget(MultiThreadedPlottingWidget): # refer to doc string for mass_pos_data to know the reason for 'ax_wf' ax = getattr(self.plotting, plot_functions[plot_type][1])( - c_data, chan_db_info, chan_id, None, None) + c_data, chan_db_info, chan_id) c_data['ax_wf'] = ax ax.chan = chan_id self.axes.append(ax) diff --git a/sohstationviewer/view/ui/main_ui.py b/sohstationviewer/view/ui/main_ui.py index a9a97a7e1d4c87268590ab5c54e1e92d280c08c6..f6143099ff85827e614f1cdfafa60823f673c38b 100755 --- a/sohstationviewer/view/ui/main_ui.py +++ b/sohstationviewer/view/ui/main_ui.py @@ -83,6 +83,11 @@ class UIMainWindow(object): """ self.search_line_edit: Union[QLineEdit, None] = None """ + log_checkbox: checkbox for user to indicate that they are reading a log + file + """ + self.log_checkbox: Union[QCheckBox, None] = None + """ clear_button: clear search_line_edit """ self.clear_search_action: Optional[QAction] = None @@ -365,7 +370,10 @@ class UIMainWindow(object): self.open_files_list = QListWidget( self.central_widget) - self.search_line_edit = QLineEdit(self.central_widget) + file_layout = QHBoxLayout() + file_layout.setSpacing(20) + left_layout.addLayout(file_layout) + self.search_line_edit = QLineEdit() self.search_line_edit.setPlaceholderText('Search...') self.search_line_edit.setToolTip('Filter the list of files based on ' 'the content.') @@ -390,7 +398,10 @@ class UIMainWindow(object): raise ValueError('No clear button could be found. Check its ' 'objectName attribute using QObject.findChildren ' 'without a name.') - left_layout.addWidget(self.search_line_edit) + file_layout.addWidget(self.search_line_edit) + + self.log_checkbox = QCheckBox("log") + file_layout.addWidget(self.log_checkbox) left_layout.addWidget(self.open_files_list, 1) pal = self.open_files_list.palette() @@ -766,6 +777,10 @@ class UIMainWindow(object): main_window.filter_folder_list ) + self.log_checkbox.toggled.connect( + main_window.on_log_file_checkbox_toggled + ) + self.replot_button.clicked.connect(main_window.replot_loaded_data) self.background_black_radio_button.toggled.connect( diff --git a/tests/controller/test_processing.py b/tests/controller/test_processing.py index 743eba9d12ce5661787a71e317e2366c0d2ae3cb..1ec8cd332a906aabe311d5710672e9e52ae90cdb 100644 --- a/tests/controller/test_processing.py +++ b/tests/controller/test_processing.py @@ -1,4 +1,3 @@ -import unittest from tempfile import TemporaryDirectory, NamedTemporaryFile from pathlib import Path @@ -10,7 +9,8 @@ from obspy import UTCDateTime from sohstationviewer.controller.processing import ( read_mseed_channels, detect_data_type, - get_data_type_from_file + get_data_type_from_file, + get_next_channel_from_mseed_file, ) from sohstationviewer.database.extract_data import get_signature_channels from PySide2 import QtWidgets @@ -336,34 +336,10 @@ class TestDetectDataType(TestCase): class TestGetDataTypeFromFile(TestCase): """Test suite for get_data_type_from_file""" - def test_rt130_data(self): + def test_can_detect_data_type_from_mseed_file(self): """ Test basic functionality of get_data_type_from_file - given file - contains RT130 data. - """ - rt130_file = Path(rt130_dir).joinpath( - '92EB/0/000000000_00000000') - expected_data_type = ('RT130', False) - self.assertTupleEqual( - get_data_type_from_file(rt130_file, get_signature_channels()), - expected_data_type - ) - - def test_cannot_detect_data_type(self): - """ - Test basic functionality of get_data_type_from_file - cannot detect - data type contained in given file. - """ - test_file = NamedTemporaryFile() - ret = get_data_type_from_file( - Path(test_file.name), get_signature_channels()) - self.assertEqual(ret, (None, False)) - - @unittest.expectedFailure - def test_mseed_data(self): - """ - Test basic functionality of get_data_type_from_file - given file - contains MSeed data. + contains MSeed data and the data type can be detected from the file. """ q330_file = q330_dir.joinpath('AX08.XA..VKI.2021.186') centaur_file = centaur_dir.joinpath( @@ -383,6 +359,42 @@ class TestGetDataTypeFromFile(TestCase): self.assertTupleEqual(get_data_type_from_file(pegasus_file, sig_chan), pegasus_data_type) + def test_cannot_detect_data_type_from_mseed_file(self): + """ + Test basic functionality of get_data_type_from_file - cannot detect + data type contained in given file. + """ + # We choose a waveform file because waveform channels cannot be used to + # determine the data type in a file. + mseed_file = q330_dir.joinpath('AX08.XA..LHE.2021.186') + expected = (None, False) + actual = get_data_type_from_file(mseed_file, get_signature_channels()) + self.assertEqual(expected, actual) + + def test_rt130_data(self): + """ + Test basic functionality of get_data_type_from_file - given file + contains RT130 data. + """ + rt130_file = Path(rt130_dir).joinpath( + '92EB/0/000000000_00000000') + expected_data_type = ('RT130', False) + self.assertTupleEqual( + get_data_type_from_file(rt130_file, get_signature_channels()), + expected_data_type + ) + + def test_empty_file(self): + """ + Test basic functionality of get_data_type_from_file - the given file is + empty. + """ + test_file = NamedTemporaryFile() + expected = (None, False) + actual = get_data_type_from_file( + Path(test_file.name), get_signature_channels()) + self.assertEqual(expected, actual) + def test_file_does_not_exist(self): """ Test basic functionality of get_data_type_from_file - given file does @@ -397,7 +409,73 @@ class TestGetDataTypeFromFile(TestCase): get_signature_channels()) def test_non_data_binary_file(self): - binary_file = Path(__file__).resolve().parent.parent.parent.joinpath( - 'images', 'home.png') + binary_file = TEST_DATA_DIR / 'Non-data-file' / 'non_data_file' ret = get_data_type_from_file(binary_file, get_signature_channels()) self.assertIsNone(ret) + + +class TestGetNextChannelFromMseedFile(TestCase): + def test_get_one_channel(self): + """ + Test basic functionality of get_next_channel_from_mseed_file - the + given file contains MSeed data. + """ + with self.subTest('test_big_endian_file'): + big_endian_file = q330_dir.joinpath('AX08.XA..VKI.2021.186') + with open(big_endian_file, 'rb') as infile: + expected = 'VKI' + actual = get_next_channel_from_mseed_file(infile) + self.assertEqual(expected, actual) + + with self.subTest('test_little_endian_file'): + little_endian_file = pegasus_dir.joinpath( + '2020/XX/KC01/VE1.D/XX.KC01..VE1.D.2020.129' + ) + with open(little_endian_file, 'rb') as infile: + expected = 'VE1' + actual = get_next_channel_from_mseed_file(infile) + self.assertEqual(expected, actual) + + def test_get_multiple_channel(self): + """ + Test basic functionality of get_next_channel_from_mseed_file - call the + function multiple times on the same file with enough channels to + accommodate those calls. + """ + try: + big_mseed_file = q330_dir.joinpath('AX08.XA..LHE.2021.186') + with open(big_mseed_file, 'rb') as infile: + get_next_channel_from_mseed_file(infile) + get_next_channel_from_mseed_file(infile) + get_next_channel_from_mseed_file(infile) + except ValueError: + self.fail('ValueError raised before file is exhausted.') + + def test_called_after_file_is_exhausted(self): + small_mseed_file = q330_dir.joinpath('AX08.XA..VKI.2021.186') + with self.assertRaises(ValueError): + with open(small_mseed_file, 'rb') as infile: + # A manual check confirms that the given file has 2 records. + get_next_channel_from_mseed_file(infile) + get_next_channel_from_mseed_file(infile) + get_next_channel_from_mseed_file(infile) + + def test_rt130_file(self): + """ + Test basic functionality of get_next_channel_from_mseed_file - the + given file contains RT130 data. + """ + rt130_file = rt130_dir.joinpath('92EB/0/000000000_00000000') + with open(rt130_file, 'rb') as infile: + with self.assertRaises(ValueError): + get_next_channel_from_mseed_file(infile) + + def test_non_data_file(self): + """ + Test basic functionality of get_next_channel_from_mseed_file - the + given file is not a data file. + """ + non_data_file = TEST_DATA_DIR / 'Non-data-file' / 'non_data_file' + with open(non_data_file, 'rb') as infile: + with self.assertRaises(ValueError): + get_next_channel_from_mseed_file(infile) diff --git a/tests/database/test_extract_data.py b/tests/database/test_extract_data.py index 268a6b0a1add7ec7959d4c3fb53989dd41181ef7..b2931355abdcfe238fe54cf2b16a14340ff4c931 100644 --- a/tests/database/test_extract_data.py +++ b/tests/database/test_extract_data.py @@ -21,7 +21,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'upDownDots', 'height': 2, 'unit': '', - 'linkedChan': None, 'convertFactor': 1, 'label': 'SOH/Data Def', 'fixPoint': 0, @@ -35,7 +34,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesMasspos', 'height': 4, 'unit': 'V', - 'linkedChan': None, 'convertFactor': 0.1, 'label': 'VM1-MassPos', 'fixPoint': 1, @@ -48,7 +46,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesMasspos', 'height': 4, 'unit': 'V', - 'linkedChan': None, 'convertFactor': 1, 'label': 'MassPos1', 'fixPoint': 1, @@ -62,7 +59,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesSRate', 'height': 8, 'unit': '', - 'linkedChan': None, 'convertFactor': 1, 'label': 'DS2', 'fixPoint': 0, @@ -75,7 +71,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesSRate', 'height': 8, 'unit': '', - 'linkedChan': None, 'convertFactor': 1, 'label': 'LHE-EW', 'fixPoint': 0, @@ -93,7 +88,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesDots', 'height': 2, 'unit': '', - 'linkedChan': None, 'convertFactor': 1, 'label': 'DEFAULT-Bad Channel ID', 'fixPoint': 0, @@ -106,7 +100,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesDots', 'height': 3, 'unit': 'us', - 'linkedChan': None, 'convertFactor': 1, 'label': 'LCE-PhaseError', 'fixPoint': 0, @@ -126,7 +119,6 @@ class TestGetChanPlotInfo(unittest.TestCase): 'plotType': 'linesDots', 'height': 2, 'unit': '', - 'linkedChan': None, 'convertFactor': 1, 'label': None, # Change for each test case 'fixPoint': 0, diff --git a/tests/test_data/Non-data-file/non_data_file b/tests/test_data/Non-data-file/non_data_file new file mode 100644 index 0000000000000000000000000000000000000000..b153bfe57660f2f0e2d8e1e30d42cc94d39ad8be Binary files /dev/null and b/tests/test_data/Non-data-file/non_data_file differ diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000000000000000000000000000000000..d1da9c98e401dc0828e1c05bf00ed0e27bc63b92 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = py38, py39, python3.10, flake8 + +[testenv] +commands = python -m unittest + +[testenv:flake8] +basepython = python +deps = flake8 +commands = flake8 --exclude sohstationviewer/view/ui sohstationviewer + flake8 tests + +