POC Working

Only one seismograph at a time - all options working
- Spectrograph mode to be in a later update
- NEXT: "Area - based" config (allows yout to select areas)
This commit is contained in:
bryce
2025-08-17 10:51:49 +12:00
parent 66434a19b5
commit 9d5ba2d7c2
19 changed files with 385 additions and 10 deletions

4
.gitignore vendored
View File

@@ -0,0 +1,4 @@
## git ignores these
#
venv/

66
config/app_styles.qss Normal file
View File

@@ -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;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

55
src/data/geonet_api.py Normal file
View File

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

View File

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

View File

@@ -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())

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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)

48
src/ui/waveform_chart.py Normal file
View File

@@ -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()

Binary file not shown.

Binary file not shown.

28
src/utils/mpl_styles.py Normal file
View File

@@ -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,
})