예제 #1
0
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
예제 #2
0
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
예제 #3
0
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,
    )
예제 #4
0
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"),
    )