def make_range(index: pd.DatetimeIndex, other_index: pd.DatetimeIndex = None) -> Union[None, Range1d]: """Make a 1D range of values from a datetime index or two. Useful to share axis among Bokeh Figures.""" index = tz_index_naively(index) other_index = tz_index_naively(other_index) a_range = None # if there is some actual data, use that to set the range if not index.empty: a_range = Range1d(start=min(index), end=max(index)) # if there is other data, include it if not index.empty and other_index is not None and not other_index.empty: a_range = Range1d(start=min(index.append(other_index)), end=max(index.append(other_index))) if a_range is None: current_app.logger.warning("Not sufficient data to create a range.") return a_range
def create_graph( # noqa: C901 data: pd.DataFrame, unit: str = "Some unit", title: str = "A plot", x_label: str = "X", y_label: str = "Y", legend_location: Union[str, Tuple[float, float]] = "top_right", legend_labels: Tuple[str, Optional[str], Optional[str]] = ( "Actual", "Forecast", "Schedules", ), x_range: Optional[Range1d] = None, forecasts: Optional[pd.DataFrame] = None, schedules: Optional[pd.DataFrame] = None, show_y_floats: bool = False, non_negative_only: bool = False, tools: Optional[List[str]] = None, sizing_mode: str = "scale_width", ) -> Figure: """ Create a Bokeh graph. As of now, assumes x data is datetimes and y data is numeric. The former is not set in stone. :param data: the actual data. Expects column name "event_value" and optional "belief_horizon" and "source" columns. :param unit: the (physical) unit of the data :param title: Title of the graph :param x_label: x axis label :param y_label: y axis label :param legend_location: location of the legend :param legend_labels: labels for the legend items :param x_range: values for x axis. If None, taken from series index. :param forecasts: forecasts of the data. Expects column names "event_value", "yhat_upper" and "yhat_lower". :param schedules: scheduled data. Expects column name "event_value". :param hover_tool: Bokeh hover tool, if required :param show_y_floats: if True, y axis will show floating numbers (defaults False, will be True if y values are < 2) :param non_negative_only: whether or not the data can only be non-negative :param tools: some tools for the plot, which defaults to ["box_zoom", "reset", "save"]. :return: a Bokeh Figure """ # Replace "source" column with "label" column (containing strings) data = replace_source_with_label(data) forecasts = replace_source_with_label(forecasts) schedules = replace_source_with_label(schedules) resolution = decide_plot_resolution(data) # Set x range if x_range is None: x_range = make_range(data.index) if x_range is None and schedules is not None: x_range = make_range(schedules.index) if x_range is None and forecasts is not None: x_range = make_range(forecasts.index) data = tz_index_naively(data) # Set default y range in case there is no data from which to derive a range y_range = None if ( data["event_value"].isnull().all() and (forecasts is None or forecasts["event_value"].isnull().all()) and (schedules is None or schedules["event_value"].isnull().all()) ): y_range = Range1d(start=0, end=1) # Set default tools if none were given if tools is None: tools = ["box_zoom", "reset", "save"] if "belief_horizon" in data.columns and "label" in data.columns: hover_tool = create_hover_tool(unit, resolution, as_beliefs=True) else: hover_tool = create_hover_tool(unit, resolution, as_beliefs=False) tools = [hover_tool] + tools fig = figure( title=title, x_range=x_range, y_range=y_range, min_border=0, toolbar_location="right", tools=tools, h_symmetry=False, v_symmetry=False, sizing_mode=sizing_mode, outline_line_color="#666666", ) if non_negative_only: fig.y_range.bounds = (0, None) fig.y_range.start = 0 if data.empty: current_app.logger.warning("No data to show for %s" % title) # Format y floats if ( not data.empty and show_y_floats is False and data["event_value"].size > 0 ): # apply a simple heuristic if forecasts is None or forecasts.empty: show_y_floats = max(data["event_value"].values) < 2 else: show_y_floats = ( max(max(data["event_value"].values), max(forecasts["event_value"])) < 2 ) palette, forecast_color, schedule_color = build_palette() legend_items: List[Tuple] = [] # Plot power data. Support special case of multiple source labels. if not data.empty: data_groups = {legend_labels[0]: data} is_multiple = "label" in data.columns and len(data["label"].unique()) > 1 if is_multiple: data_groups = { label: data.loc[data.label == label] for label in data["label"].unique() } legend_items = [] for plot_label, plot_data in data_groups.items(): ds = make_datasource_from(plot_data, resolution) if not is_multiple: ac = fig.circle( x="x", y="y", source=ds, color=palette.pop(0), alpha=0.5, size=10 ) else: ac = fig.line(x="x", y="y", source=ds, color=palette.pop(0)) legend_items.append((plot_label, [ac])) # Plot forecast data if forecasts is not None and not forecasts.empty: forecasts = tz_index_naively(forecasts) if "label" not in forecasts: forecasts["label"] = "Forecast from unknown source" labels = forecasts["label"].unique() for label in labels: # forecasts from different data sources label_forecasts = forecasts[forecasts["label"] == label] fds = make_datasource_from(label_forecasts, resolution) fc = fig.circle(x="x", y="y", source=fds, color=forecast_color, size=10) fl = fig.line(x="x", y="y", source=fds, color=forecast_color) # draw uncertainty range as a two-dimensional patch if "yhat_lower" and "yhat_upper" in label_forecasts: x_points = np.append(label_forecasts.index, label_forecasts.index[::-1]) y_points = np.append( label_forecasts.yhat_lower, label_forecasts.yhat_upper[::-1] ) fig.patch( x_points, y_points, color=forecast_color, fill_alpha=0.2, line_width=0.01, ) if legend_labels[1] is None: raise TypeError("Legend label must be of type string, not None.") if label == labels[0]: # only add 1 legend item for forecasts legend_items.append((legend_labels[1], [fc, fl])) # Plot schedule data. Support special case of multiple source labels. if ( schedules is not None and not schedules.empty and not schedules["event_value"].isnull().all() ): schedules = tz_index_naively(schedules) legend_label = "" if legend_labels[2] is None else legend_labels[2] schedule_groups = {legend_label: schedules} if "label" in schedules.columns and len(schedules["label"].unique()) > 1: schedule_groups = { label: schedules.loc[schedules.label == label] for label in schedules["label"].unique() } for plot_label, plot_data in schedule_groups.items(): sds = make_datasource_from(plot_data, resolution) sl = fig.line(x="x", y="y", source=sds, color=palette.pop(0)) if plot_label is None: raise TypeError("Legend label must be of type string, not None.") legend_items.append((plot_label, [sl])) fig.toolbar.logo = None fig.yaxis.axis_label = y_label fig.yaxis.formatter = NumeralTickFormatter(format="0,0") if show_y_floats: fig.yaxis.formatter = NumeralTickFormatter(format="0,0.00") fig.ygrid.grid_line_alpha = 0.5 fig.xaxis.axis_label = x_label fig.xgrid.grid_line_alpha = 0.5 if legend_location is not None: legend = Legend(items=legend_items, location=legend_location) fig.add_layout(legend, "center") return fig
def portfolio_view(): # noqa: C901 """Portfolio view. By default, this page shows live results (production, consumption and market data) from the user's portfolio. Time windows for which the platform has identified upcoming balancing opportunities are highlighted. The page can also be used to navigate historical results. """ set_time_range_for_session() start = session.get("start_time") end = session.get("end_time") resolution = session.get("resolution") # Get plot perspective perspectives = ["production", "consumption"] default_stack_side = "production" # todo: move to user config setting show_stacked = request.values.get("show_stacked", default_stack_side) perspectives.remove(show_stacked) show_summed: str = perspectives[0] plot_label = f"Stacked {show_stacked} vs aggregated {show_summed}" # Get structure and data assets: List[Asset] = get_assets( order_by_asset_attribute="display_name", order_direction="asc" ) represented_asset_types, markets, resource_dict = get_structure(assets) for resource_name, resource in resource_dict.items(): resource.load_sensor_data( [Power, Price], start=start, end=end, resolution=resolution, exclude_source_types=["scheduling script"], ) # The resource caches the results ( supply_resources_df_dict, demand_resources_df_dict, production_per_asset_type, consumption_per_asset_type, production_per_asset, consumption_per_asset, ) = get_power_data(resource_dict) price_bdf_dict, average_price_dict = get_price_data(resource_dict) # Pick a perspective for summing and for stacking sum_dict = ( demand_resources_df_dict.values() if show_summed == "consumption" else supply_resources_df_dict.values() ) power_sum_df = ( pd.concat(sum_dict, axis=1).sum(axis=1).to_frame(name="event_value") if sum_dict else pd.DataFrame() ) stack_dict = ( rename_event_value_column_to_resource_name(supply_resources_df_dict).values() if show_summed == "consumption" else rename_event_value_column_to_resource_name( demand_resources_df_dict ).values() ) df_stacked_data = pd.concat(stack_dict, axis=1) if stack_dict else pd.DataFrame() # Create summed plot power_sum_df = data_or_zeroes(power_sum_df, start, end, resolution) x_range = plotting.make_range( pd.date_range(start, end, freq=resolution, closed="left") ) fig_profile = plotting.create_graph( power_sum_df, unit="MW", title=plot_label, x_range=x_range, x_label="Time (resolution of %s)" % time_utils.freq_label_to_human_readable_label(resolution), y_label="Power (in MW)", legend_location="top_right", legend_labels=(capitalize(show_summed), None, None), show_y_floats=True, non_negative_only=True, ) fig_profile.plot_height = 450 fig_profile.plot_width = 900 # Create stacked plot df_stacked_data = data_or_zeroes(df_stacked_data, start, end, resolution) df_stacked_areas = stack_df(df_stacked_data) num_areas = df_stacked_areas.shape[1] if num_areas <= 2: colors = ["#99d594", "#dddd9d"] else: colors = palettes.brewer["Spectral"][num_areas] df_stacked_data = time_utils.tz_index_naively(df_stacked_data) x_points = np.hstack((df_stacked_data.index[::-1], df_stacked_data.index)) fig_profile.grid.minor_grid_line_color = "#eeeeee" for a, area in enumerate(df_stacked_areas): fig_profile.patch( x_points, df_stacked_areas[area].values, color=colors[a], alpha=0.8, line_color=None, legend=df_stacked_data.columns[a], level="underlay", ) # Flexibility numbers are mocked for now curtailment_per_asset = {a.name: 0 for a in assets} shifting_per_asset = {a.name: 0 for a in assets} profit_loss_flexibility_per_asset = {a.name: 0 for a in assets} curtailment_per_asset_type = {k: 0 for k in represented_asset_types.keys()} shifting_per_asset_type = {k: 0 for k in represented_asset_types.keys()} profit_loss_flexibility_per_asset_type = { k: 0 for k in represented_asset_types.keys() } shifting_per_asset["48_r"] = 1.1 profit_loss_flexibility_per_asset["48_r"] = 76000 shifting_per_asset_type["one-way EVSE"] = shifting_per_asset["48_r"] profit_loss_flexibility_per_asset_type[ "one-way EVSE" ] = profit_loss_flexibility_per_asset["48_r"] curtailment_per_asset["hw-onshore"] = 1.3 profit_loss_flexibility_per_asset["hw-onshore"] = 84000 curtailment_per_asset_type["wind turbines"] = curtailment_per_asset["hw-onshore"] profit_loss_flexibility_per_asset_type[ "wind turbines" ] = profit_loss_flexibility_per_asset["hw-onshore"] # Add referral to mocked control action this_hour = time_utils.get_most_recent_hour() next4am = [ dt for dt in [this_hour + timedelta(hours=i) for i in range(1, 25)] if dt.hour == 4 ][0] # TODO: show when user has (possible) actions in order book for a time slot if current_user.is_authenticated and ( current_user.has_role("admin") or "wind" in current_user.email or "charging" in current_user.email ): plotting.highlight( fig_profile, next4am, next4am + timedelta(hours=1), redirect_to="/control" ) # actions df_actions = pd.DataFrame(index=power_sum_df.index, columns=["event_value"]).fillna( 0 ) if next4am in df_actions.index: if current_user.is_authenticated: if current_user.has_role("admin"): df_actions.loc[next4am] = -2.4 # mock two actions elif "wind" in current_user.email: df_actions.loc[next4am] = -1.3 # mock one action elif "charging" in current_user.email: df_actions.loc[next4am] = -1.1 # mock one action next2am = [ dt for dt in [this_hour + timedelta(hours=i) for i in range(1, 25)] if dt.hour == 2 ][0] if next2am in df_actions.index: if next2am < next4am and ( current_user.is_authenticated and ( current_user.has_role("admin") or "wind" in current_user.email or "charging" in current_user.email ) ): # mock the shift "payback" (actually occurs earlier in our mock example) df_actions.loc[next2am] = 1.1 next9am = [ dt for dt in [this_hour + timedelta(hours=i) for i in range(1, 25)] if dt.hour == 9 ][0] if next9am in df_actions.index: # mock some other ordered actions that are not in an opportunity hour anymore df_actions.loc[next9am] = 3.5 fig_actions = plotting.create_graph( df_actions, unit="MW", title="Ordered balancing actions", x_range=x_range, y_label="Power (in MW)", ) fig_actions.plot_height = 150 fig_actions.plot_width = fig_profile.plot_width fig_actions.xaxis.visible = False if current_user.is_authenticated and ( current_user.has_role("admin") or "wind" in current_user.email or "charging" in current_user.email ): plotting.highlight( fig_actions, next4am, next4am + timedelta(hours=1), redirect_to="/control" ) portfolio_plots_script, portfolio_plots_divs = components( (fig_profile, fig_actions) ) next24hours = [ (time_utils.get_most_recent_hour() + timedelta(hours=i)).strftime("%I:00 %p") for i in range(1, 26) ] return render_flexmeasures_template( "views/portfolio.html", assets=assets, average_prices=average_price_dict, asset_types=represented_asset_types, markets=markets, production_per_asset=production_per_asset, consumption_per_asset=consumption_per_asset, curtailment_per_asset=curtailment_per_asset, shifting_per_asset=shifting_per_asset, profit_loss_flexibility_per_asset=profit_loss_flexibility_per_asset, production_per_asset_type=production_per_asset_type, consumption_per_asset_type=consumption_per_asset_type, curtailment_per_asset_type=curtailment_per_asset_type, shifting_per_asset_type=shifting_per_asset_type, profit_loss_flexibility_per_asset_type=profit_loss_flexibility_per_asset_type, sum_production=sum(production_per_asset_type.values()), sum_consumption=sum(consumption_per_asset_type.values()), sum_curtailment=sum(curtailment_per_asset_type.values()), sum_shifting=sum(shifting_per_asset_type.values()), sum_profit_loss_flexibility=sum( profit_loss_flexibility_per_asset_type.values() ), portfolio_plots_script=portfolio_plots_script, portfolio_plots_divs=portfolio_plots_divs, next24hours=next24hours, alt_stacking=show_summed, )
def portfolio_view(): # noqa: C901 """Portfolio view. By default, this page shows live results (production, consumption and market data) from the user's portfolio. Time windows for which the platform has identified upcoming balancing opportunities are highlighted. The page can also be used to navigate historical results. """ set_time_range_for_session() start = session.get("start_time") end = session.get("end_time") resolution = session.get("resolution") # Get plot perspective perspectives = ["production", "consumption"] default_stack_side = "production" # todo: move to user config setting show_stacked = request.values.get("show_stacked", default_stack_side) perspectives.remove(show_stacked) show_summed: str = perspectives[0] plot_label = f"Stacked {show_stacked} vs aggregated {show_summed}" # Get structure and data assets: List[Asset] = get_assets(order_by_asset_attribute="display_name", order_direction="asc") represented_asset_types, markets, resource_dict = get_structure(assets) for resource_name, resource in resource_dict.items(): resource.load_sensor_data( [Power, Price], start=start, end=end, resolution=resolution, exclude_source_types=["scheduling script"], ) # The resource caches the results ( supply_resources_df_dict, demand_resources_df_dict, production_per_asset_type, consumption_per_asset_type, production_per_asset, consumption_per_asset, ) = get_power_data(resource_dict) price_bdf_dict, average_price_dict = get_price_data(resource_dict) # Pick a perspective for summing and for stacking sum_dict = (demand_resources_df_dict.values() if show_summed == "consumption" else supply_resources_df_dict.values()) power_sum_df = (pd.concat(sum_dict, axis=1).sum(axis=1).to_frame( name="event_value") if sum_dict else pd.DataFrame()) # Create summed plot power_sum_df = data_or_zeroes(power_sum_df, start, end, resolution) x_range = plotting.make_range( pd.date_range(start, end, freq=resolution, closed="left")) fig_profile = plotting.create_graph( power_sum_df, unit="MW", title=plot_label, x_range=x_range, x_label="Time (resolution of %s)" % time_utils.freq_label_to_human_readable_label(resolution), y_label="Power (in MW)", legend_location="top_right", legend_labels=(capitalize(show_summed), None, None), show_y_floats=True, non_negative_only=True, ) fig_profile.plot_height = 450 fig_profile.plot_width = 900 # Create stacked plot stack_dict = (rename_event_value_column_to_resource_name( supply_resources_df_dict).values() if show_summed == "consumption" else rename_event_value_column_to_resource_name( demand_resources_df_dict).values()) df_stacked_data = pd.concat(stack_dict, axis=1) if stack_dict else pd.DataFrame() df_stacked_data = data_or_zeroes(df_stacked_data, start, end, resolution) df_stacked_areas = stack_df(df_stacked_data) num_areas = df_stacked_areas.shape[1] if num_areas <= 2: colors = ["#99d594", "#dddd9d"] else: colors = palettes.brewer["Spectral"][num_areas] df_stacked_data = time_utils.tz_index_naively(df_stacked_data) x_points = np.hstack((df_stacked_data.index[::-1], df_stacked_data.index)) fig_profile.grid.minor_grid_line_color = "#eeeeee" for a, area in enumerate(df_stacked_areas): fig_profile.patch( x_points, df_stacked_areas[area].values, color=colors[a], alpha=0.8, line_color=None, legend=df_stacked_data.columns[a], level="underlay", ) portfolio_plots_script, portfolio_plots_divs = components(fig_profile) # Flexibility numbers and a mocked control action are mocked for demo mode at the moment flex_info = {} if current_app.config.get("FLEXMEASURES_MODE") == "demo": flex_info = mock_flex_info(assets, represented_asset_types) fig_actions = mock_flex_figure(x_range, power_sum_df.index, fig_profile.plot_width) mock_flex_action_in_main_figure(fig_profile) portfolio_plots_script, portfolio_plots_divs = components( (fig_profile, fig_actions)) return render_flexmeasures_template( "views/portfolio.html", assets=assets, average_prices=average_price_dict, asset_types=represented_asset_types, markets=markets, production_per_asset=production_per_asset, consumption_per_asset=consumption_per_asset, production_per_asset_type=production_per_asset_type, consumption_per_asset_type=consumption_per_asset_type, sum_production=sum(production_per_asset_type.values()), sum_consumption=sum(consumption_per_asset_type.values()), flex_info=flex_info, portfolio_plots_script=portfolio_plots_script, portfolio_plots_divs=portfolio_plots_divs, alt_stacking=show_summed, fm_mode=current_app.config.get("FLEXMEASURES_MODE"), )