Skip to content

Commit

Permalink
Add dwd_global_rad_dev Development API Server
Browse files Browse the repository at this point in the history
- This API server shall provide an easier to maintain image. The Home Assistant Base image is quite tricky when it comes to installing complex requirements like the netCDF4 package.
  • Loading branch information
aschmere committed Jun 17, 2024
1 parent af549c7 commit f5dad1f
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 1 deletion.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
.DS_Store
.DS_Store
25 changes: 25 additions & 0 deletions dwd_global_rad_api_server_dev/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
build-essential \
libfreetype6-dev \
libpng-dev \
pkg-config \
sudo \
git \
ffmpeg \
procps \
&& rm -rf /var/lib/apt/lists/*

RUN pip install --upgrade pip setuptools wheel

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app.py /app/app.py
COPY main.py /app/main.py
WORKDIR /app

EXPOSE 5002
# Run the application
CMD ["python", "app.py"]
156 changes: 156 additions & 0 deletions dwd_global_rad_api_server_dev/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from flask import Flask, request, jsonify, send_file
import json
import io
import pickle
import xarray as xr
import main
import logging
import dwd_global_radiation as dgr
from datetime import datetime, timezone

app = Flask(__name__)

# Instantiate main object
objGlobalRadiation = dgr.GlobalRadiation()
# Configure logging with a custom formatter
class CustomFormatter(logging.Formatter):
def format(self, record):
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
formatter = logging.Formatter(log_format, datefmt='%Y-%m-%d %H:%M:%S')
return formatter.format(record)

# Remove the default Flask handlers if any
for handler in app.logger.handlers[:]:
app.logger.removeHandler(handler)
handler = logging.StreamHandler()
handler.setFormatter(CustomFormatter())
app.logger.addHandler(handler)
app.logger.setLevel(logging.INFO)

app.logger.propagate = False


@app.route('/locations/<name>/forecast/<int:number_of_hours>h', methods=['GET'])
def get_forecast_for_future_hour(name, number_of_hours):
try:
# Get the current datetime
datetime_input = datetime.now(timezone.utc)

# Get the location
location = objGlobalRadiation.get_location_by_name(name)
if not location:
return jsonify({'error': 'Location not found'}), 404

# Get the forecast for the specified future hour
forecast = location.get_forecast_for_future_hour(datetime_input, number_of_hours)
return jsonify(forecast)
except Exception as e:
app.logger.error(f"Error fetching forecast for future hour: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/process', methods=['POST'])
def process():
try:
if 'dataset' in request.files:
app.logger.info("Dataset provided in request")
dataset_file = request.files['dataset']
ds = pickle.load(dataset_file)
else:
app.logger.info("No dataset provided, fetching internally")
objGlobalRadiation.fetch_forecasts()
ds = objGlobalRadiation.forecast_data.all_grid_forecasts

if 'locations' in request.form:
app.logger.info("Locations provided in request")
locations = json.loads(request.form['locations'])
else:
app.logger.info("No locations provided, using internal locations")
locations = [{"lat": loc.latitude, "lon": loc.longitude} for loc in objGlobalRadiation.locations]

app.logger.info("Creating animation")
output_file = main.create_animation(ds, locations)
app.logger.info("Animation created successfully")

return send_file(output_file, as_attachment=True, download_name='forecast_animation.gif', mimetype='image/gif')
except Exception as e:
app.logger.error(f"Error processing request: {e}")
return jsonify({'error': str(e)}), 500
# Instantiate main object
objGlobalRadiation = dgr.GlobalRadiation()

@app.route('/locations', methods=['GET'])
def get_locations():
locations = objGlobalRadiation.locations
if not locations:
return jsonify({'error': 'No locations found'}), 404

serializable_locations = [loc.to_dict() for loc in locations]
print(json.dumps(serializable_locations, indent=2)) # Pretty-print the JSON data
return jsonify(serializable_locations)

@app.route('/locations', methods=['POST'])
def add_location():
data = request.json
name = data['name']
latitude = data['latitude']
longitude = data['longitude']
try:
# Call the add_location method from the GlobalRadiation class
objGlobalRadiation.add_location(name=name, latitude=latitude, longitude=longitude)
return jsonify({'status': 'Location added successfully'}), 200
except ValueError as e:
# Handle the exception and return a 400 Bad Request response
return jsonify({'error': str(e)}), 400

@app.route('/locations/<name>', methods=['GET'])
def get_location_by_name(name):
"""Fetch a specific location by name."""
location = objGlobalRadiation.get_location_by_name(name)
if location is None:
return jsonify({'error': 'Location not found'}), 404

return jsonify(location.to_dict())

@app.route('/forecasts', methods=['GET'])
def fetch_forecasts():
"""Check if forecast data is available."""
# Fetch the forecast data from the DWD servers
objGlobalRadiation.fetch_forecasts()

forecast_data = objGlobalRadiation.forecast_data
if forecast_data is None:
return jsonify({'error': 'Forecast data not found'}), 404

return jsonify({'message': 'Forecast data is available'}), 200

@app.route('/measurements', methods=['GET'])
def fetch_measurements():
"""Fetch the measurement data for a specific location by name."""

# Get the 'hours' query parameter from the request, default to 3 if not provided
hours = request.args.get('hours', default=3, type=int)
# Fetch the measurement data from the DWD servers
objGlobalRadiation.fetch_measurements(max_hour_age_of_measurement=hours)

measurement_data = objGlobalRadiation.measurement_data
if measurement_data is None:
return jsonify({'error': 'Forecast data not found'}), 404

return jsonify({'message': 'Measurement data is available'}), 200

@app.route('/locations/<name>', methods=['DELETE'])
def remove_location(name):
"""Remove a specific location by name."""
try:
objGlobalRadiation.remove_location(name)
return jsonify({'status': 'Location removed successfully'}), 200
except ValueError as e:
return jsonify({'error': str(e)}), 404
@app.route('/status', methods=['GET'])

def get_status():
"""Endpoint to check if the API server is running."""
return '', 204 # No Content status

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5001)
14 changes: 14 additions & 0 deletions dwd_global_rad_api_server_dev/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
name: DWD Global Rad Api Server (Development)
version: "0.1.0"
slug: dwd_global_rad_api_server
description: DWD Global Rad Api Server
arch:
- armv7
- aarch64
- amd64
image: "aschmere/dwd_global_rad_api_server_dev-{arch}"
init: false
ports:
5001/tcp: 5002

109 changes: 109 additions & 0 deletions dwd_global_rad_api_server_dev/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import numpy as np
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from matplotlib.colors import LinearSegmentedColormap
import io
from matplotlib.animation import PillowWriter

import logging

# Set up logging configuration to show only warnings and errors
#logging.basicConfig(level=logging.WARNING)
#logging.getLogger('fiona').setLevel(logging.WARNING)
#logging.getLogger('matplotlib').setLevel(logging.WARNING)

def create_animation(ds, custom_locations):
lats = ds['lat'].values
lons = ds['lon'].values
data = ds['SIS'].values # Assuming shape is (num_hours, lat, lon)
times = ds['time'].values

integer_lats_indices = np.where(np.isclose(lats % 1, 0, atol=0.01))[0]
integer_lons_indices = np.where(np.isclose(lons % 1, 0, atol=0.01))[0]
integer_lats = lats[integer_lats_indices]
integer_lons = lons[integer_lons_indices]

reference_locations = [
(52.5200, 13.4050, 'Berlin'),
(48.1351, 11.5820, 'Munich'),
(53.5511, 9.9937, 'Hamburg'),
(50.1109, 8.6821, 'Frankfurt'),
(48.7758, 9.1829, 'Stuttgart'),
(49.7913, 9.9534, 'Würzburg'),
(51.3127, 9.4797, 'Kassel'),
(50.9375, 6.9603, 'Köln'), # Cologne
(51.0504, 13.7373, 'Dresden') # Dresden
]

colors = [
(0.0, 'blue'),
(0.1, 'cyan'),
(0.3, 'green'),
(0.5, 'yellow'),
(0.7, 'orange'),
(0.9, 'red'),
(1.0, 'firebrick')
]
cmap = LinearSegmentedColormap.from_list('custom_cmap', colors)

fig, ax = plt.subplots(figsize=(14, 14), subplot_kw={'projection': ccrs.PlateCarree()})
extent = [lons.min(), lons.max(), lats.min(), lats.max()]
ax.set_extent(extent, crs=ccrs.PlateCarree())

mesh = ax.pcolormesh(lons, lats, data[0], cmap=cmap, shading='auto', vmin=0, vmax=1000)

ax.add_feature(cfeature.BORDERS, linestyle='-')
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.LAND, edgecolor='black')
ax.add_feature(cfeature.LAKES, alpha=0.5)
ax.add_feature(cfeature.RIVERS)

for lat, lon, name in reference_locations:
ax.plot(lon, lat, marker='*', color='blue', markersize=8, transform=ccrs.PlateCarree())
ax.text(lon, lat, name, fontsize=8, transform=ccrs.PlateCarree(), verticalalignment='top')

for loc in custom_locations:
lat, lon = loc['lat'], loc['lon']
ax.plot(lon, lat, marker='^', color='red', markersize=8, markeredgecolor='black', transform=ccrs.PlateCarree())

color_bar = fig.colorbar(mesh, ax=ax, orientation='vertical', pad=0.01,aspect=70, shrink=0.8)
color_bar.set_label('SIS [W/m2]', fontsize=14)

ax.set_xlabel('Longitude',fontsize=16)
ax.set_ylabel('Latitude',fontsize=16)
ax.set_xticks(integer_lons)
ax.set_xticklabels(np.round(integer_lons, 2), rotation=45, ha="right")
ax.set_yticks(integer_lats)
ax.set_yticklabels(np.round(integer_lats, 2))

#forecast_hour_title = fig.suptitle('Forecast Hour: 0', fontsize=16, y=0.95)
fc_const_str = 'Forecast Hour: '
time_const_str = ' UTC Time: '
combined_text = fc_const_str + '0' + time_const_str + str(times[0])
time_title = fig.text(0.65, 0.9, combined_text, ha='right', fontsize=18)

ax.xaxis.set_ticks_position('bottom')
ax.xaxis.set_label_position('bottom')

# Adjust margins to minimize space on the left, right, top, and bottom
plt.subplots_adjust(left=0.05, top=0.95, right=0.99, bottom=0.01)
plt.tight_layout()

def update(hour):
mesh.set_array(data[hour].ravel())
#forecast_hour_title.set_text(f'Forecast Hour: {hour}')
combined_text = fc_const_str + str(hour) + time_const_str + str(times[hour])
time_title.set_text(combined_text)

ani = animation.FuncAnimation(fig, update, frames=data.shape[0], interval=2000, repeat=True)

buffer = io.BytesIO()
ani.save("forecast_animation.gif", writer=PillowWriter(fps=0.5))
with open("forecast_animation.gif", "rb") as f:
buffer.write(f.read())
buffer.seek(0)

plt.close(fig) # Close the figure to free up resources
return buffer
9 changes: 9 additions & 0 deletions dwd_global_rad_api_server_dev/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
matplotlib
cartopy
numpy==2.0.0
xarray
netCDF4==1.7.0
flask
requests
dwd_global_radiation
pillow
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/execlineb -S0

# Print an info message
echo "Application stopped."
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh

# Print an info message
echo "Starting the application..."

# Activate the virtual environment
source /app/venv/bin/activate

# Debug: Print which Python is being used
which python

# Debug: Print Python version
python --version

# Debug: List files in /app directory
ls -l /app

# Start the application
exec python /app/app.py


0 comments on commit f5dad1f

Please sign in to comment.