forecast_app.views
All the views for the forecast app. Each view corresponds to a route.
View Source
"""All the views for the forecast app. Each view corresponds to a route.""" import datetime import os import time from datetime import date, timedelta from multiprocessing import Process import flask_login import pandas as pd from flask import ( current_app, redirect, render_template, request, send_from_directory, url_for, ) from flask.views import MethodView, View from sqlalchemy import desc, null from forecast_app import burtcoppd from forecast_app.models import ( ForecastModel, ForecastWeatherData, HistoricalLoadData, HistoricalWeatherData, ) from forecast_app.utils import ADMIN_USER, db, safe_error, safe_flash, upload_file from forecast_app.weather import AsosRequest, NwsForecastRequest # TODO: Set default ordering to milliseconds / timestamps to prevent chart mixups class DataView(MethodView): """Abstract class for handling the various views for uploading and displaying historical and forecast data""" decorators = [flask_login.login_required] model = None view_name = None title = None gist_example = None instructions = None # Variable for whether user can sync data with external API sync_request = None def get_missing_values_summary(self): """Collect basic information about missing values in the given model""" df = self.model.to_df() col = self.model.column_name df = df.set_index("dates") cumulative_df = ( df[col] .isnull() .astype(int) .groupby(df[col].notnull().astype(int).cumsum()) .cumsum() ) max_span = cumulative_df.max() end_datetime = cumulative_df.idxmax() # NOTE: timedelta can't take numpy.int64 start_datetime = end_datetime - datetime.timedelta(hours=int(max_span - 1)) return { "count": df[col].isna().sum(), "max_span": max_span, "start_datetime": start_datetime, "end_datetime": end_datetime, } def get_summary(self): """Return dictionary for the data views "Data Summary" section.""" if self.model.query.count() == 0: return None return { "count": self.model.query.count(), "start_datetime": self.model.query.order_by(self.model.timestamp) .first() .timestamp, "end_datetime": self.model.query.order_by(self.model.timestamp.desc()) .first() .timestamp, "missing_values": self.get_missing_values_summary(), } def get_table(self): """Put data into a format that can be rendered by jinja as a table""" query = db.session.query( self.model.timestamp, self.model.value, ) query = query.order_by(desc(self.model.timestamp)) return [{"timestamp": timestamp, "value": value} for timestamp, value in query] def get_chart(self): """Put data into a format that can be rendered by highstock as a chart""" query = db.session.query(self.model.milliseconds, self.model.value).order_by( self.model.milliseconds ) data = [list(row) for row in query] return [{"data": data, "name": self.model.column_name}] def post(self): """Given a POST request to the data view's endpoint, upload the file and load it into the database""" filepath = upload_file("file") self.model.load_data(filepath) return redirect(url_for(self.view_name)) def get(self): """Render the data view""" # NOTE: Just pass self? return render_template( f"data-view.html", **{ "name": self.view_name, "table": self.get_table(), "chart": self.get_chart(), "title": self.title, "gist_example": self.gist_example, "instructions": self.instructions, "sync_request": self.sync_request, "summary": self.get_summary(), }, ) class ForecastWeatherDataView(DataView): """View for the forecast weather data""" model = ForecastWeatherData view_name = "forecast-weather-data" title = "Forecast Weather Data" gist_example = "https://gist.github.com/kmcelwee/e56308a8096356fcdc699ca168904aa4" instructions = "/instructions#forecast-weather-data" sync_request = "The National Weather Service" class HistoricalLoadDataView(DataView): """View for the historical load data""" model = HistoricalLoadData view_name = "historical-load-data" title = "Historical Load Data" gist_example = "https://gist.github.com/kmcelwee/ce163d8c9d2871ab4c652382431c7801" instructions = "/instructions#historical-load-data" class HistoricalWeatherDataView(DataView): """View for the historical weather data""" model = HistoricalWeatherData view_name = "historical-weather-data" title = "Historical Weather Data" gist_example = "https://gist.github.com/kmcelwee/e56308a8096356fcdc699ca168904aa4" instructions = "/instructions#historical-weather-data" sync_request = "ASOS" class LatestForecastView(MethodView): """Masked redirect to the latest successful forecast model""" view_name = "latest-forecast" decorators = [flask_login.login_required] def get_latest_successful_model(self): """Return the latest successful forecast model to show to user in this view""" # NOTE: Need to do manually because of exited_successfully being a property query = ( db.session.query(ForecastModel) .order_by(desc(ForecastModel.creation_date)) .all() ) for model in query: if model.exited_successfully: latest_successful_forecast = model break else: latest_successful_forecast = None return latest_successful_forecast def get(self): """Redirect to the latest successful forecast model if one exists, otherwise show a page with message.""" model = self.get_latest_successful_model() if model: return ForecastModelDetailView().get(slug=model.slug) else: return render_template("latest-forecast.html") class LoginView(MethodView): """View for the login page.""" view_name = "login" view_url = "/" def post(self): """Given a POST request to the login page, authenticate the user and redirect""" if request.form.get("password") == current_app.config["ADMIN_PASSWORD"]: remember = request.form.get("remember-me") == "on" flask_login.login_user(ADMIN_USER, remember=remember) return redirect(url_for("latest-forecast")) # NOTE: Some kind of attribute error is preventing me from simply using # self.get(error=error). It's not occuring in other pages. safe_flash("Incorrect username and/or password.") return redirect(url_for("login")) def get(self): """Render the login page""" if flask_login.current_user.is_authenticated: return redirect(url_for("latest-forecast")) return render_template("login.html") class LogoutView(MethodView): """View for the logout page""" view_name = "logout" def get(self): """Logout the user and redirect to the login page""" flask_login.logout_user() return redirect("/") class RenderTemplateView(View): """Simple view to render any static page, like `instructions.html`.""" decorators = [flask_login.login_required] def __init__(self, template_name): self.template_name = template_name def dispatch_request(self): return render_template(self.template_name) @classmethod def view(cls, name, template=None): if not template: template = name + ".html" return cls.as_view(name, template_name=template) class ForecastModelListView(MethodView): """View for the list of all forecast models and generating new ones.""" decorators = [flask_login.login_required] view_name = "forecast-model-list" view_url = "/forecast-models" def post(self): """Generate a new forecast model.""" new_model = ForecastModel() new_model.save() print(f"Starting model {new_model.creation_date}") # NOTE: For testing, send 'mock' as a parameter to avoid lengthy training # TODO: This is a hacky way to do this. if request.values.get("mock") == "true": process = Process(target=time.sleep, args=(3,)) else: process = Process( target=new_model.launch_model, args=(current_app.config["NAME"],) ) process.start() new_model.store_process_id(process.pid) safe_flash("Model has begun training.", "info") return redirect(url_for("forecast-model-list")) def get(self): """Render the list of all forecast models and show the state of all data views""" models = ForecastModel.query.order_by(desc(ForecastModel.creation_date)).all() model_is_prepared = ForecastModel.is_prepared() data_is_prepared = { "Historical load data": HistoricalLoadData.is_prepared(), "Historical weather data": HistoricalWeatherData.is_prepared(), "Forecast weather data": ForecastWeatherData.is_prepared(), } return render_template( "forecast-model-list.html", models=models, model_is_prepared=model_is_prepared, data_is_prepared=data_is_prepared, ) class DownloadModelFiles(MethodView): """View for downloading the model files""" view_url = "/forecast-models/<slug>/output/<path:filename>" view_name = "download-model-files" decorators = [flask_login.login_required] def get(self, slug, filename): """Expose the model's output directory to the user and return a 404 if that file doesn't exist""" model = ForecastModel.query.filter_by(slug=slug).first() rel_path = os.path.join(model.output_dir, filename) if model and os.path.exists(rel_path): # NOTE: The absolute path is necessary to make file downloadable abs_dir_path = os.path.abspath(model.output_dir) return send_from_directory(abs_dir_path, filename, as_attachment=True) else: return render_template("404.html", title="404"), 404 class ForecastModelDetailView(MethodView): """View for the details of a single forecast model""" view_name = "forecast-model-detail" view_url = "/forecast-models/<slug>" decorators = [flask_login.login_required] def post(self, slug): """Cancel a specific model""" model = ForecastModel.query.filter_by(slug=slug).first() if model.is_running: model.cancel() safe_flash(f"Model {model.slug} was cancelled.", "info") return redirect(url_for("forecast-model-list")) def get_training_chart(self, df): if df is None or ("forecasted_load" not in df.columns): return None df = df.sort_values("timestamp") load_data = [[row.timestamp, row.load] for row in df.itertuples()] forecast_data = [ [row.timestamp, row.forecasted_load] for row in df.itertuples() ] return [ { "data": load_data, "name": "Load", }, { "data": forecast_data, "name": "Forecast", "color": "blue", }, ] def get_forecast_chart(self, df): if df is None or ("forecasted_load" not in df.columns): return None df = df.sort_values("timestamp") # Get end of load data lvi = df["load"].last_valid_index() CONTEXT = 72 context_data = [ [row.timestamp, row.load] for row in df.iloc[lvi - CONTEXT : lvi].itertuples() ] forecast_data = [ [row.timestamp, row.forecasted_load] # lvi - 1 because it's nicer to see the chart connected to historical data for row in df.iloc[lvi - 1 :].itertuples() ] return [ { "data": context_data, "name": "Load", }, { "data": forecast_data, "name": "Forecast", "color": "blue", }, ] def get_highest_monthly_peak(self, df, model): """Get the peak load for the month""" if model is None: return None if df is None or ("forecasted_load" not in df.columns): return None # Return none if the start date is the first of the month # (otherwise we'd have to handle truthy NaNs in the logic below) if model.start_date.day == 1: return None month_id = f"{model.start_date.year}-{model.start_date.month}" df = df.set_index("dates", drop=False) return df.loc[month_id].load.max() def get(self, slug): forecast_model = ForecastModel.query.filter_by(slug=slug).first() # NOTE: Easier to just munge one dataframe for all queries # more efficient to request dataframe once if forecast_model: df = forecast_model.get_df() df["timestamp"] = df.dates.apply(lambda x: x.timestamp() * 1000) else: df = None return render_template( "forecast-model-detail.html", name="forecast", forecast_chart=self.get_forecast_chart(df), training_chart=self.get_training_chart(df), peak_info=burtcoppd.get_on_and_off_peak_info(df, forecast_model), forecast_model=forecast_model, ) class DataSync(MethodView): """Abstract view to handle data syncing with external APIs.""" view_name = None view_url = None endpoint_class = None parent_view = None def post(self): """Sync data with the given external API.""" request = self.build_request() request.send_request() df = request.create_df() self.parent_view.model.load_df(df) return redirect(url_for(self.parent_view.view_name)) def get(self): """Redirect any GET requests to the parent view.""" return redirect(url_for(self.parent_view.view_name)) class HistoricalWeatherDataSync(DataSync): """View to sync historical weather data with the ASOS API.""" view_name = "historical-weather-data-sync" view_url = "/historical-weather-data/sync" endpoint_class = AsosRequest parent_view = HistoricalWeatherDataView def build_request(self): """Given the state of the database, prepare (but don't send) an appropriate request for ASOS.""" if HistoricalWeatherData.query.count() > 0: # Get the latest sync timestamp as the start date start_date = ( HistoricalWeatherData.query.order_by( HistoricalWeatherData.timestamp.desc() ) .first() .timestamp.date() ) else: # If the database is empty, use the start date provided by the config start_date = current_app.config["EARLIEST_SYNC_DATE"] return AsosRequest( start_date=start_date, end_date=date.today() + timedelta(days=1), tz=current_app.config["TIMEZONE"], station=current_app.config["ASOS_STATION"], ) class ForecastWeatherDataSync(DataSync): """View to sync forecast weather data with the NWS API.""" view_name = "forecast-weather-data-sync" view_url = "/forecast-weather-data/sync" endpoint_class = NwsForecastRequest parent_view = ForecastWeatherDataView def build_request(self): """Given app config, prepare (but don't send) an appropriate request for NWS.""" return NwsForecastRequest(nws_code=current_app.config["NWS_CODE"])
View Source
class DataView(MethodView): """Abstract class for handling the various views for uploading and displaying historical and forecast data""" decorators = [flask_login.login_required] model = None view_name = None title = None gist_example = None instructions = None # Variable for whether user can sync data with external API sync_request = None def get_missing_values_summary(self): """Collect basic information about missing values in the given model""" df = self.model.to_df() col = self.model.column_name df = df.set_index("dates") cumulative_df = ( df[col] .isnull() .astype(int) .groupby(df[col].notnull().astype(int).cumsum()) .cumsum() ) max_span = cumulative_df.max() end_datetime = cumulative_df.idxmax() # NOTE: timedelta can't take numpy.int64 start_datetime = end_datetime - datetime.timedelta(hours=int(max_span - 1)) return { "count": df[col].isna().sum(), "max_span": max_span, "start_datetime": start_datetime, "end_datetime": end_datetime, } def get_summary(self): """Return dictionary for the data views "Data Summary" section.""" if self.model.query.count() == 0: return None return { "count": self.model.query.count(), "start_datetime": self.model.query.order_by(self.model.timestamp) .first() .timestamp, "end_datetime": self.model.query.order_by(self.model.timestamp.desc()) .first() .timestamp, "missing_values": self.get_missing_values_summary(), } def get_table(self): """Put data into a format that can be rendered by jinja as a table""" query = db.session.query( self.model.timestamp, self.model.value, ) query = query.order_by(desc(self.model.timestamp)) return [{"timestamp": timestamp, "value": value} for timestamp, value in query] def get_chart(self): """Put data into a format that can be rendered by highstock as a chart""" query = db.session.query(self.model.milliseconds, self.model.value).order_by( self.model.milliseconds ) data = [list(row) for row in query] return [{"data": data, "name": self.model.column_name}] def post(self): """Given a POST request to the data view's endpoint, upload the file and load it into the database""" filepath = upload_file("file") self.model.load_data(filepath) return redirect(url_for(self.view_name)) def get(self): """Render the data view""" # NOTE: Just pass self? return render_template( f"data-view.html", **{ "name": self.view_name, "table": self.get_table(), "chart": self.get_chart(), "title": self.title, "gist_example": self.gist_example, "instructions": self.instructions, "sync_request": self.sync_request, "summary": self.get_summary(), }, )
Abstract class for handling the various views for uploading and displaying historical and forecast data
View Source
def get_missing_values_summary(self): """Collect basic information about missing values in the given model""" df = self.model.to_df() col = self.model.column_name df = df.set_index("dates") cumulative_df = ( df[col] .isnull() .astype(int) .groupby(df[col].notnull().astype(int).cumsum()) .cumsum() ) max_span = cumulative_df.max() end_datetime = cumulative_df.idxmax() # NOTE: timedelta can't take numpy.int64 start_datetime = end_datetime - datetime.timedelta(hours=int(max_span - 1)) return { "count": df[col].isna().sum(), "max_span": max_span, "start_datetime": start_datetime, "end_datetime": end_datetime, }
Collect basic information about missing values in the given model
View Source
def get_summary(self): """Return dictionary for the data views "Data Summary" section.""" if self.model.query.count() == 0: return None return { "count": self.model.query.count(), "start_datetime": self.model.query.order_by(self.model.timestamp) .first() .timestamp, "end_datetime": self.model.query.order_by(self.model.timestamp.desc()) .first() .timestamp, "missing_values": self.get_missing_values_summary(), }
Return dictionary for the data views "Data Summary" section.
View Source
def get_table(self): """Put data into a format that can be rendered by jinja as a table""" query = db.session.query( self.model.timestamp, self.model.value, ) query = query.order_by(desc(self.model.timestamp)) return [{"timestamp": timestamp, "value": value} for timestamp, value in query]
Put data into a format that can be rendered by jinja as a table
View Source
def get_chart(self): """Put data into a format that can be rendered by highstock as a chart""" query = db.session.query(self.model.milliseconds, self.model.value).order_by( self.model.milliseconds ) data = [list(row) for row in query] return [{"data": data, "name": self.model.column_name}]
Put data into a format that can be rendered by highstock as a chart
View Source
def post(self): """Given a POST request to the data view's endpoint, upload the file and load it into the database""" filepath = upload_file("file") self.model.load_data(filepath) return redirect(url_for(self.view_name))
Given a POST request to the data view's endpoint, upload the file and load it into the database
View Source
def get(self): """Render the data view""" # NOTE: Just pass self? return render_template( f"data-view.html", **{ "name": self.view_name, "table": self.get_table(), "chart": self.get_chart(), "title": self.title, "gist_example": self.gist_example, "instructions": self.instructions, "sync_request": self.sync_request, "summary": self.get_summary(), }, )
Render the data view
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class ForecastWeatherDataView(DataView): """View for the forecast weather data""" model = ForecastWeatherData view_name = "forecast-weather-data" title = "Forecast Weather Data" gist_example = "https://gist.github.com/kmcelwee/e56308a8096356fcdc699ca168904aa4" instructions = "/instructions#forecast-weather-data" sync_request = "The National Weather Service"
View for the forecast weather data
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class ForecastWeatherData(TrainingData, db.Model): """Table of forecasted weather data.""" __tablename__ = "forecast_weather_data" friendly_name = "Forecast Temperature" column_name = "tempc" minimum_data_required = 24
Table of forecasted weather data.
Inherited Members
- forecast_app.models.ForecastWeatherData
- ForecastWeatherData
- friendly_name
- column_name
- minimum_data_required
- timestamp
- milliseconds
- value
- sqlalchemy.orm.decl_api.Model
- query_class
- query
- registry
- metadata
View Source
class HistoricalLoadDataView(DataView): """View for the historical load data""" model = HistoricalLoadData view_name = "historical-load-data" title = "Historical Load Data" gist_example = "https://gist.github.com/kmcelwee/ce163d8c9d2871ab4c652382431c7801" instructions = "/instructions#historical-load-data"
View for the historical load data
Inherited Members
- DataView
- decorators
- sync_request
- get_missing_values_summary
- get_summary
- get_table
- get_chart
- post
- get
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class HistoricalLoadData(TrainingData, db.Model): """Table of historical load data.""" __tablename__ = "historical_load_data" friendly_name = "Historical Load" column_name = "load" minimum_data_required = 24 * 365 * 3
Table of historical load data.
Inherited Members
- forecast_app.models.HistoricalLoadData
- HistoricalLoadData
- friendly_name
- column_name
- minimum_data_required
- timestamp
- milliseconds
- value
- sqlalchemy.orm.decl_api.Model
- query_class
- query
- registry
- metadata
View Source
class HistoricalWeatherDataView(DataView): """View for the historical weather data""" model = HistoricalWeatherData view_name = "historical-weather-data" title = "Historical Weather Data" gist_example = "https://gist.github.com/kmcelwee/e56308a8096356fcdc699ca168904aa4" instructions = "/instructions#historical-weather-data" sync_request = "ASOS"
View for the historical weather data
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class HistoricalWeatherData(TrainingData, db.Model): """Table of historical weather data.""" __tablename__ = "historical_weather_data" friendly_name = "Historical Temperature" column_name = "tempc" minimum_data_required = 24 * 365 * 3
Table of historical weather data.
Inherited Members
- forecast_app.models.HistoricalWeatherData
- HistoricalWeatherData
- friendly_name
- column_name
- minimum_data_required
- timestamp
- milliseconds
- value
- sqlalchemy.orm.decl_api.Model
- query_class
- query
- registry
- metadata
View Source
class LatestForecastView(MethodView): """Masked redirect to the latest successful forecast model""" view_name = "latest-forecast" decorators = [flask_login.login_required] def get_latest_successful_model(self): """Return the latest successful forecast model to show to user in this view""" # NOTE: Need to do manually because of exited_successfully being a property query = ( db.session.query(ForecastModel) .order_by(desc(ForecastModel.creation_date)) .all() ) for model in query: if model.exited_successfully: latest_successful_forecast = model break else: latest_successful_forecast = None return latest_successful_forecast def get(self): """Redirect to the latest successful forecast model if one exists, otherwise show a page with message.""" model = self.get_latest_successful_model() if model: return ForecastModelDetailView().get(slug=model.slug) else: return render_template("latest-forecast.html")
Masked redirect to the latest successful forecast model
View Source
def get_latest_successful_model(self): """Return the latest successful forecast model to show to user in this view""" # NOTE: Need to do manually because of exited_successfully being a property query = ( db.session.query(ForecastModel) .order_by(desc(ForecastModel.creation_date)) .all() ) for model in query: if model.exited_successfully: latest_successful_forecast = model break else: latest_successful_forecast = None return latest_successful_forecast
Return the latest successful forecast model to show to user in this view
View Source
def get(self): """Redirect to the latest successful forecast model if one exists, otherwise show a page with message.""" model = self.get_latest_successful_model() if model: return ForecastModelDetailView().get(slug=model.slug) else: return render_template("latest-forecast.html")
Redirect to the latest successful forecast model if one exists, otherwise show a page with message.
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class LoginView(MethodView): """View for the login page.""" view_name = "login" view_url = "/" def post(self): """Given a POST request to the login page, authenticate the user and redirect""" if request.form.get("password") == current_app.config["ADMIN_PASSWORD"]: remember = request.form.get("remember-me") == "on" flask_login.login_user(ADMIN_USER, remember=remember) return redirect(url_for("latest-forecast")) # NOTE: Some kind of attribute error is preventing me from simply using # self.get(error=error). It's not occuring in other pages. safe_flash("Incorrect username and/or password.") return redirect(url_for("login")) def get(self): """Render the login page""" if flask_login.current_user.is_authenticated: return redirect(url_for("latest-forecast")) return render_template("login.html")
View for the login page.
View Source
def post(self): """Given a POST request to the login page, authenticate the user and redirect""" if request.form.get("password") == current_app.config["ADMIN_PASSWORD"]: remember = request.form.get("remember-me") == "on" flask_login.login_user(ADMIN_USER, remember=remember) return redirect(url_for("latest-forecast")) # NOTE: Some kind of attribute error is preventing me from simply using # self.get(error=error). It's not occuring in other pages. safe_flash("Incorrect username and/or password.") return redirect(url_for("login"))
Given a POST request to the login page, authenticate the user and redirect
View Source
def get(self): """Render the login page""" if flask_login.current_user.is_authenticated: return redirect(url_for("latest-forecast")) return render_template("login.html")
Render the login page
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- decorators
- as_view
View Source
class LogoutView(MethodView): """View for the logout page""" view_name = "logout" def get(self): """Logout the user and redirect to the login page""" flask_login.logout_user() return redirect("/")
View for the logout page
View Source
def get(self): """Logout the user and redirect to the login page""" flask_login.logout_user() return redirect("/")
Logout the user and redirect to the login page
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- decorators
- as_view
View Source
class RenderTemplateView(View): """Simple view to render any static page, like `instructions.html`.""" decorators = [flask_login.login_required] def __init__(self, template_name): self.template_name = template_name def dispatch_request(self): return render_template(self.template_name) @classmethod def view(cls, name, template=None): if not template: template = name + ".html" return cls.as_view(name, template_name=template)
Simple view to render any static page, like instructions.html
.
View Source
def __init__(self, template_name): self.template_name = template_name
View Source
def dispatch_request(self): return render_template(self.template_name)
Subclasses have to override this method to implement the actual view function code. This method is called with all the arguments from the URL rule.
View Source
@classmethod def view(cls, name, template=None): if not template: template = name + ".html" return cls.as_view(name, template_name=template)
Inherited Members
- flask.views.View
- methods
- provide_automatic_options
- as_view
View Source
class ForecastModelListView(MethodView): """View for the list of all forecast models and generating new ones.""" decorators = [flask_login.login_required] view_name = "forecast-model-list" view_url = "/forecast-models" def post(self): """Generate a new forecast model.""" new_model = ForecastModel() new_model.save() print(f"Starting model {new_model.creation_date}") # NOTE: For testing, send 'mock' as a parameter to avoid lengthy training # TODO: This is a hacky way to do this. if request.values.get("mock") == "true": process = Process(target=time.sleep, args=(3,)) else: process = Process( target=new_model.launch_model, args=(current_app.config["NAME"],) ) process.start() new_model.store_process_id(process.pid) safe_flash("Model has begun training.", "info") return redirect(url_for("forecast-model-list")) def get(self): """Render the list of all forecast models and show the state of all data views""" models = ForecastModel.query.order_by(desc(ForecastModel.creation_date)).all() model_is_prepared = ForecastModel.is_prepared() data_is_prepared = { "Historical load data": HistoricalLoadData.is_prepared(), "Historical weather data": HistoricalWeatherData.is_prepared(), "Forecast weather data": ForecastWeatherData.is_prepared(), } return render_template( "forecast-model-list.html", models=models, model_is_prepared=model_is_prepared, data_is_prepared=data_is_prepared, )
View for the list of all forecast models and generating new ones.
View Source
def post(self): """Generate a new forecast model.""" new_model = ForecastModel() new_model.save() print(f"Starting model {new_model.creation_date}") # NOTE: For testing, send 'mock' as a parameter to avoid lengthy training # TODO: This is a hacky way to do this. if request.values.get("mock") == "true": process = Process(target=time.sleep, args=(3,)) else: process = Process( target=new_model.launch_model, args=(current_app.config["NAME"],) ) process.start() new_model.store_process_id(process.pid) safe_flash("Model has begun training.", "info") return redirect(url_for("forecast-model-list"))
Generate a new forecast model.
View Source
def get(self): """Render the list of all forecast models and show the state of all data views""" models = ForecastModel.query.order_by(desc(ForecastModel.creation_date)).all() model_is_prepared = ForecastModel.is_prepared() data_is_prepared = { "Historical load data": HistoricalLoadData.is_prepared(), "Historical weather data": HistoricalWeatherData.is_prepared(), "Forecast weather data": ForecastWeatherData.is_prepared(), } return render_template( "forecast-model-list.html", models=models, model_is_prepared=model_is_prepared, data_is_prepared=data_is_prepared, )
Render the list of all forecast models and show the state of all data views
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class DownloadModelFiles(MethodView): """View for downloading the model files""" view_url = "/forecast-models/<slug>/output/<path:filename>" view_name = "download-model-files" decorators = [flask_login.login_required] def get(self, slug, filename): """Expose the model's output directory to the user and return a 404 if that file doesn't exist""" model = ForecastModel.query.filter_by(slug=slug).first() rel_path = os.path.join(model.output_dir, filename) if model and os.path.exists(rel_path): # NOTE: The absolute path is necessary to make file downloadable abs_dir_path = os.path.abspath(model.output_dir) return send_from_directory(abs_dir_path, filename, as_attachment=True) else: return render_template("404.html", title="404"), 404
View for downloading the model files
View Source
def get(self, slug, filename): """Expose the model's output directory to the user and return a 404 if that file doesn't exist""" model = ForecastModel.query.filter_by(slug=slug).first() rel_path = os.path.join(model.output_dir, filename) if model and os.path.exists(rel_path): # NOTE: The absolute path is necessary to make file downloadable abs_dir_path = os.path.abspath(model.output_dir) return send_from_directory(abs_dir_path, filename, as_attachment=True) else: return render_template("404.html", title="404"), 404
Expose the model's output directory to the user and return a 404 if that file doesn't exist
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class ForecastModelDetailView(MethodView): """View for the details of a single forecast model""" view_name = "forecast-model-detail" view_url = "/forecast-models/<slug>" decorators = [flask_login.login_required] def post(self, slug): """Cancel a specific model""" model = ForecastModel.query.filter_by(slug=slug).first() if model.is_running: model.cancel() safe_flash(f"Model {model.slug} was cancelled.", "info") return redirect(url_for("forecast-model-list")) def get_training_chart(self, df): if df is None or ("forecasted_load" not in df.columns): return None df = df.sort_values("timestamp") load_data = [[row.timestamp, row.load] for row in df.itertuples()] forecast_data = [ [row.timestamp, row.forecasted_load] for row in df.itertuples() ] return [ { "data": load_data, "name": "Load", }, { "data": forecast_data, "name": "Forecast", "color": "blue", }, ] def get_forecast_chart(self, df): if df is None or ("forecasted_load" not in df.columns): return None df = df.sort_values("timestamp") # Get end of load data lvi = df["load"].last_valid_index() CONTEXT = 72 context_data = [ [row.timestamp, row.load] for row in df.iloc[lvi - CONTEXT : lvi].itertuples() ] forecast_data = [ [row.timestamp, row.forecasted_load] # lvi - 1 because it's nicer to see the chart connected to historical data for row in df.iloc[lvi - 1 :].itertuples() ] return [ { "data": context_data, "name": "Load", }, { "data": forecast_data, "name": "Forecast", "color": "blue", }, ] def get_highest_monthly_peak(self, df, model): """Get the peak load for the month""" if model is None: return None if df is None or ("forecasted_load" not in df.columns): return None # Return none if the start date is the first of the month # (otherwise we'd have to handle truthy NaNs in the logic below) if model.start_date.day == 1: return None month_id = f"{model.start_date.year}-{model.start_date.month}" df = df.set_index("dates", drop=False) return df.loc[month_id].load.max() def get(self, slug): forecast_model = ForecastModel.query.filter_by(slug=slug).first() # NOTE: Easier to just munge one dataframe for all queries # more efficient to request dataframe once if forecast_model: df = forecast_model.get_df() df["timestamp"] = df.dates.apply(lambda x: x.timestamp() * 1000) else: df = None return render_template( "forecast-model-detail.html", name="forecast", forecast_chart=self.get_forecast_chart(df), training_chart=self.get_training_chart(df), peak_info=burtcoppd.get_on_and_off_peak_info(df, forecast_model), forecast_model=forecast_model, )
View for the details of a single forecast model
View Source
def post(self, slug): """Cancel a specific model""" model = ForecastModel.query.filter_by(slug=slug).first() if model.is_running: model.cancel() safe_flash(f"Model {model.slug} was cancelled.", "info") return redirect(url_for("forecast-model-list"))
Cancel a specific model
View Source
def get_training_chart(self, df): if df is None or ("forecasted_load" not in df.columns): return None df = df.sort_values("timestamp") load_data = [[row.timestamp, row.load] for row in df.itertuples()] forecast_data = [ [row.timestamp, row.forecasted_load] for row in df.itertuples() ] return [ { "data": load_data, "name": "Load", }, { "data": forecast_data, "name": "Forecast", "color": "blue", }, ]
View Source
def get_forecast_chart(self, df): if df is None or ("forecasted_load" not in df.columns): return None df = df.sort_values("timestamp") # Get end of load data lvi = df["load"].last_valid_index() CONTEXT = 72 context_data = [ [row.timestamp, row.load] for row in df.iloc[lvi - CONTEXT : lvi].itertuples() ] forecast_data = [ [row.timestamp, row.forecasted_load] # lvi - 1 because it's nicer to see the chart connected to historical data for row in df.iloc[lvi - 1 :].itertuples() ] return [ { "data": context_data, "name": "Load", }, { "data": forecast_data, "name": "Forecast", "color": "blue", }, ]
View Source
def get_highest_monthly_peak(self, df, model): """Get the peak load for the month""" if model is None: return None if df is None or ("forecasted_load" not in df.columns): return None # Return none if the start date is the first of the month # (otherwise we'd have to handle truthy NaNs in the logic below) if model.start_date.day == 1: return None month_id = f"{model.start_date.year}-{model.start_date.month}" df = df.set_index("dates", drop=False) return df.loc[month_id].load.max()
Get the peak load for the month
View Source
def get(self, slug): forecast_model = ForecastModel.query.filter_by(slug=slug).first() # NOTE: Easier to just munge one dataframe for all queries # more efficient to request dataframe once if forecast_model: df = forecast_model.get_df() df["timestamp"] = df.dates.apply(lambda x: x.timestamp() * 1000) else: df = None return render_template( "forecast-model-detail.html", name="forecast", forecast_chart=self.get_forecast_chart(df), training_chart=self.get_training_chart(df), peak_info=burtcoppd.get_on_and_off_peak_info(df, forecast_model), forecast_model=forecast_model, )
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class DataSync(MethodView): """Abstract view to handle data syncing with external APIs.""" view_name = None view_url = None endpoint_class = None parent_view = None def post(self): """Sync data with the given external API.""" request = self.build_request() request.send_request() df = request.create_df() self.parent_view.model.load_df(df) return redirect(url_for(self.parent_view.view_name)) def get(self): """Redirect any GET requests to the parent view.""" return redirect(url_for(self.parent_view.view_name))
Abstract view to handle data syncing with external APIs.
View Source
def post(self): """Sync data with the given external API.""" request = self.build_request() request.send_request() df = request.create_df() self.parent_view.model.load_df(df) return redirect(url_for(self.parent_view.view_name))
Sync data with the given external API.
View Source
def get(self): """Redirect any GET requests to the parent view.""" return redirect(url_for(self.parent_view.view_name))
Redirect any GET requests to the parent view.
Inherited Members
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- decorators
- as_view
View Source
class HistoricalWeatherDataSync(DataSync): """View to sync historical weather data with the ASOS API.""" view_name = "historical-weather-data-sync" view_url = "/historical-weather-data/sync" endpoint_class = AsosRequest parent_view = HistoricalWeatherDataView def build_request(self): """Given the state of the database, prepare (but don't send) an appropriate request for ASOS.""" if HistoricalWeatherData.query.count() > 0: # Get the latest sync timestamp as the start date start_date = ( HistoricalWeatherData.query.order_by( HistoricalWeatherData.timestamp.desc() ) .first() .timestamp.date() ) else: # If the database is empty, use the start date provided by the config start_date = current_app.config["EARLIEST_SYNC_DATE"] return AsosRequest( start_date=start_date, end_date=date.today() + timedelta(days=1), tz=current_app.config["TIMEZONE"], station=current_app.config["ASOS_STATION"], )
View to sync historical weather data with the ASOS API.
View Source
def build_request(self): """Given the state of the database, prepare (but don't send) an appropriate request for ASOS.""" if HistoricalWeatherData.query.count() > 0: # Get the latest sync timestamp as the start date start_date = ( HistoricalWeatherData.query.order_by( HistoricalWeatherData.timestamp.desc() ) .first() .timestamp.date() ) else: # If the database is empty, use the start date provided by the config start_date = current_app.config["EARLIEST_SYNC_DATE"] return AsosRequest( start_date=start_date, end_date=date.today() + timedelta(days=1), tz=current_app.config["TIMEZONE"], station=current_app.config["ASOS_STATION"], )
Given the state of the database, prepare (but don't send) an appropriate request for ASOS.
View Source
class AsosRequest: """Pulls hourly data for a specified year and ASOS station. Drawn heavily from https://github.com/dpinney/omf * ASOS is the Automated Surface Observing System, a network of about 900 weater stations, they collect data at hourly intervals, they're run by NWS, FAA, and DOD, and there is data going back to 1901 in some sites. * AKA METAR data, which is the name of the format its stored in. * For ASOS station code see https://www.faa.gov/air_traffic/weather/asos/ * For datatypes see bottom of https://mesonet.agron.iastate.edu/request/download.phtml * Note for USA stations (beginning with a K) you must NOT include the 'K' * ASOS User's Guide: https://www.weather.gov/media/asos/aum-toc.pdf """ base_url = "https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py" def __init__( self, start_date=None, end_date=None, station=None, tz=None, missing_value="M" ): self.start_date = start_date self.end_date = end_date self.station = station self.tz = tz self.missing_value = missing_value self.params = { "station": station, "data": "tmpc", "year1": start_date.year, "month1": start_date.month, "day1": start_date.day, "year2": end_date.year, "month2": end_date.month, "day2": end_date.day, "tz": tz, "format": "onlycomma", "missing": missing_value, "trace": "T", # TODO: What does this mean? "latlon": "no", "elev": "no", "direct": "no", "report_type": 1, "report_type": 2, # TODO: Why? } def send_request(self): """Send the request to the ASOS server and return the response""" self.request = requests.get(self.base_url, params=self.params) if self.request.status_code == 404: raise Exception(f"Dataset URL does not exist. {self.request.url}") return self.request def write_response(self, filepath): """Write the response to a file""" self.filepath = filepath if not hasattr(self, "request"): raise Exception("No request has been sent yet.") if not self.request.text: raise Exception(f"No data found. {self.request.url}") with open(filepath, "w") as f: f.write(self.request.text) def create_df(self): """Create a dataframe from the response in the format we need for our models""" if not hasattr(self, "request"): raise Exception("No request has been sent yet.") df = pd.read_csv(StringIO(self.request.text), parse_dates=["valid"]) df = df[df["tmpc"] != self.missing_value] df["tempc"] = df["tmpc"].astype(float) # rename column and cast df["timestamp"] = df.valid.dt.round("h") # Round to nearest hour df = df[["timestamp", "tempc"]] series = df.groupby("timestamp")["tempc"].mean() # Cast as a dataframe and ensure a continuous index df_n = pd.DataFrame(series) df_n = df_n.resample("h").last() df_n["timestamp"] = df_n.index return df_n
Pulls hourly data for a specified year and ASOS station.
Drawn heavily from https://github.com/dpinney/omf
- ASOS is the Automated Surface Observing System, a network of about 900 weater stations, they collect data at hourly intervals, they're run by NWS, FAA, and DOD, and there is data going back to 1901 in some sites.
- AKA METAR data, which is the name of the format its stored in.
- For ASOS station code see https://www.faa.gov/air_traffic/weather/asos/
- For datatypes see bottom of https://mesonet.agron.iastate.edu/request/download.phtml
- Note for USA stations (beginning with a K) you must NOT include the 'K'
- ASOS User's Guide: https://www.weather.gov/media/asos/aum-toc.pdf
Inherited Members
View Source
class HistoricalWeatherDataView(DataView): """View for the historical weather data""" model = HistoricalWeatherData view_name = "historical-weather-data" title = "Historical Weather Data" gist_example = "https://gist.github.com/kmcelwee/e56308a8096356fcdc699ca168904aa4" instructions = "/instructions#historical-weather-data" sync_request = "ASOS"
View for the historical weather data
Inherited Members
- HistoricalWeatherDataView
- HistoricalWeatherDataView
- model
- view_name
- title
- gist_example
- instructions
- sync_request
- methods
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view
View Source
class ForecastWeatherDataSync(DataSync): """View to sync forecast weather data with the NWS API.""" view_name = "forecast-weather-data-sync" view_url = "/forecast-weather-data/sync" endpoint_class = NwsForecastRequest parent_view = ForecastWeatherDataView def build_request(self): """Given app config, prepare (but don't send) an appropriate request for NWS.""" return NwsForecastRequest(nws_code=current_app.config["NWS_CODE"])
View to sync forecast weather data with the NWS API.
View Source
def build_request(self): """Given app config, prepare (but don't send) an appropriate request for NWS.""" return NwsForecastRequest(nws_code=current_app.config["NWS_CODE"])
Given app config, prepare (but don't send) an appropriate request for NWS.
View Source
class NwsForecastRequest: # TODO: Can this be an abstract class? """Pulls hourly data from the National Weather Service Docs: https://weather-gov.github.io/api/ Example request: https://api.weather.gov/gridpoints/LWX/96,70/forecast/hourly * Timezone data is encoded in the response as UTC with offset. We strip it. * The temperature is in Fahrenheit, we convert to Celsius. * 6.5 days of forecast data is provided. """ base_url = "https://api.weather.gov/gridpoints/" def __init__(self, nws_code=""): self.nws_code = nws_code @classmethod def fahrenheit_to_celcius(cls, fahrenheit): """Class method to convert Fahrenheit to Celsius""" # TODO: We should be using fahrenheit instead of celcius, but it's baked into # a lot of the structure. May not be worth switching back for a while. return round((fahrenheit - 32) * 5 / 9, 2) def send_request(self): """Send the request to the NWS server and return the response""" self.request = requests.get(self.base_url + self.nws_code + "/forecast/hourly") if self.request.status_code == 404: raise Exception(f"Dataset URL does not exist. {self.request.url}") return self.request def write_response(self, filepath): """Write the response to a file""" self.filepath = filepath if not hasattr(self, "request"): raise Exception("No request has been sent yet.") if not self.request.text: raise Exception(f"No data found. {self.request.url}") with open(filepath, "w") as f: f.write(self.request.text) def create_df(self): """Create a dataframe from the response in the format we need for our models""" if not hasattr(self, "request"): raise Exception("No request has been sent yet.") json_response = json.loads(self.request.text) dict_list = [] for item in json_response["properties"]["periods"]: item = { # Removing tz info from timestamp. This makes the strong assumption # that NWS will always correctly provide data in the timezone of # the station we're pulling from. "timestamp": pd.to_datetime(item["startTime"]).replace(tzinfo=None), "tempc": self.fahrenheit_to_celcius(item["temperature"]), } dict_list.append(item) df = pd.DataFrame(dict_list) return df
Pulls hourly data from the National Weather Service
Docs: https://weather-gov.github.io/api/ Example request: https://api.weather.gov/gridpoints/LWX/96,70/forecast/hourly
- Timezone data is encoded in the response as UTC with offset. We strip it.
- The temperature is in Fahrenheit, we convert to Celsius.
- 6.5 days of forecast data is provided.
View Source
class ForecastWeatherDataView(DataView): """View for the forecast weather data""" model = ForecastWeatherData view_name = "forecast-weather-data" title = "Forecast Weather Data" gist_example = "https://gist.github.com/kmcelwee/e56308a8096356fcdc699ca168904aa4" instructions = "/instructions#forecast-weather-data" sync_request = "The National Weather Service"
View for the forecast weather data
Inherited Members
- ForecastWeatherDataView
- ForecastWeatherDataView
- model
- view_name
- title
- gist_example
- instructions
- sync_request
- methods
- flask.views.MethodView
- dispatch_request
- flask.views.View
- provide_automatic_options
- as_view