-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add dwd_global_rad_dev Development API Server
- 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
Showing
8 changed files
with
338 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1 @@ | ||
.DS_Store | ||
.DS_Store |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
4 changes: 4 additions & 0 deletions
4
dwd_global_rad_api_server_dev/services.d/dwd_global_rad_api_server/finish
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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." |
21 changes: 21 additions & 0 deletions
21
dwd_global_rad_api_server_dev/services.d/dwd_global_rad_api_server/run
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
|