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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -0,0 +1,4 @@
|
||||
## git ignores these
|
||||
#
|
||||
|
||||
venv/
|
||||
|
66
config/app_styles.qss
Normal file
66
config/app_styles.qss
Normal 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;
|
||||
}
|
BIN
src/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/main.cpython-313.pyc
Normal file
BIN
src/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/data/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/data/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/data/__pycache__/geonet_api.cpython-313.pyc
Normal file
BIN
src/data/__pycache__/geonet_api.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/data/__pycache__/miniseed_parser.cpython-313.pyc
Normal file
BIN
src/data/__pycache__/miniseed_parser.cpython-313.pyc
Normal file
Binary file not shown.
55
src/data/geonet_api.py
Normal file
55
src/data/geonet_api.py
Normal 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
|
55
src/data/miniseed_parser.py
Normal file
55
src/data/miniseed_parser.py
Normal 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
|
||||
|
@@ -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())
|
||||
|
||||
|
BIN
src/ui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/ui/__pycache__/main_window.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/main_window.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/ui/__pycache__/waveform_chart.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/waveform_chart.cpython-313.pyc
Normal file
Binary file not shown.
@@ -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
48
src/ui/waveform_chart.py
Normal 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()
|
||||
|
||||
|
BIN
src/utils/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/utils/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/utils/__pycache__/mpl_styles.cpython-313.pyc
Normal file
BIN
src/utils/__pycache__/mpl_styles.cpython-313.pyc
Normal file
Binary file not shown.
28
src/utils/mpl_styles.py
Normal file
28
src/utils/mpl_styles.py
Normal 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,
|
||||
})
|
Reference in New Issue
Block a user