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
+
+