diff --git a/.gitignore b/.gitignore index e69de29..71fb7e7 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,4 @@ +## git ignores these +# + + venv/ diff --git a/assets/styles/app_stlyes.qss b/assets/styles/app_stlyes.qss deleted file mode 100644 index e69de29..0000000 diff --git a/config/app_styles.qss b/config/app_styles.qss new file mode 100644 index 0000000..f13ac94 --- /dev/null +++ b/config/app_styles.qss @@ -0,0 +1,66 @@ +QMainWindow { + background-color: #A7FAFC; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Halvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +QLabel, QGroupBox, QWidget, QComboBox, QPushButton { + color: #1A202C; +} + +QLabel#headerLabel { + font-size: 32px; + font-weight: bold; + color: #1A202C; + padding-bottom: 10px; +} + +QLabel { + font-size: 16px; + color: #4A5568; +} + +QLabel[property="controlLabel"] { + font-weight: bold; + color: #333333; +} + +QComboBox { + padding: 5px; + border: 1px solid #CBD5E0; + border-radius: 4ps; + background-color: white; +} + +QComboBox::drop-down { + border-left: 1px solid #CBD5E0; + width: 20px; +} + +QComboBox::down-arrow { + +} + +QPushButton { + background-color: #3B82F6; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-weight: bold; +} + +QPushButton:hover { + background-color: #2563EB; +} + +QPushButton:disabled { + background-color: #9CA3AF; + color: #E5E7EB; +} + +QWidget#waveformChartContainer { + background-color: white; + border: 1px solid #E2E8F0; + border-radius: 8px; + padding: 16px; +} diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fa6c62d Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..67e3084 Binary files /dev/null and b/src/__pycache__/main.cpython-313.pyc differ diff --git a/src/data/__pycache__/__init__.cpython-313.pyc b/src/data/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1a9f639 Binary files /dev/null and b/src/data/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/data/__pycache__/geonet_api.cpython-313.pyc b/src/data/__pycache__/geonet_api.cpython-313.pyc new file mode 100644 index 0000000..200d5f2 Binary files /dev/null and b/src/data/__pycache__/geonet_api.cpython-313.pyc differ diff --git a/src/data/__pycache__/miniseed_parser.cpython-313.pyc b/src/data/__pycache__/miniseed_parser.cpython-313.pyc new file mode 100644 index 0000000..607457b Binary files /dev/null and b/src/data/__pycache__/miniseed_parser.cpython-313.pyc differ diff --git a/src/data/geonet_api.py b/src/data/geonet_api.py new file mode 100644 index 0000000..6aedda0 --- /dev/null +++ b/src/data/geonet_api.py @@ -0,0 +1,55 @@ +import requests +from datetime import datetime, timedelta +from src.data.miniseed_parser import parse_miniseed_data + +NRT_FDSN_URL = 'https://service-nrt.geonet.org.nz/fdsnws/dataselect/1/query' + +def fetch_waveform_data(station, channel, location='--', duration_seconds=120): + """ + Fetches waveform data from Geonet NRT FDSN service. + + Args: + station (str): station code (e.g. 'WEL'), + channel (str): channel code (e.g. 'BHZ'), + location (str): locaiton code (default '--'), + duration_secconds (int): how many seconds of data to fetch (default 120). + + + Returns: + list: A list of parsed waveform traces. + Raises: + requests.exceptions.RequestException: For network errors. + Exception: For other errors during parsing. + """ + end_time = datetime.utcnow() #UTC for FDSN + start_time = end_time - timedelta(seconds=duration_seconds) + + #FDSN ISO 8601 format + params = { + 'network': 'NZ', + 'station': station, + 'location': location, + 'channel': channel, + 'starttime': start_time.isoformat(timespec='seconds'), + 'endtime': end_time.isoformat(timespec='seconds') + } + + print(f"Fetching from {NRT_FDSN_URL} with params: {params}") + + try: + response = requests.get(NRT_FDSN_URL, params=params, timeout=10) # 10 sec timeout + response.raise_for_status() + + if not response.content: + print(f"No content returned for {station}-{channel}.") + return [] + + traces = parse_miniseed_data(response.content) + return traces + + except requests.exceptions.RequestException as e: + print(f"Network or HTTP error fetching GeoNet data: {e}") + raise + except Exception as e: + print(f"Error processing GeoNet data: {e}") + raise diff --git a/src/data/miniseed_parser.py b/src/data/miniseed_parser.py new file mode 100644 index 0000000..6feceb9 --- /dev/null +++ b/src/data/miniseed_parser.py @@ -0,0 +1,55 @@ +from obspy import read +import datetime +import io + +def parse_miniseed_data(data_bytes): + """ + Parses miniseed byte data into obspy stream objects. + Transforms obspy trace data into a list of dictionaries suitible for plotting. + + Args: + data_bytes (bytes): The raw miniseed data recieved from the API. + + Returns: + list: A list of dictionaries, where each dict represents a trace: + { + 'channel': str, + 'station': str, + 'network': str, + 'location': str, + 'starttime': datetime, + 'sample_rate': float, + 'points': list of {'x': datetime, 'y': float} + } + Raises: + Exception: if obspy fails to read the data. + """ + + try: + stream = read(io.BytesIO(data_bytes), format="MSEED") + + traces_for_plotting = [] + for trace in stream: + start_time_dt = trace.stats.starttime.datetime + # create points + points = [] + + for i, amp in enumerate(trace.data): + # calc absolute time stap for each point + point_time = start_time_dt + datetime.timedelta(seconds=trace.stats.delta * i) + points.append({'x': point_time, 'y': float(amp)}) + + traces_for_plotting.append({ + 'channel': trace.stats.channel, + 'station': trace.stats.station, + 'network': trace.stats.network, + 'location': trace.stats.location if trace.stats.location else '--', + 'starttime': start_time_dt, + 'sample_rate': trace.stats.sampling_rate, + 'points': points + }) + return traces_for_plotting + except Exception as e: + print(f"Error parsing miniSEED data with ObsPy: {e}") + raise + diff --git a/src/main.py b/src/main.py index 697abac..bf63736 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,15 @@ +import os import sys from PyQt6.QtWidgets import QApplication from src.ui.main_window import MainWindow +from qt_material import apply_stylesheet def main(): app = QApplication(sys.argv) - window = Mainwindow() + + apply_stylesheet(app, theme='dark_blue.xml') + + window = MainWindow() window.show() sys.exit(app.exec()) diff --git a/src/ui/__pycache__/__init__.cpython-313.pyc b/src/ui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c0597ec Binary files /dev/null and b/src/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/ui/__pycache__/main_window.cpython-313.pyc b/src/ui/__pycache__/main_window.cpython-313.pyc new file mode 100644 index 0000000..ed2497e Binary files /dev/null and b/src/ui/__pycache__/main_window.cpython-313.pyc differ diff --git a/src/ui/__pycache__/waveform_chart.cpython-313.pyc b/src/ui/__pycache__/waveform_chart.cpython-313.pyc new file mode 100644 index 0000000..544994a Binary files /dev/null and b/src/ui/__pycache__/waveform_chart.cpython-313.pyc differ diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 79db347..99f39d6 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -1,24 +1,138 @@ - -from PyQt6.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QWidget -from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QWidget, QPushButton, QComboBox, QHBoxLayout +from PyQt6.QtCore import Qt, QTimer +from src.ui.waveform_chart import WaveformChart +from src.data.geonet_api import fetch_waveform_data +import sys class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("NZ EarthQuake Viewer (POC)") self.setGeometry(100, 100, 1200, 800) # x, y, width, height - + self.setMinimumSize(800, 600) + central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(15) - self.hello_label = QLabel("Hello from PyQt! This is Working.", self) + controls_layout = QHBoxLayout() - self. hello_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.app_title_label = QLabel("NZ Seismic Viewer", self) + self.app_title_label.setObjectName("headerLabel") + main_layout.addWidget(self.app_title_label) - self.hello_label.setStyleSheet("font-size: 24px; color: #333; padding: 20px;") + #Station & channel selection + station_label = QLabel("Station:", self) + station_label.setProperty("controlLabel", True) + self.station_selector = QComboBox(self) + self.station_selector.addItems(["WEL", "MAZ", "LTZ", "WRRZ"]) - main_layout.addWidget(self.hello_label) + channel_label = QLabel("Channel:", self) + channel_label.setProperty("controlLabel", True) + self.channel_selector = QComboBox(self) + self.channel_selector.addItems([ + "BHZ", "BHN", "BHE", # Common Broadband + "HHZ", "HHN", "HHE", # High-Gain Broadband + "LHZ", "LHN", "LHE", # Low-Gain Broadband, Long Period - + "EHZ", "EHN", "EHE", # Short-Period (Popular) + + "HNZ", "HNN", "HNE", # High-Gain, Strong Motion + + # OTHER COMMON Channels that could be encountered in the future + # ONLY UNCOMMENT THE LINE WHEN NEEDED! + # "BMZ", "BNN", "BNE", # Broadband Variants + # "ENZ", "ENN", "ENE", # Short-Period Variants + # "SHZ", "SHN", "SHE", # other Short-Period variants (Less Common in NZ) + ]) + self.channel_selector.setCurrentText("HHE") # Default + + #Duration Selector + duration_label = QLabel("Duration (min):", self) + duration_label.setProperty("controlLabel", True) + self.duration_selector = QComboBox(self) + self.duration_selector.addItems(["5", "15", "20", "30", "60", "90", "120"]) # 60 =1hr | 90 = 1.5hrs | 120 = 2hrs + self.duration_selector.setCurrentText("30") # Default to 30 mins + + #Location Code Selector (GeoNet requires this to not be '--') + location_label = QLabel("Location:", self) + location_label.setProperty("controlLabel", True) + self.location_selector = QComboBox(self) + self.location_selector.addItems(["--", "00", "01", "02", "03", "10", "20", "22", "30"]) # DOUBLE CHECK AS GET INTO THE MULTIPLE GRAPHS + self.location_selector.setCurrentText("10") # most responsive I've found on swarm - BC + + # Fetch Button + self.fetch_button = QPushButton("Fetch waveform", self) + self.fetch_button.clicked.connect(self.fetch_and_plot_waveform) + + # LAYOUT + controls_layout.addWidget(station_label) + controls_layout.addWidget(self.station_selector) + controls_layout.addSpacing(15) + controls_layout.addWidget(channel_label) + controls_layout.addWidget(self.channel_selector) + controls_layout.addSpacing(15) + controls_layout.addWidget(location_label) + controls_layout.addWidget(self.location_selector) + controls_layout.addSpacing(15) + controls_layout.addWidget(duration_label) + controls_layout.addWidget(self.duration_selector) + controls_layout.addStretch(1) + controls_layout.addWidget(self.fetch_button) + + main_layout.addLayout(controls_layout) + + self.status_label = QLabel("Ready.", self) + self.status_label.setObjectName("statusLabel") + + main_layout.addWidget(self.status_label) + + self.waveform_chart = WaveformChart(self) + self.waveform_chart.setObjectName("waveformChartContainer") + + main_layout.addWidget(self.waveform_chart) + + self.polling_timer = QTimer(self) + self.polling_timer.setInterval(15 * 1000) # 15 seconds + self.polling_timer.timeout.connect(self.fetch_and_plot_waveform) + self.polling_timer.start() + + self.fetch_and_plot_waveform() + + def set_status(self, message, is_error=False): + self.status_label.setText(message) + + if is_error: + self.status_label.setStyleSheet("color: #EF4444; font-weight: bold;") + else: + self.status_label.setStyleSheet("color: #6B7280; font-weight: normal;") + + def fetch_and_plot_waveform(self): + station = self.station_selector.currentText() + channel = self.channel_selector.currentText() + location_code = self.location_selector.currentText() + self.set_status(f"Fetching data for {station}-{channel}...", is_error=False) + self.fetch_button.setEnabled(False) + + try: + traces = fetch_waveform_data(station, channel, location=location_code, duration_seconds=1800) + + if traces: + selected_trace = next((t for t in traces if t['channel'] == channel), None) + if selected_trace and selected_trace['points']: + self.waveform_chart.plot_waveform(selected_trace['points'], f"Live Waveform: {station}-{channel}") + self.set_status(f"Data for {station}-{channel} updated. Last sample at {selected_trace['points'][-1]['x'].strftime('%H:%M:%S')}", is_error=False) + else: + self.set_status(f"No waveform points found for {station}-{channel} in this window", is_error=True) + self.waveform_chart.plot_waveform([], f"No data: {station}-{channel}") + else: + self.set_status(f"No Data returned from GeoNet for {station}-{channel}.", is_error=True) + self.waveform_chart.plot_waveform([], f"No data: {station}-{channel}") + except Exception as e: + self.set_status(f"Error fetching waveform: {e}", is_error=True) + print(f"Detailed error in main_window.py: {e}", file=sys.stderr) + finally: + self.fetch_button.setEnabled(True) diff --git a/src/ui/waveform_chart.py b/src/ui/waveform_chart.py new file mode 100644 index 0000000..48a2299 --- /dev/null +++ b/src/ui/waveform_chart.py @@ -0,0 +1,48 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import matplotlib.dates as mdates +from src.utils.mpl_styles import apply_mpl_styles + +class WaveformChart(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0,0,0,0) + self.layout.setSpacing(0) + + # Stylesheet for matplot + apply_mpl_styles() + + self.title_label = QLabel("Waveform Chart", self) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.layout.addWidget(self.title_label) + + # Matplot + self.fig = Figure(figsize=(10,4), dpi=100) + self.canvas = FigureCanvas(self.fig) + self.layout.addWidget(self.canvas) + + self.ax = self.fig.add_subplot(111) + + # Axis - dateTime + self.formatter = mdates.DateFormatter('%H:%M:%S') + self.ax.xaxis.set_major_formatter(self.formatter) + self.ax.tick_params(axis='x', rotation=45) + + self.fig.tight_layout(pad=0.5) + + def plot_waveform(self, points, title=""): + self.title_label.setText(title) + self.ax.clear() + + if points: + x_data = [p['x'] for p in points] + y_data = [p['y'] for p in points] + self.ax.plot(x_data, y_data) + self.fig.autofmt_xdate() + + self.canvas.draw() + + diff --git a/src/utils/__pycache__/__init__.cpython-313.pyc b/src/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c424475 Binary files /dev/null and b/src/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/utils/__pycache__/mpl_styles.cpython-313.pyc b/src/utils/__pycache__/mpl_styles.cpython-313.pyc new file mode 100644 index 0000000..3d456ef Binary files /dev/null and b/src/utils/__pycache__/mpl_styles.cpython-313.pyc differ diff --git a/src/utils/mpl_styles.py b/src/utils/mpl_styles.py new file mode 100644 index 0000000..a31367e --- /dev/null +++ b/src/utils/mpl_styles.py @@ -0,0 +1,28 @@ +import matplotlib.pyplot as plt + +def apply_mpl_styles(): + """ + Applies a consistent set of Matplotlib RC parameters for waveform plots. + Mimics Tailwind CSS colors for axes/labels. + """ + plt.rcParams.update({ + 'axes.labelcolor': '#CBD5E0', + 'xtick.color': '#A0AEC0', + 'ytick.color': '#A0AEC0', + 'axes.edgecolor': '#4A5568', + 'grid.color': '#4A5568', + 'grid.linestyle': '--', + 'grid.alpha': 0.6, + 'axes.grid': True, + 'figure.facecolor': '#1A202C', + 'axes.facecolor': '#2D3748', + 'lines.linewidth': 1, + 'lines.color': '4BC0C0', + 'axes.spines.top': False, + 'axes.spines.right': False, + 'font.family': ['sans-serif'], + 'font.sans-serif': ['Arial', 'Halvetica', 'DejaVu Sans', 'Liberation Sans', 'sans-serif'], + 'font.size': 10, + 'text.color': '#CBD5E0', + 'figure.autolayout': True, + })