add http server to serve generated maps, reorganize structure

This commit is contained in:
Cyberes 2024-09-03 16:37:39 -06:00
parent 5dc2f23dc0
commit 979381f365
11 changed files with 204 additions and 64 deletions

View File

@ -26,10 +26,10 @@ LON_RANGE_MIN=<lower range for lon bounding box> \
LON_RANGE_MAX=<upper range for lon bounding box> \ LON_RANGE_MAX=<upper range for lon bounding box> \
CDDIS_USERNAME=<username> CDDIS_PASSWORD=<password> \ CDDIS_USERNAME=<username> CDDIS_PASSWORD=<password> \
MQTT_BROKER_HOST="<Home Assistant IP>" MQTT_BROKER_PORT=1883 MQTT_USERNAME="user" MQTT_PASSWORD="<password>" \ MQTT_BROKER_HOST="<Home Assistant IP>" MQTT_BROKER_PORT=1883 MQTT_USERNAME="user" MQTT_PASSWORD="<password>" \
python3 main.py python3 mqtt.py
``` ```
An example systemd service file is provided. Example systemd service files are provided.
### Home Assistant MQTT Config ### Home Assistant MQTT Config

49
feeder/cache.py Normal file
View File

@ -0,0 +1,49 @@
import logging
import os
import pickle
import signal
import sys
import threading
import time
import traceback
from datetime import datetime
from redis import Redis
from lib.cddis_fetch import fetch_latest_ionex
from lib.tecmap import get_tecmaps, parse_ionex_datetime
logging.basicConfig(level=logging.INFO)
CDDIS_USERNAME = os.getenv('CDDIS_USERNAME')
CDDIS_PASSWORD = os.getenv('CDDIS_PASSWORD')
if not CDDIS_USERNAME or not CDDIS_PASSWORD:
logging.critical('Must set CDDIS_USERNAME and CDDIS_PASSWORD environment variables')
sys.exit(1)
def update_cache():
try:
redis = Redis(host='localhost', port=6379, db=0)
redis.flushall()
while True:
utc_hr = datetime.utcnow().hour
logging.info('Fetching latest IONEX data')
logging.info(f'Using hour {utc_hr}')
ionex_data = fetch_latest_ionex(CDDIS_USERNAME, CDDIS_PASSWORD)
parsed_data = []
for tecmap, epoch in get_tecmaps(ionex_data):
parsed_dt = parse_ionex_datetime(epoch)
parsed_data.append((tecmap, parsed_dt))
redis.set('tecmap_data', pickle.dumps(parsed_data))
logging.info('Scrape complete')
time.sleep(1800) # 30 minutes
except:
logging.critical(traceback.format_exc())
os.kill(os.getpid(), signal.SIGKILL)
if __name__ == '__main__':
threading.Thread(target=update_cache).start()
while True:
time.sleep(3600)

46
feeder/global-image.py Normal file
View File

@ -0,0 +1,46 @@
import io
import logging
import pickle
import sys
from datetime import datetime
from typing import List
from PIL import Image
from redis import Redis
from lib.tecmap import plot_tec_map
logging.basicConfig(level=logging.INFO)
LAT_RANGE_MIN = -90
LAT_RANGE_MAX = 90
LON_RANGE_MIN = -180
LON_RANGE_MAX = 180
redis = Redis(host='localhost', port=6379, db=0)
utc_hr = datetime.utcnow().hour
logging.info(f'Generating plot for hour {utc_hr}')
ionex_data: List = pickle.loads(redis.get('tecmap_data'))
if ionex_data is None:
logging.critical('Redis has not been populated yet. Is cache.py running?')
sys.exit(1)
for tecmap, epoch in ionex_data:
if epoch.hour == utc_hr:
plt = plot_tec_map(tecmap, [float(LON_RANGE_MIN), float(LON_RANGE_MAX)], [float(LAT_RANGE_MIN), float(LAT_RANGE_MAX)])[1]
buf = io.BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0, dpi=110)
plt.close()
del plt
buf.seek(0)
img = Image.open(buf)
# img = img.resize((img.size[0], 500), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format='PNG')
redis.set('global_map', buf.getvalue())
buf.close()

View File

@ -39,11 +39,11 @@ def plot_tec_map(tecmap, lon_range: list, lat_range: list):
# Create arrays of latitudes and longitudes to match the geographical grid of the TEC map data. # Create arrays of latitudes and longitudes to match the geographical grid of the TEC map data.
# This is hard coded and should never change. # This is hard coded and should never change.
lat = np.arange(-87.5, 87.5, 2.5) lat = np.arange(-87.5, 87.5, 2.5)
lon = np.arange(-180, 180, 2.5) lon = np.arange(-180, 180, 5.0)
# Create a mask for the data in the lat/lon range # Create a mask for the data in the lat/lon range
lon_mask = (lon >= lon_range[0]) & (lon <= lon_range[1]) lon_mask = (lon >= lon_range[0]) & (lon < lon_range[1])
lat_mask = (lat >= lat_range[0]) & (lat <= lat_range[1]) lat_mask = (lat >= lat_range[0]) & (lat < lat_range[1])
mask = np.ix_(lat_mask, lon_mask) mask = np.ix_(lat_mask, lon_mask)
# Select only the data in the lat/lon range # Select only the data in the lat/lon range
@ -54,16 +54,13 @@ def plot_tec_map(tecmap, lon_range: list, lat_range: list):
# Make graph pretty # Make graph pretty
ax.coastlines() ax.coastlines()
plt.title('VTEC map') plt.title(datetime.now().strftime('%H:%M %d-%m-%Y'), fontsize=12, y=1.04)
plt.suptitle('Vertical Total Electron Count', fontsize=16, y=0.87)
divider = make_axes_locatable(ax) divider = make_axes_locatable(ax)
ax_cb = divider.new_horizontal(size='5%', pad=0.1, axes_class=plt.Axes) ax_cb = divider.new_horizontal(size='5%', pad=0.1, axes_class=plt.Axes)
f.add_axes(ax_cb) f.add_axes(ax_cb)
cb = plt.colorbar(h, cax=ax_cb) cb = plt.colorbar(h, cax=ax_cb)
plt.rc('text', usetex=True) plt.rc('text', usetex=True)
cb.set_label('TECU ($10^{16} \\mathrm{el}/\\mathrm{m}^2$)') cb.set_label('TECU ($10^{16} \\mathrm{el}/\\mathrm{m}^2$)')
plt.show()
# Deallocate
plt.close()
return tecmap_ranged, plt return tecmap_ranged, plt

View File

@ -1,15 +1,17 @@
import logging import logging
import os import os
import pickle
import sys import sys
import threading
import time import time
import traceback
from datetime import datetime from datetime import datetime
from typing import List
import numpy as np import numpy as np
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from redis import Redis
from lib.cddis_fetch import fetch_latest_ionex from lib.tecmap import plot_tec_map, parse_ionex_datetime
from lib.tecmap import get_tecmaps, plot_tec_map, parse_ionex_datetime
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -26,6 +28,7 @@ LON_RANGE_MIN = os.getenv('LON_RANGE_MIN')
LON_RANGE_MAX = os.getenv('LON_RANGE_MAX') LON_RANGE_MAX = os.getenv('LON_RANGE_MAX')
if not LAT_RANGE_MIN or not LAT_RANGE_MAX or not LON_RANGE_MIN or not LON_RANGE_MAX: if not LAT_RANGE_MIN or not LAT_RANGE_MAX or not LON_RANGE_MIN or not LON_RANGE_MAX:
logging.critical('Must set LAT_RANGE_MIN, LAT_RANGE_MAX, LON_RANGE_MIN, and LON_RANGE_MAX environment variables') logging.critical('Must set LAT_RANGE_MIN, LAT_RANGE_MAX, LON_RANGE_MIN, and LON_RANGE_MAX environment variables')
print(LAT_RANGE_MIN, LAT_RANGE_MAX, LON_RANGE_MIN, LON_RANGE_MAX)
sys.exit(1) sys.exit(1)
CDDIS_USERNAME = os.getenv('CDDIS_USERNAME') CDDIS_USERNAME = os.getenv('CDDIS_USERNAME')
@ -57,56 +60,29 @@ def publish(topic: str, msg):
logging.error(f'Failed to send message to topic {topic_expanded}.') logging.error(f'Failed to send message to topic {topic_expanded}.')
class DataCache: def main():
def __init__(self): try:
self.value = None redis = Redis(host='localhost', port=6379, db=0)
self.lock = threading.Lock()
def update(self, new_value): while True:
with self.lock: utc_hr = datetime.utcnow().hour
self.value = new_value logging.info('Fetching latest IONEX data')
logging.info(f'Using hour {utc_hr}')
def get(self): ionex_data: List = pickle.loads(redis.get('tecmap_data'))
with self.lock: avg_tec = None
return self.value for tecmap, epoch in ionex_data:
parsed_dt = parse_ionex_datetime(epoch)
if parsed_dt.hour == utc_hr:
cached_data = DataCache() avg_tec = np.mean(plot_tec_map(tecmap, [float(LON_RANGE_MIN), float(LON_RANGE_MAX)], [float(LAT_RANGE_MIN), float(LAT_RANGE_MAX)])[0])
logging.info(f'Data timestamp: {parsed_dt.isoformat()}')
break
def update_cache(): latest = round(avg_tec, 1)
while True:
utc_hr = datetime.utcnow().hour
logging.info('Fetching latest IONEX data')
logging.info(f'Using hour {utc_hr}')
ionex_data = fetch_latest_ionex(CDDIS_USERNAME, CDDIS_PASSWORD)
avg_tec = None
for tecmap, epoch in get_tecmaps(ionex_data):
parsed_dt = parse_ionex_datetime(epoch)
if parsed_dt.hour == utc_hr:
avg_tec = np.mean(plot_tec_map(tecmap, [float(LON_RANGE_MIN), float(LON_RANGE_MAX)], [float(LAT_RANGE_MIN), float(LAT_RANGE_MAX)])[0])
logging.info(f'Data timestamp: {parsed_dt.isoformat()}')
break
latest = round(avg_tec, 1)
cached_data.update(latest)
logging.info(f'Latest value: {latest}')
time.sleep(1800) # 30 minutes
def publish_cache():
"""
A seperate thread that will send the current value to HA every minute. This
seems to help avoid HA reporting "unknown" for the VTEC value.
"""
while True:
latest = cached_data.get()
if latest is not None:
publish('vtec', latest) publish('vtec', latest)
time.sleep(60) time.sleep(60)
except:
logging.critical(traceback.format_exc())
sys.exit(1)
if __name__ == '__main__': if __name__ == '__main__':
threading.Thread(target=update_cache).start() main()
threading.Thread(target=publish_cache).start()
while True:
time.sleep(3600)

View File

@ -5,3 +5,7 @@ requests==2.32.3
matplotlib==3.9.2 matplotlib==3.9.2
cartopy==0.23.0 cartopy==0.23.0
numpy==2.0.1 numpy==2.0.1
redis==5.0.8
async-timeout==4.0.3
Pillow
flask==3.0.3

22
feeder/server.py Normal file
View File

@ -0,0 +1,22 @@
import io
import redis
from flask import Flask, send_file
app = Flask(__name__)
redis_client = redis.Redis(host='localhost', port=6379)
@app.route('/global')
def serve_global_map():
global_map_data = redis_client.get('global_map')
if global_map_data is None:
return "No global map available", 404
buf = io.BytesIO(global_map_data)
buf.seek(0)
return send_file(buf, mimetype='image/png')
if __name__ == '__main__':
app.run()

View File

@ -0,0 +1,15 @@
[Unit]
Description=Space Weather Cache
After=network.target
[Service]
Type=simple
User=homeassistant
EnvironmentFile=/etc/secrets/space-weather
ExecStart=/srv/ha-noaa-space-weather/venv/bin/python /srv/ha-noaa-space-weather/feeder/cache.py
SyslogIdentifier=space-weather-cache
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,15 @@
[Unit]
Description=Space Weather Global Image Generator
After=network.target
[Service]
Type=simple
User=homeassistant
EnvironmentFile=/etc/secrets/space-weather
ExecStart=/srv/ha-noaa-space-weather/venv/bin/python /srv/ha-noaa-space-weather/feeder/global-image.py
SyslogIdentifier=space-weather-global-image
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target

View File

@ -1,12 +1,13 @@
[Unit] [Unit]
Description=Space Weather VTEC Description=Space Weather MQTT
After=network.target After=network.target
[Service] [Service]
Type=simple Type=simple
User=homeassistant User=homeassistant
EnvironmentFile=/etc/secrets/space-weather EnvironmentFile=/etc/secrets/space-weather
ExecStart=/srv/space-weather/ha-noaa-space-weather/venv/bin/python /srv/space-weather/ha-noaa-space-weather/feeder/main.py ExecStart=/srv/ha-noaa-space-weather/venv/bin/python /srv/ha-noaa-space-weather/feeder/mqtt.py
SyslogIdentifier=space-weather-mqtt
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s

View File

@ -0,0 +1,15 @@
[Unit]
Description=Space Weather Server
After=network.target
[Service]
Type=simple
User=homeassistant
EnvironmentFile=/etc/secrets/space-weather
ExecStart=/srv/ha-noaa-space-weather/venv/bin/python /srv/ha-noaa-space-weather/feeder/server.py
SyslogIdentifier=space-weather-server
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target