pacti-org / pacti

A package for compositional system analysis and design
https://www.pacti.org
BSD 3-Clause "New" or "Revised" License
19 stars 5 forks source link

[Feature request] Support plotting of 1-to-many variables. #319

Open NicolasRouquette opened 1 year ago

NicolasRouquette commented 1 year ago

Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

The current pacti.terms.polyhedra.plots API supports plotting 1 variable as a function of another. I would like support for plotting multiple variables as a function of a single variable.

Describe the solution you'd like A clear and concise description of what you want to happen.

Expand the current API:

def plot_assumptions(
    contract: PolyhedralContract,
    x_var: Var,
    y_var: Var,
    var_values: Dict[Var, numeric],
    x_lims: Tuple[numeric, numeric],
    y_lims: Tuple[numeric, numeric],
) -> MplFigure

def plot_guarantees(
    contract: PolyhedralContract,
    x_var: Var,
    y_var: Var,
    var_values: Dict[Var, numeric],
    x_lims: Tuple[numeric, numeric],
    y_lims: Tuple[numeric, numeric],
) -> MplFigure

To something like this:

Expand the current API:

def plot_assumptions(
    contract: PolyhedralContract,
    x_var: Var,
    y_vars: List[Var],
    var_values: Dict[Var, numeric],
    x_lims: Dict[Var, Tuple[numeric, numeric]],
    y_lims: Tuple[numeric, numeric],
) -> MplFigure

def plot_guarantees(
    contract: PolyhedralContract,
    x_var: Var,
    y_vars: List[Var],
    var_values: Dict[Var, numeric],
    x_lims: Dict[Var, Tuple[numeric, numeric]],
    y_lims: Tuple[numeric, numeric],
) -> MplFigure

In either form, this would produce a stacked list of plots, one for each y_vars as a function of x_var.

Describe alternatives you've considered

A clear and concise description of any alternative solutions or features you've considered.

Additional context Add any other context or screenshots about the feature request here.

ayush9pandey commented 1 year ago

This would be a great feature! A related feature that I have worked on would implement something like:

def plot_guarantees(
    contract: List[PolyhedralContract],
    x_var: Var,
    y_vars: Var,
    var_values: List[Dict[Var, numeric]],
    x_lims: List[Dict[Var, Tuple[numeric, numeric]]],
    y_lims: List[Tuple[numeric, numeric]],
) -> MplFigure

Here a list of contracts is passed in and the resulting plot is a stitched matplotlib figure of the listed contracts with corresponding var_values, x_lims, y_lims. It might make the plot_guarantees function too overloaded so I'm open to adding this as a new function.

NicolasRouquette commented 1 year ago

@iincer @ayush9pandey What do you think of the following plot_input_output_polyhedral_term_list and plot_input_outputs_polyhedral_term_list API below?

The current plotting API requires providing bounds for x and y; we could instead ask Pacti to calculate them as done below:


# Add a callback function for the mouse click event
def _on_hover(ptl: PolyhedralTermList, x_var: Var, y_var: Var, fig, ax: Axes, event):
    if event.inaxes == ax:
        x_coord, y_coord = event.xdata, event.ydata
        y_min, y_max = calculate_output_bounds_for_input_value(
            ptl, {x_var: x_coord}, y_var
        )
        ax.set_title(
            f"@ {x_var.name}={x_coord:.2f}\n{y_min:.2f} <= {y_var.name} <= {y_max:.2f}"
        )
        fig.canvas.draw_idle()

def plot_input_output_polyhedral_term_list(
    ptl: PolyhedralTermList, x_var: Var, y_var: Var
) -> MplFigure:
    """
    In a Jupyter notebook, show a 2-D plot of the input/output relationship
    among variables of a polyhedral term list.

    Clicking in an area of the 2-D plot shows the bounds of the output variable
    corresponding to the value of the input variable corresponding to the location
    of the mouse click.

    Args:
        ptl: PolyhedralTermList
            A polyhedral term list of two variables only: x_var and y_var.

        x_var: Var
            The input variable.

        y_var: Var
            The output variable.

    Returns:
        A 2-D plot figure for interactive visualization in a Jupyter notebook.
    """
    x_lims = get_bounds(ptl, x_var.name)
    y_lims = get_bounds(ptl, y_var.name)
    res_tuple = PolyhedralTermList.termlist_to_polytope(ptl, PolyhedralTermList([]))
    # variables = res_tuple[0]
    a_mat = res_tuple[1]
    b = res_tuple[2]

    x, y = plh_plots._get_bounding_vertices(a_mat, b)

    # generate figure
    fig = plt.figure()
    ax = fig.add_subplot(1, 1, 1, aspect="equal")
    ax.set_xlim(x_lims)
    ax.set_ylim(ymin=y_lims[0], ymax=y_lims[1])
    ax.set_xlabel(x_var.name)
    ax.set_ylabel(y_var.name)
    ax.set_aspect((x_lims[1] - x_lims[0]) / (y_lims[1] - y_lims[0]))

    poly = MplPatchPolygon(
        xy=np.column_stack([x, y]).tolist(),
        # xy=np.column_stack([x, y]).flatten().tolist(),
        animated=False,
        closed=True,
        facecolor="deepskyblue",
        edgecolor="deepskyblue",
    )
    ax.add_patch(poly)

    # Connect the event to the callback function
    fig.canvas.mpl_connect(
        "button_press_event", lambda event: _on_hover(ptl, x_var, y_var, fig, ax, event)
    )

    return fig

The button_press_event callback function, _on_hover, will add a text with the calculated bound for the corresponding x coordinates of the mouse click.

I have generalized this for a list of y variables like this:

def _on_hovers(ptls: List[PolyhedralTermList], x_var: Var, y_vars: List[Var], fig, axl: List[Axes], event):
    if event.inaxes in axl:
        x_coord = event.xdata
        for i, ax in enumerate(axl):
            y_min, y_max = calculate_output_bounds_for_input_value(
                ptls[i], {x_var: x_coord}, y_vars[i]
            )
            axl[i].set_title(
                f"@ {x_var.name}={x_coord:.2f}\n{y_min:.2f} <= {y_vars[i].name} <= {y_max:.2f}"
            )
        fig.canvas.draw_idle()

def retain_constraints_involving_variables(
    ptl: PolyhedralTermList, vs: List[Var]
) -> PolyhedralTermList:
    """
    Retain only the constraints (terms) involving all variables in the provided list.

    Args:
        ptl: PolyhedralTermList
            The initial polyhedral term list.

        vs: List[Var]
            List of variables. Only terms involving all variables from this list are retained.

    Returns:
        A new PolyhedralTermList containing only the terms involving all variables from the list.
    """
    ts = []
    for term in ptl.terms:
        if all(term.contains_var(var_to_seek=v) for v in vs):
            ts.append(term)
        elif any(term.contains_var(var_to_seek=v) for v in vs) and all(
            v in vs for v in term.variables.keys()
        ):
            ts.append(term)

    return PolyhedralTermList(terms=ts)

def plot_input_outputs_polyhedral_term_list(
    ptl: PolyhedralTermList, x_var: Var, y_vars: List[Var]
) -> MplFigure:
    """
    In a Jupyter notebook, show a 2-D plot of the input/output relationship
    among variables of a polyhedral term list.

    Clicking in an area of the 2-D plot shows the bounds of the output variable
    corresponding to the value of the input variable corresponding to the location
    of the mouse click.

    Args:
        ptl: PolyhedralTermList
            A polyhedral term list of two variables only: x_var and y_var.

        x_var: Var
            The input variable.

        y_vars: Var
            The output variables.

    Returns:
        A stacked figure of a 2-D plot figure for each output variable
        as a function of the input variable.

        This stacked figure supports interactive visualization in a Jupyter notebook.
    """
    num_plots = len(y_vars)
    x_lims = get_bounds(ptl, x_var.name)

    fig, axs = plt.subplots(
        nrows=num_plots, ncols=1, 
        figsize=(6, 4 * num_plots), 
        constrained_layout=True
    )

    ptls: List[PolyhedralTermList] = []
    axl: List[Axes] = []
    for i, y_var in enumerate(y_vars):
        y_ptl: PolyhedralTermList = retain_constraints_involving_variables(
            ptl, [x_var, y_var]
        )
        ptls.append(y_ptl)
        y_lim: tuple[float, float] = get_bounds(y_ptl, y_var.name)

        res_tuple = PolyhedralTermList.termlist_to_polytope(
            y_ptl, PolyhedralTermList([])
        )
        # variables = res_tuple[0]
        a_mat: np.ndarray = res_tuple[1]
        b: np.ndarray = res_tuple[2]

        try:
            x, y = plh_plots._get_bounding_vertices(a_mat, b)

            ax: Axes = axs[i]
            ax.set_xlim(x_lims)
            ax.set_ylim(ymin=y_lim[0], ymax=y_lim[1])
            ax.set_xlabel(x_var.name)
            ax.set_ylabel(y_var.name)
            ax.set_aspect((x_lims[1] - x_lims[0]) / (y_lim[1] - y_lim[0]))

            poly = MplPatchPolygon(
                xy=np.column_stack([x, y]).tolist(),
                # xy=np.column_stack([x, y]).flatten().tolist(),
                animated=False,
                closed=True,
                facecolor="deepskyblue",
                edgecolor="deepskyblue",
            )
            ax.add_patch(poly)
            axl.append(ax)
        except QhullError as e:
            print(f"x_var: {x_var.name}, y_var: {y_var.name}")
            print(f"term list\n" + show_termlist(y_ptl))
            print(f"QhullError: {e}")
            pass
        except IndexError as e:
            print(f"x_var: {x_var.name}, y_var: {y_var.name}")
            print(f"term list\n" + show_termlist(y_ptl))
            print(f"IndexError: {e}")
            pass

    # Connect the event to the callback function
    fig.canvas.mpl_connect(
        "button_press_event", lambda event: _on_hovers(ptls, x_var, y_vars, fig, axl, event)
    )

    return fig

Here is an example where the PolyhedralTermList is the following:

[
  -d_var <= 0
  d_var + 4 t_var <= 40
  -d_var - 5 t_var <= -40
  -duration <= -0
  0.2 duration <= 80
  -duration + t_var <= 0
  r_var = 80
  soc_var + 0.1 t_var <= 80
  -soc_var - 0.2 t_var <= -80
  -t_var <= 0
  0.1 t_var - u_var <= -20
  -0.2 t_var + u_var <= 20
  u_var <= 100
]

I get this:

image

The 1st and 3rd plots show the effect of the _on_hovers callback where I clicked somewhere in the plot area and each plot shows the bound of the corresponding output variable for the x coordinate of the mouse click.

The 2nd plot does now show anything because pacti.terms.polyhedra.plots._get_bounding_vertices does not handle the degenerate case where the output variable is a constant:

x_var: t_var, y_var: r_var
term list
[
  r_var = 80
  -t_var <= 0
]
iincer commented 1 year ago

Hi @NicolasRouquette, I think the functionality to interactively show values and bounds could be very useful. A couple of comments/questions:

NicolasRouquette commented 1 year ago

Regarding your first point, the main advantage stems from the interactive hover that uses the same x-coordinate of the mouse click to compute the ranges for all plots. The screenshot shows an example where the x-coordinate corresponds to t_var=6.10 and each plot shows the corresponding range for the different variables, r_var and d_var.

Regarding your second point: clearly, we need to improve the support for corner cases: