Repository: fitbenchmarking/fitbenchmarking

Issue:
Refactor plots.py

Discussion:
github-actions[bot]: ## Unit & system test results
  3 files   -   1    3 suites   - 1   14m 2s ⏱️ -37s
738 tests  -   6  724 ✅  -  10  10 💤 ± 0  4 ❌ +4 
743 runs   - 673  724 ✅  - 618  15 💤  - 59  4 ❌ +4 

For more details on these failures, see [this check](https://github.com/fitbenchmarking/fitbenchmarking/runs/38593238999).

Results for commit a96ef03d. ± Comparison against base commit b6e01908.

<details>
  <summary>This pull request <b>removes</b> 8 and <b>adds</b> 2 tests. <i>Note that renamed tests count towards both.</i></summary>

```
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_factory[nist-/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/basic.dat]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_factory[nist-/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/start_end_x.dat]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_function_evaluation[/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/function_evaluations.json-nist]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_hessian_evaluation[/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/hessian_evaluations.json-nist]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_jacobian_evaluation[/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/jacobian_evaluations.json-nist]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_parsers[expected0-nist-/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/basic.dat]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_parsers[expected1-nist-/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/start_end_x.dat]
fitbenchmarking.parsing.tests.test_parsers.TestParsers ‑ test_sparsej_evaluation[/home/runner/work/fitbenchmarking/fitbenchmarking/fitbenchmarking/parsing/tests/nist/sparse_jacobian_evaluations.json-nist]
```

```
fitbenchmarking.results_processing.tests.test_plots.PlotTests ‑ test_create_empty_residuals_plot_spinw
fitbenchmarking.results_processing.tests.test_plots.PlotTests ‑ test_plot_residuals_create_files
```
</details>

[test-results]:data:application/gzip;base64,H4sIADOf0GcC/1WMyw6DIBQFf8Ww7gIBEfszzeWVkKo0CKum/96L9dXlzDmZN/FhdAu5N/zWkKWEfIAtCXKIM6ISDAVOuY49Vzs9lmJMVUyc6hleqFp6GA9hRHNeXEoxoamXVOY1KvgG/82f2ZLdLi7Fla9BE6cpZAQCg3Secku71gIwa8BpaQyz0g9cKq2lcMJBTz5fPFJ3rAcBAAA=

coveralls: 
[![Coverage Status](https://coveralls.io/builds/72666167/badge)](https://coveralls.io/builds/72666167)

coverage: 90.12% (-0.4%) from 90.476%
when pulling **a96ef03d051daa2dcaeb6cc2d6f9368bb64e4ea7 on 1453-refactor-plots**
into **b6e01908bf95d14138adad9ee87243d007cd013f on master**.


Patch:
diff --git a/fitbenchmarking/parsing/horace_parser.py b/fitbenchmarking/parsing/horace_parser.py
index ae46b459..0d81da04 100644
--- a/fitbenchmarking/parsing/horace_parser.py
+++ b/fitbenchmarking/parsing/horace_parser.py
@@ -31,7 +31,7 @@ def horace_on():
     ):
         raise ParsingError(
             "Could not parse SpinW problem. Please ensure "
-            "that HORACE_LOCATION is specfied as a environment "
+            "that HORACE_LOCATION is specfied as an environment "
             "variable"
         )
     elif (
@@ -39,7 +39,7 @@ def horace_on():
     ):
         raise ParsingError(
             "Could not parse SpinW problem. Please ensure "
-            "that SPINW_LOCATION is specfied as a environment "
+            "that SPINW_LOCATION is specfied as an environment "
             "variable"
         )
     else:
@@ -295,7 +295,7 @@ class HoraceParser(FitbenchmarkParser):
         # This if condition avoids error when "plot_type" not provided
         if "plot_type" in self._entries:
             self._set_plot_type()
-            self._set_qcens_and_ncuts()
+            self._add_plot_info_as_additional_info()
 
     def _set_plot_type(self) -> None:
         """
@@ -313,27 +313,42 @@ class HoraceParser(FitbenchmarkParser):
                 f"options {plot_type_options}"
             )
 
-    def _set_qcens_and_ncuts(self) -> None:
+    def _get_subplot_titles_SpinW(self, qcens) -> list[str]:
+        """Gets subplot titles for SpinW."""
+        subplot_titles = [f"{i} Å<sup>-1</sup>" for i in qcens]
+        return subplot_titles
+
+    def _add_plot_info_as_additional_info(self) -> None:
         """
-        Add q_cens and n_cuts to fitting_problem.additional_info.
+        Add plot info needed for 1d cuts (e.g. n_plots and subplot_titles)
+        to fitting_problem.additional_info.
         """
+
         if self.fitting_problem.additional_info["plot_type"] == "1d_cuts":
             eng.evalc(f"ebin_cens = {self._horace_w}(1).x")
             self.fitting_problem.additional_info["ebin_cens"] = np.array(
                 eng.workspace["ebin_cens"], dtype=np.float64
             )[0]
+
+            self.fitting_problem.additional_info["ax_titles"] = {
+                "x": "Energy (meV)",
+                "y": "Intensity",
+            }
+
             if "q_cens" in self._entries:
-                self.fitting_problem.additional_info["q_cens"] = self._entries[
-                    "q_cens"
-                ].split(",")
-                self.fitting_problem.additional_info["n_cuts"] = len(
-                    self.fitting_problem.additional_info["q_cens"]
+                qcens = self._entries["q_cens"].split(",")
+                self.fitting_problem.additional_info["n_plots"] = len(qcens)
+                self.fitting_problem.additional_info["subplot_titles"] = (
+                    self._get_subplot_titles_SpinW(qcens)
                 )
             else:
-                raise ParsingError("q_cens are required for plotting 1D cuts")
+                raise ParsingError(
+                    "q_cens are required for plotting 1D cuts of SpinW data"
+                )
+
             if not float(
                 len(self.fitting_problem.data_y)
-                / self.fitting_problem.additional_info["n_cuts"]
+                / self.fitting_problem.additional_info["n_plots"]
             ).is_integer():
                 raise ParsingError(
                     "Number of data points must be divisible "
diff --git a/fitbenchmarking/results_processing/plots.py b/fitbenchmarking/results_processing/plots.py
index 791c82ff..78df4412 100644
--- a/fitbenchmarking/results_processing/plots.py
+++ b/fitbenchmarking/results_processing/plots.py
@@ -7,7 +7,6 @@ import os
 
 import numpy as np
 import plotly.colors as ptly_colors
-import plotly.express as px
 import plotly.graph_objects as go
 from plotly.subplots import make_subplots
 
@@ -22,13 +21,6 @@ class Plot:
 
     _data_marker = {"symbol": "x", "color": "black", "opacity": 0.3}
     _best_fit_line = {"dash": "dot", "color": "#6699ff"}
-    _legend_options = {
-        "yanchor": "top",
-        "y": 0.99,
-        "xanchor": "left",
-        "x": 0.01,
-        "bgcolor": "rgba(0,0,0,0.1)",
-    }
     _summary_best_plot_line = {"width": 2}
     _summary_plot_line = {"width": 1, "dash": "dash"}
     _starting_guess_plot_line = {"width": 1, "color": "#5F8575"}
@@ -40,6 +32,7 @@ class Plot:
         "width": 4,
         "color": "rgba(0,0,0,0.4)",
     }
+    _default_ax_titles = {"x": "x", "y": "y"}
 
     def __init__(self, best_result, options, figures_dir):
         self.result = best_result
@@ -52,7 +45,7 @@ class Plot:
             )
         if (
             self.result.problem_format == "horace"
-            and self.result.spinw_plot_info is None
+            and self.result.plot_info is None
         ):
             self.plots_failed = True
             raise PlottingError(
@@ -66,7 +59,9 @@ class Plot:
         self.figures_dir = figures_dir
 
     @staticmethod
-    def write_html_with_link_plotlyjs(fig, figures_dir, htmlfile, options):
+    def write_html_with_link_plotlyjs(
+        fig, figures_dir, htmlfile, options
+    ) -> None:
         """
         Writes an html file for the figure passed as input and
         includes link to the relevant plotly.js file.
@@ -79,145 +74,54 @@ class Plot:
         :type htmlfile: str
         :param options: The options for the run
         :type options: utils.options.Options
-
-        :return: None
         """
         plotly_path = get_js(options, figures_dir).get("plotly")
         html_file_name = os.path.join(figures_dir, htmlfile)
         fig.write_html(html_file_name, include_plotlyjs=plotly_path)
 
-    def plotly_spinw(self, df, minimizer=None, y_best=None):
-        n_cuts = self.result.spinw_plot_info["n_cuts"]
-        titles_with_unit = [
-            f"{i} Å<sup>-1</sup>"
-            for i in self.result.spinw_plot_info["q_cens"]
-        ]
-        fig = make_subplots(
-            rows=1,
-            cols=n_cuts,
-            subplot_titles=titles_with_unit,
-        )
-        data_len = int(len(df["y"][df["minimizer"] == "Data"]) / n_cuts)
-        if data_len != len(self.result.spinw_plot_info["ebin_cens"]):
-            raise PlottingError("x and y data lengths are not the same")
-
-        for i in range(n_cuts):
-            fig.update_xaxes(title_text="Energy (meV)", row=1, col=i + 1)
-            fig.update_yaxes(title_text="Intensity", row=1, col=i + 1)
-
-            if minimizer and minimizer not in ["Data", "Starting Guess"]:
-                fig.add_trace(
-                    go.Scatter(
-                        x=self.result.spinw_plot_info["ebin_cens"],
-                        y=df["y"][df["minimizer"] == minimizer][
-                            (data_len * i) : (data_len * (i + 1))
-                        ],
-                        name=minimizer,
-                        line=self._subplots_line,
-                        showlegend=i == 0,
-                    ),
-                    row=1,
-                    col=i + 1,
-                )
-                if not df["best"][df["minimizer"] == minimizer].iloc[0]:
-                    name = f"Best Fit ({df['minimizer'][df['best']].iloc[0]})"
-                    fig.add_trace(
-                        go.Scatter(
-                            x=self.result.spinw_plot_info["ebin_cens"],
-                            y=y_best[(data_len * i) : (data_len * (i + 1))],
-                            name=name,
-                            line=self._best_fit_line,
-                            showlegend=i == 0,
-                        ),
-                        row=1,
-                        col=i + 1,
-                    )
-            # plot starting guess if minimizer not provided
-            fig.add_trace(
-                go.Scatter(
-                    x=self.result.spinw_plot_info["ebin_cens"],
-                    y=df["y"][df["minimizer"] == "Starting Guess"][
-                        (data_len * i) : (data_len * (i + 1))
-                    ],
-                    name="Starting Guess",
-                    line=self._starting_guess_plot_line,
-                    showlegend=i == 0,
-                ),
-                row=1,
-                col=i + 1,
-            )
-            # plot data in both cases
-            fig.add_trace(
-                go.Scatter(
-                    x=self.result.spinw_plot_info["ebin_cens"],
-                    y=df["y"][df["minimizer"] == "Data"][
-                        (data_len * i) : (data_len * (i + 1))
-                    ],
-                    mode="markers",
-                    name="Data",
-                    marker=self._data_marker,
-                    showlegend=i == 0,
-                ),
-                row=1,
-                col=i + 1,
-            )
-
-        fig.update_layout(
-            legend={"yanchor": "auto", "y": 0.5, "xanchor": "right", "x": -0.1}
-        )
-        return fig
-
-    def plot_initial_guess(self, df):
+    def plot_initial_guess(self, df_fit) -> str:
         """
         Plots the initial guess along with the data and stores in a file
 
-        :param df: A dataframe holding the data
-        :type df: Pandas dataframe
+        :param df_fit: A dataframe holding the data
+        :type df_fit: Pandas dataframe
 
         :return: path to the saved file
         :rtype: str
         """
+        title = self.result.name
+        n_plots, subplot_titles, ax_titles = self._set_n_plots_and_titles(
+            self.result
+        )
 
-        if self.result.spinw_plot_info is not None:
-            fig = self.plotly_spinw(df)
-        else:
-            # Plotly implementation below
-            fig = px.line(
-                df[df["minimizer"] == "Starting Guess"],
-                x="x",
-                y="y",
-                color="minimizer",
-                title=self.result.name,
-                markers=True,
-            )
-            self._error_dict["array"] = df["e"][df["minimizer"] == "Data"]
-            # add the raw data as a scatter plot
-            fig.add_trace(
-                go.Scatter(
-                    x=df["x"][df["minimizer"] == "Data"],
-                    y=df["y"][df["minimizer"] == "Data"],
-                    error_y=self._error_dict,
-                    mode="markers",
-                    name="Data",
-                    marker=self._data_marker,
-                )
-            )
-            fig.update_layout(legend=self._legend_options)
+        fig = make_subplots(
+            rows=1,
+            cols=n_plots,
+            subplot_titles=subplot_titles,
+        )
 
-        if self.result.plot_scale in ["loglog", "logx"]:
-            fig.update_xaxes(type="log")
-        if self.result.plot_scale in ["loglog", "logy"]:
-            fig.update_yaxes(type="log")
+        self._error_dict["array"] = df_fit["e"][df_fit["minimizer"] == "Data"]
 
-        htmlfile = f"start_for_{self.result.sanitised_name}.html"
+        self._add_data_points(
+            fig,
+            df_fit["x"][df_fit["minimizer"] == "Data"],
+            df_fit["y"][df_fit["minimizer"] == "Data"],
+            self._error_dict,
+            n_plots,
+        )
+        self._add_starting_guess(fig, df_fit, n_plots, ax_titles)
+
+        fig.update_layout(title=title)
+        self._update_to_logscale_if_needed(fig, self.result)
 
+        htmlfile = f"start_for_{self.result.sanitised_name}.html"
         self.write_html_with_link_plotlyjs(
             fig, self.figures_dir, htmlfile, self.options
         )
         return htmlfile
 
     @staticmethod
-    def best_filename(result):
+    def best_filename(result) -> str:
         """
         Returns the filename of the best fit plot.
 
@@ -233,95 +137,101 @@ class Plot:
         )
         return htmlfile
 
-    def plotly_fit(self, df):
+    def plotly_fit(self, df_fit) -> dict[str, str]:
         """
         Uses plotly to plot the calculated fit, along with the best fit.
         Stores the plot in a file
 
-        :param df: A dataframe holding the data
-        :type df: Pandas dataframe
+        :param df_fit: A dataframe holding the data
+        :type df_fit: Pandas dataframe
 
         :return: A dictionary of paths to the saved files
         :rtype: dict[str, str]
         """
-        # Plotly implementation below
         htmlfiles = {}
-        x_best = df["x"][df["best"]]
-        y_best = df["y"][df["best"]]
-        x_data = df["x"][df["minimizer"] == "Data"]
-        y_data = df["y"][df["minimizer"] == "Data"]
-        x_start = df[df["minimizer"] == "Starting Guess"]["x"]
-        y_start = df[df["minimizer"] == "Starting Guess"]["y"]
-        self._error_dict["array"] = df["e"][df["minimizer"] == "Data"]
-
-        for minimizer in df["minimizer"].unique():
-            if minimizer not in ["Data", "Starting Guess"]:
-                if self.result.spinw_plot_info is None:
-                    fig = px.line(
-                        df[df["minimizer"] == minimizer],
-                        x="x",
-                        y="y",
-                        color="minimizer",
-                        title=self.result.name,
-                        markers=True,
-                    )
-                    if not df["best"][df["minimizer"] == minimizer].iloc[0]:
-                        # add the best plot
-                        name = (
-                            f"Best Fit ({df['minimizer'][df['best']].iloc[0]})"
-                        )
-                        fig.add_trace(
-                            go.Scatter(
-                                x=x_best,
-                                y=y_best,
-                                mode="lines",
-                                name=name,
-                                line=self._best_fit_line,
-                            )
-                        )
-                    # add the raw data as a scatter plot
-                    fig.add_trace(
-                        go.Scatter(
-                            x=x_start,
-                            y=y_start,
-                            mode="lines+markers",
-                            name="Starting Guess",
-                            line=self._starting_guess_plot_line,
-                        )
+        x_best = df_fit["x"][df_fit["best"]]
+        y_best = df_fit["y"][df_fit["best"]]
+        x_data = df_fit["x"][df_fit["minimizer"] == "Data"]
+        y_data = df_fit["y"][df_fit["minimizer"] == "Data"]
+        self._error_dict["array"] = df_fit["e"][df_fit["minimizer"] == "Data"]
+
+        n_plots, subplot_titles, ax_titles = self._set_n_plots_and_titles(
+            self.result
+        )
+
+        if self.result.plot_info is not None:
+            self._check_data_len(x_data, y_data)
+
+        data_len = int(
+            len(df_fit["y"][df_fit["minimizer"] == "Data"]) / n_plots
+        )
+
+        for minimizer in df_fit[
+            ~df_fit.minimizer.isin(["Data", "Starting Guess"])
+        ]["minimizer"].unique():
+            fig = make_subplots(
+                rows=1,
+                cols=n_plots,
+                subplot_titles=subplot_titles,
+            )
+            self._add_data_points(
+                fig, x_data, y_data, self._error_dict, n_plots
+            )
+            self._add_starting_guess(fig, df_fit, n_plots, ax_titles)
+
+            for i in range(n_plots):
+                fig.add_trace(
+                    go.Scatter(
+                        x=df_fit["x"][df_fit["minimizer"] == minimizer],
+                        y=df_fit["y"][df_fit["minimizer"] == minimizer][
+                            (data_len * i) : (data_len * (i + 1))
+                        ],
+                        name=minimizer,
+                        line=self._subplots_line,
+                        showlegend=i == 0,
+                        mode="markers+lines",
+                        legendgroup=minimizer,
+                    ),
+                    row=1,
+                    col=i + 1,
+                )
+
+                if not df_fit["best"][df_fit["minimizer"] == minimizer].iloc[
+                    0
+                ]:
+                    # Add the best plot
+                    name = (
+                        "Best Fit "
+                        + f"({df_fit['minimizer'][df_fit['best']].iloc[0]})"
                     )
                     fig.add_trace(
                         go.Scatter(
-                            x=x_data,
-                            y=y_data,
-                            error_y=self._error_dict,
-                            mode="markers",
-                            name="Data",
-                            marker=self._data_marker,
-                        )
+                            x=x_best,
+                            y=y_best[(data_len * i) : (data_len * (i + 1))],
+                            name=name,
+                            line=self._best_fit_line,
+                            legendgroup="best-minim",
+                            showlegend=i == 0,
+                        ),
+                        row=1,
+                        col=i + 1,
                     )
-                    fig.update_layout(legend=self._legend_options)
-                else:
-                    fig = self.plotly_spinw(df, minimizer, y_best)
-
-                if self.result.plot_scale in ["loglog", "logx"]:
-                    fig.update_xaxes(type="log")
-                if self.result.plot_scale in ["loglog", "logy"]:
-                    fig.update_yaxes(type="log")
-
-                htmlfile = (
-                    f"{minimizer}_fit_for_{self.result.costfun_tag}"
-                    f"_{self.result.sanitised_name}.html"
-                )
 
-                self.write_html_with_link_plotlyjs(
-                    fig, self.figures_dir, htmlfile, self.options
-                )
+            fig.update_layout(title=self.result.name)
+            self._update_to_logscale_if_needed(fig, self.result)
 
-                htmlfiles[minimizer] = htmlfile
+            htmlfile = (
+                f"{minimizer}_fit_for_{self.result.costfun_tag}"
+                f"_{self.result.sanitised_name}.html"
+            )
+            self.write_html_with_link_plotlyjs(
+                fig, self.figures_dir, htmlfile, self.options
+            )
+            htmlfiles[minimizer] = htmlfile
 
         return htmlfiles
 
-    def plot_posteriors(self, result):
+    def plot_posteriors(self, result) -> str:
         """
         Use Plotly to plot estimated posterior pdfs.
 
@@ -333,7 +243,6 @@ class Plot:
         """
 
         par_names = self.result.param_names
-
         fig = make_subplots(
             rows=len(par_names), cols=1, subplot_titles=par_names
         )
@@ -382,7 +291,7 @@ class Plot:
         return html_fname
 
     @classmethod
-    def plot_summary(cls, categories, title, options, figures_dir):
+    def plot_summary(cls, categories, title, options, figures_dir) -> str:
         """
         Create a comparison plot showing all fits from the results with the
         best for each category highlighted.
@@ -399,143 +308,165 @@ class Plot:
         :return: The path to the new plot
         :rtype: str
         """
-
-        col_vals = np.linspace(0, 1, len(categories))
-        plotly_colours = ptly_colors.sample_colorscale(
-            ptly_colors.sequential.Rainbow, samplepoints=col_vals
-        )
-
+        colours = cls._sample_colours(np.linspace(0, 1, len(categories)))
         first_result = next(iter(categories.values()))[0]
+        n_plots, subplot_titles, ax_titles = cls._set_n_plots_and_titles(
+            first_result
+        )
 
-        if first_result.spinw_plot_info is not None:
-            n_plots = first_result.spinw_plot_info["n_cuts"]
-            titles_with_unit = [
-                f"{i} Å<sup>-1</sup>"
-                for i in first_result.spinw_plot_info["q_cens"]
-            ]
-            plotlyfig = make_subplots(
-                rows=1,
-                cols=n_plots,
-                subplot_titles=titles_with_unit,
-            )
-            data_len = int(len(first_result.data_y) / n_plots)
-        else:
-            n_plots = 1
-            plotlyfig = go.Figure()
+        fig = make_subplots(
+            rows=1,
+            cols=n_plots,
+            subplot_titles=subplot_titles,
+        )
 
-        # Plot data
         if "weighted_nlls" in options.cost_func_type:
             error_y = {
                 "type": "data",
                 "array": first_result.data_e,
-                "color": "rgba(0,0,0,0.4)",
+                "color": "rgba(0,0,0,0.3)",
                 "thickness": 1,
                 "visible": True,
             }
         else:
             error_y = None
-        if n_plots > 1:
-            for i in range(n_plots):
-                plotlyfig.add_trace(
-                    go.Scatter(
-                        x=first_result.spinw_plot_info["ebin_cens"],
-                        y=first_result.data_y[
-                            (data_len * i) : (data_len * (i + 1))
-                        ],
-                        error_y=error_y,
-                        mode="markers",
-                        name="Data",
-                        marker=cls._data_marker,
-                        showlegend=i == 0,
-                        legendgroup="group-data",
-                    ),
-                    row=1,
-                    col=i + 1,
-                )
-        else:
-            plotlyfig.add_trace(
+
+        data_x = first_result.data_x
+        data_y = first_result.data_y
+        cls._add_data_points(fig, data_x, data_y, error_y, n_plots)
+
+        # Plot categories (cost functions)
+        for (categ, results), colour in zip(categories.items(), colours):
+            for result in results:
+                if result.params is not None:
+                    cls._plot_minimizer_results(
+                        fig,
+                        result,
+                        n_plots,
+                        categ,
+                        ax_titles,
+                        colour,
+                    )
+
+                cls._update_to_logscale_if_needed(fig, result)
+
+        fig.update_layout(title=title)
+
+        html_fname = f"summary_plot_for_{first_result.sanitised_name}.html"
+        cls.write_html_with_link_plotlyjs(
+            fig, figures_dir, html_fname, options
+        )
+        return html_fname
+
+    @classmethod
+    def _add_data_points(
+        cls, fig, data_x, data_y, error_y, n_plots
+    ) -> go.Figure:
+        """
+        Adds data points and error bars to given plot.
+
+        :param fig: The plotly figure to add the data to
+        :type fig: plotly.graph_objects.Figure
+        :param data_x: Data along x axis
+        :type data_x: np.ndarray
+        :param data_y: Data along y axis
+        :type data_y: np.ndarray
+        :param error_y: Error associated with the points
+        :type error_y: np.ndarray
+        :param n_plots: number of subplots in the one row
+        :type n_plots: int
+
+        :return: Updated plot
+        :rtype: plotly.graph_objects.Figure
+        """
+        data_len = int(len(data_y) / n_plots)
+
+        for i in range(n_plots):
+            fig.add_trace(
                 go.Scatter(
-                    x=first_result.data_x,
-                    y=first_result.data_y,
+                    x=data_x,
+                    y=data_y[(data_len * i) : (data_len * (i + 1))],
                     error_y=error_y,
                     mode="markers",
                     name="Data",
                     marker=cls._data_marker,
-                )
+                    showlegend=i == 0,
+                    legendgroup="group-data",
+                ),
+                row=1,
+                col=i + 1,
             )
+        return fig
 
-        for (key, results), colour in zip(categories.items(), plotly_colours):
-            # Plot category
-            for result in results:
-                if result.params is not None:
-                    line = (
-                        cls._summary_best_plot_line
-                        if result.is_best_fit
-                        else cls._summary_plot_line
-                    )
-
-                    line["color"] = colour
-                    label = key if result.is_best_fit else ""
-                    if result.is_best_fit:
-                        line = cls._summary_best_plot_line
-                        line["color"] = colour
-                    else:
-                        line = cls._summary_plot_line
-                        transparency = 0.5
-                        line["color"] = (
-                            "rgba"
-                            + colour[3:-1]
-                            + ", "
-                            + str(transparency)
-                            + ")"
-                        )
-
-                    if n_plots > 1:
-                        for i in range(n_plots):
-                            plotlyfig.add_trace(
-                                go.Scatter(
-                                    x=result.spinw_plot_info["ebin_cens"],
-                                    y=result.fin_y[
-                                        (data_len * i) : (data_len * (i + 1))
-                                    ],
-                                    mode="lines",
-                                    name=label,
-                                    line=line,
-                                    showlegend=result.is_best_fit and i == 0,
-                                    legendgroup=f"group-{key}-{result.is_best_fit}",
-                                ),
-                                row=1,
-                                col=i + 1,
-                            )
-                    else:
-                        plotlyfig.add_trace(
-                            go.Scatter(
-                                x=result.data_x[result.sorted_index],
-                                y=result.fin_y[result.sorted_index],
-                                mode="lines",
-                                name=label,
-                                line=line,
-                                showlegend=result.is_best_fit,
-                            )
-                        )
-
-                    plotlyfig.update_layout(title=title)
-
-                if result.plot_scale in ["loglog", "logx"]:
-                    plotlyfig.update_xaxes(type="log")
-                if result.plot_scale in ["loglog", "logy"]:
-                    plotlyfig.update_yaxes(type="log")
+    @classmethod
+    def _plot_minimizer_results(
+        cls, fig, result, n_plots, categ, ax_titles, colour
+    ) -> go.Figure:
+        """
+        Plots results for each minimizer.
 
-        html_fname = f"summary_plot_for_{first_result.sanitised_name}.html"
+        :param fig: The plotly figure to add the traces to
+        :type fig: plotly.graph_objects.Figure
+        :param result: Result we want to add the trace for
+        :type result: FittingResult
+        :param n_plots_per_row: number of subplots per row
+        :type n_plots_per_row: int
+        :param categ: Cost function name
+        :type categ: str
+        :param ax_titles: Titles for x and y axis
+        :type ax_titles: dict[str, str]
+        :param colour: Colour for the minimizer we are plotting
+        :type colour: str
 
-        cls.write_html_with_link_plotlyjs(
-            plotlyfig, figures_dir, html_fname, options
+        :return: Updated plot
+        :rtype: plotly.graph_objects.Figure
+        """
+        line = (
+            cls._summary_best_plot_line
+            if result.is_best_fit
+            else cls._summary_plot_line
         )
+        label = categ if result.is_best_fit else ""
+        if result.is_best_fit:
+            line = cls._summary_best_plot_line
+            line["color"] = colour
+        else:
+            line = cls._summary_plot_line
+            line["color"] = (
+                "rgba" + colour[3:-1] + ", 0.5)"  # 0.5 transparency
+            )
 
-        return html_fname
+        data_len = int(len(result.data_y) / n_plots)
+
+        for i in range(n_plots):
+            if n_plots > 1:
+                x = result.data_x
+                y = result.fin_y[(data_len * i) : (data_len * (i + 1))]
+            else:
+                x = result.data_x[result.sorted_index]
+                y = result.fin_y[result.sorted_index]
+
+            fig.add_trace(
+                go.Scatter(
+                    x=x,
+                    y=y,
+                    mode="lines",
+                    name=label,
+                    line=line,
+                    showlegend=result.is_best_fit and i == 0,
+                    legendgroup=f"group-{categ}-{result.is_best_fit}"
+                    if n_plots > 1
+                    else None,
+                ),
+                row=1,
+                col=i + 1,
+            )
+            cls._update_axes_titles(fig, i, ax_titles)
+
+        return fig
 
     @classmethod
-    def plot_residuals(cls, categories, title, options, figures_dir):
+    def plot_residuals(cls, categories, title, options, figures_dir) -> str:
         """
         Create a comparison plot showing residuals for all fits,
         while emphasizing the residuals for the best fit .
@@ -552,98 +483,128 @@ class Plot:
         :return: The path to the new plot
         :rtype: str
         """
-
         first_result = next(iter(categories.values()))[0]
         col_vals = np.linspace(0, 1, len(list(categories.values())[0]))
-        plotly_colours = ptly_colors.sample_colorscale(
-            ptly_colors.sequential.Rainbow, samplepoints=col_vals
-        )
+        colours = cls._sample_colours(col_vals)
+        n_plots_per_row = 1
+        subplot_titles = None
 
-        if first_result.spinw_plot_info is not None:
-            n_plots_per_row = first_result.spinw_plot_info["n_cuts"]
-            plotlyfig = cls._create_empty_residuals_plot_spinw(
-                categories, n_plots_per_row
-            )
+        if first_result.plot_info is not None:
+            n_plots_per_row = first_result.plot_info["n_plots"]
+            subplot_titles = first_result.plot_info["subplot_titles"]
+
+        # Create subplots on each row if needed
+        if n_plots_per_row > 1:
+            fig = cls._create_empty_residuals_plots(categories, subplot_titles)
         else:
-            n_plots_per_row = 1
-            plotlyfig = make_subplots(
+            fig = make_subplots(
                 rows=len(categories),
                 cols=n_plots_per_row,
                 subplot_titles=list(categories.keys()),
             )
 
         for row_ind, (results) in enumerate(categories.values(), 1):
-            for result, colour in zip(results, plotly_colours):
+            for result, colour in zip(results, colours):
                 if result.params is not None:
-                    plotlyfig = cls._add_residual_traces_to_fig(
-                        plotlyfig, result, n_plots_per_row, colour, row_ind
+                    fig = cls._add_residual_traces(
+                        fig, result, n_plots_per_row, colour, row_ind
                     )
-                    plotlyfig.update_layout(title=title + ": residuals")
-
-                if result.plot_scale in ["loglog", "logx"]:
-                    plotlyfig.update_xaxes(type="log")
-                if result.plot_scale in ["loglog", "logy"]:
-                    plotlyfig.update_yaxes(type="log")
+                cls._update_to_logscale_if_needed(fig, result)
 
             if row_ind == 1:
-                plotlyfig.update_traces(row=row_ind)
+                fig.update_traces(row=row_ind)
+
+        fig.update_layout(title=title + ": residuals")
 
         html_fname = f"residuals_plot_for_{first_result.sanitised_name}.html"
         cls.write_html_with_link_plotlyjs(
-            plotlyfig, figures_dir, html_fname, options
+            fig, figures_dir, html_fname, options
         )
-
         return html_fname
 
     @classmethod
-    def _create_empty_residuals_plot_spinw(cls, categories, n_plots_per_row):
+    def _create_empty_residuals_plots(
+        cls, categories, subplot_titles
+    ) -> go.Figure:
         """
         Creates the initially empty residuals plot for spinw problems.
 
         :param categories: The results to plot
         :type categories: dict[str, list[FittingResults]]
-        :param n_plots_per_row: Number of subplots in each row
-        :type n_plots_per_row: int
+        :param subplot_titles: Subplot titles
+        :type subplot_titles: list
 
         :return: The produced figure
         :rtype: plotly.graph_objects.Figure
         """
-        first_result = next(iter(categories.values()))[0]
-
-        subplot_titles = [
-            f"{i} Å<sup>-1</sup>"
-            for i in first_result.spinw_plot_info["q_cens"]
-        ]
         row_titles = list(categories.keys())
 
-        plotlyfig = make_subplots(
+        fig = make_subplots(
             rows=len(categories),
-            cols=n_plots_per_row,
+            cols=len(subplot_titles),
             row_titles=row_titles,
             subplot_titles=subplot_titles * len(categories),
         )
 
-        data_len = int(len(first_result.data_y) / n_plots_per_row)
-        if data_len != len(first_result.spinw_plot_info["ebin_cens"]):
-            raise PlottingError("x and y data lengths are not the same")
-
         # Place name of cost function on left hand side of figure
-        plotlyfig.for_each_annotation(
+        fig.for_each_annotation(
             lambda a: a.update(x=-0.08, textangle=-90)
             if a.text in row_titles
             else ()
         )
-        return plotlyfig
+        return fig
+
+    def _add_starting_guess(
+        self, fig, df_fit, n_plots_per_row, ax_titles
+    ) -> go.Figure:
+        """
+        Adds starting guess to figure.
+
+        :param fig: The plotly figure to add the traces to
+        :type fig: plotly.graph_objects.Figure
+        :param df_fit: The dataframe with the data to plot
+        :type df_fit: Pandas dataframe
+        :param n_plots_per_row: number of subplots per row
+        :type n_plots_per_row: int
+        :param ax_titles: Titles for axes
+        :type ax_titles: dict[str, str]
+
+        :return: Updated plot
+        :rtype: plotly.graph_objects.Figure
+        """
+        data_len = int(
+            len(df_fit["y"][df_fit["minimizer"] == "Starting Guess"])
+            / n_plots_per_row
+        )
+        for i in range(n_plots_per_row):
+            fig.add_trace(
+                go.Scatter(
+                    x=df_fit["x"][df_fit["minimizer"] == "Starting Guess"],
+                    y=df_fit["y"][df_fit["minimizer"] == "Starting Guess"][
+                        (data_len * i) : (data_len * (i + 1))
+                    ],
+                    name="Starting Guess",
+                    line=self._starting_guess_plot_line,
+                    showlegend=i == 0,
+                    legendgroup="starting-guess",
+                    mode="markers+lines",
+                ),
+                row=1,
+                col=i + 1,
+            )
+            self._update_axes_titles(fig, i, ax_titles)
+
+        return fig
 
     @classmethod
-    def _add_residual_traces_to_fig(
-        cls, plotlyfig, result, n_plots_per_row, colour, row_ind
-    ):
+    def _add_residual_traces(
+        cls, fig, result, n_plots_per_row, colour, row_ind
+    ) -> go.Figure:
         """
         Adds traces to the empty residuals plot figure.
 
-        :param plotlyfig: The plotly figure to add the traces to
-        :type plotlyfig: plotly.graph_objects.Figure
+        :param fig: The plotly figure to add the traces to
+        :type fig: plotly.graph_objects.Figure
         :param result: Result we want to add the trace for
         :type result: FittingResult
         :param n_plots_per_row: number of subplots per row
@@ -657,37 +618,88 @@ class Plot:
         :rtype: plotly.graph_objects.Figure
         """
         minim = result.minimizer
-
         label = f" {minim}"
-        if n_plots_per_row > 1:
-            data_len = int(len(result.data_y) / n_plots_per_row)
-            for i in range(n_plots_per_row):
-                plotlyfig.add_trace(
-                    go.Scatter(
-                        x=result.spinw_plot_info["ebin_cens"],
-                        y=result.r_x[(data_len * i) : (data_len * (i + 1))],
-                        mode="markers",
-                        name=label,
-                        marker={"color": colour},
-                        showlegend=(i == 0 and row_ind == 1),
-                        legendgroup=f"group{minim}",
-                    ),
-                    row=row_ind,
-                    col=i + 1,
-                )
-        else:
-            plotlyfig.add_trace(
+        data_len = int(len(result.data_y) / n_plots_per_row)
+
+        for i in range(n_plots_per_row):
+            fig.add_trace(
                 go.Scatter(
-                    x=result.data_x[result.sorted_index],
-                    y=result.r_x[result.sorted_index],
+                    x=result.data_x,
+                    y=result.r_x[(data_len * i) : (data_len * (i + 1))],
                     mode="markers",
                     name=label,
                     marker={"color": colour},
-                    showlegend=row_ind == 1,
+                    showlegend=(i == 0 and row_ind == 1),
                     legendgroup=f"group{minim}",
                 ),
                 row=row_ind,
-                col=1,
+                col=i + 1,
             )
 
-        return plotlyfig
+        return fig
+
+    def _check_data_len(self, x_data, y_data) -> None:
+        """
+        Checks x and y data have same length and raises error if not.
+        """
+        if len(y_data) != len(x_data):
+            raise PlottingError("x and y data lengths are not the same")
+
+    @classmethod
+    def _update_axes_titles(cls, fig, col_ind, ax_titles) -> go.Figure:
+        """
+        Sets the titles of x and y axes.
+        For space reasons, only sets the y axis title of the plot on the very
+        left, as this will be the same in the other plots.
+
+        :param fig: The plotly figure to add the traces to
+        :type fig: plotly.graph_objects.Figure
+        :param col_ind: Index of the colum (subplot) we are adding ax titles to
+        :type col_ind: int
+        :param ax_titles: Titles for each axis
+        :type ax_titles: dict[str, str]
+
+        :return: Updated plot
+        :rtype: plotly.graph_objects.Figure
+        """
+        if col_ind == 0:
+            fig.update_yaxes(title_text=ax_titles["y"], row=1, col=col_ind + 1)
+        fig.update_xaxes(title_text=ax_titles["x"], row=1, col=col_ind + 1)
+        return fig
+
+    @classmethod
+    def _update_to_logscale_if_needed(self, fig, result) -> go.Figure:
+        """
+        Updates logscale to log if this is specified in result.plot_scale .
+        """
+        if result.plot_scale in ["loglog", "logx"]:
+            fig.update_xaxes(type="log")
+        if result.plot_scale in ["loglog", "logy"]:
+            fig.update_yaxes(type="log")
+        return fig
+
+    @classmethod
+    def _sample_colours(cls, points) -> list[str]:
+        """
+        Samples plotly colours based on values passed as input
+        """
+        plotly_colours = ptly_colors.sample_colorscale(
+            ptly_colors.sequential.Rainbow, samplepoints=points
+        )
+        return plotly_colours
+
+    @classmethod
+    def _set_n_plots_and_titles(cls, result):
+        """
+        Returns n_plots, subplot_titles, ax_titles
+        """
+        n_plots = 1
+        subplot_titles = None
+        ax_titles = cls._default_ax_titles
+
+        if result.plot_info is not None:
+            n_plots = result.plot_info["n_plots"]
+            subplot_titles = result.plot_info["subplot_titles"]
+            ax_titles = result.plot_info["ax_titles"]
+
+        return n_plots, subplot_titles, ax_titles
diff --git a/fitbenchmarking/utils/checkpoint.py b/fitbenchmarking/utils/checkpoint.py
index ea9e6174..5c9b2f31 100644
--- a/fitbenchmarking/utils/checkpoint.py
+++ b/fitbenchmarking/utils/checkpoint.py
@@ -110,7 +110,7 @@ class Checkpoint:
             "func_evals": result.func_evals,
             "flag": result.error_flag,
             "params_pdfs": result.params_pdfs,
-            "spinw_plot_info": result.spinw_plot_info,
+            "plot_info": result.plot_info,
             "software": result.software,
             "minimizer": result.minimizer,
             "jacobian": result.jac,
@@ -333,7 +333,7 @@ class Checkpoint:
                 new_result.func_evals = r["func_evals"]
                 new_result.error_flag = r["flag"]
                 new_result.params_pdfs = r["params_pdfs"]
-                new_result.spinw_plot_info = r["spinw_plot_info"]
+                new_result.plot_info = r["plot_info"]
                 new_result.software = r["software"]
                 new_result.minimizer = r["minimizer"]
                 new_result.jac = r["jacobian"]
diff --git a/fitbenchmarking/utils/fitbm_result.py b/fitbenchmarking/utils/fitbm_result.py
index b133dd2c..16f97c04 100644
--- a/fitbenchmarking/utils/fitbm_result.py
+++ b/fitbenchmarking/utils/fitbm_result.py
@@ -83,6 +83,13 @@ class FittingResult:
             self.params = controller.final_params[dataset]
             self.accuracy = accuracy[dataset]
 
+        # if SpinW, make sure data_x is correct
+        if "ebin_cens" in problem.additional_info:
+            n_plots = problem.additional_info["n_plots"]
+            self.data_x = np.array(
+                n_plots * problem.additional_info["ebin_cens"].tolist()
+            )
+
         self.runtimes = runtimes if isinstance(runtimes, list) else [runtimes]
         self.runtime_metric = runtime_metric
         self.energy = energy
@@ -94,14 +101,14 @@ class FittingResult:
 
         # Additional plotting info for SpinW powder plots
         if "plot_type" in problem.additional_info:
-            self.spinw_plot_info = {
+            self.plot_info = {
                 "plot_type": problem.additional_info["plot_type"],
-                "n_cuts": problem.additional_info["n_cuts"],
-                "q_cens": problem.additional_info["q_cens"],
-                "ebin_cens": problem.additional_info["ebin_cens"].tolist(),
+                "n_plots": problem.additional_info["n_plots"],
+                "subplot_titles": problem.additional_info["subplot_titles"],
+                "ax_titles": problem.additional_info["ax_titles"],
             }
         else:
-            self.spinw_plot_info = None
+            self.plot_info = None
 
         # Details of options used for this run
         self.software = controller.software


Test patch:
diff --git a/fitbenchmarking/results_processing/tests/test_plots.py b/fitbenchmarking/results_processing/tests/test_plots.py
index 42d64dd2..88a24368 100644
--- a/fitbenchmarking/results_processing/tests/test_plots.py
+++ b/fitbenchmarking/results_processing/tests/test_plots.py
@@ -177,60 +177,59 @@ class PlotTests(unittest.TestCase):
             self.assertTrue(os.path.exists(path))
             self.assertEqual(find_error_bar_count(path), 2)
 
-    @mock.patch("fitbenchmarking.results_processing.plots.Plot.plotly_spinw")
-    def test_plotly_spinw_called(self, spinw_plot):
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._check_data_len"
+    )
+    def test__check_data_len_called_by_plotly_fit(self, check_data_len):
         """
-        Test that plotly_fit calls plotly_spinw
-        when spinw_plot_info is set.
+        Test that plotly_fit calls _check_data_len
+        when plot_info is set.
         """
-        self.plot.result.spinw_plot_info = {
+        self.plot.result.plot_info = {
             "plot_type": "1d_cuts",
-            "n_cuts": 2,
-            "q_cens": [0.5, 1],
-            "ebin_cens": [0.9, 1.6, 1.1],
+            "n_plots": 3,
+            "subplot_titles": [
+                "0.5 Å<sup>-1</sup>",
+                "1 Å<sup>-1</sup>",
+                "1.5 Å<sup>-1</sup>",
+            ],
+            "ebin_cens": [0.9],
+            "ax_titles": {"x": "x", "y": "y"},
         }
 
-        self.plot.plot_initial_guess(self.df[("Fake_Test_Data", "prob_1")])
-        spinw_plot.assert_called()
-
         self.plot.plotly_fit(self.df[("Fake_Test_Data", "prob_1")])
-        spinw_plot.assert_called()
+        check_data_len.assert_called()
 
-    @mock.patch("fitbenchmarking.results_processing.plots.Plot.plotly_spinw")
-    def test_plotly_spinw_fit_not_called(self, spinw_plot):
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._check_data_len"
+    )
+    def test__check_data_len_not_called(self, check_data_len):
         """
-        Test that plotly_fit doesn't call plotly_spinw
-        when spinw_plot_info is not set.
+        Test that plotly_fit and plots_residuals don't call
+        _check_data_len when plot_info is not set.
         """
-        self.plot.plot_initial_guess(self.df[("Fake_Test_Data", "prob_1")])
-        spinw_plot.assert_not_called()
-
         self.plot.plotly_fit(self.df[("Fake_Test_Data", "prob_1")])
-        spinw_plot.assert_not_called()
+        check_data_len.assert_not_called()
+
+        self.plot.plot_residuals(
+            categories=self.fr,
+            title="",
+            options=self.opts,
+            figures_dir=self.figures_dir,
+        )
+        check_data_len.assert_not_called()
 
-    def test_plotly_spinw(self):
+    def test__check_data_len_raises_error(self):
         """
-        Test that plotly_spinw creates correct number of subplots
-        and raises error if data lengths are unexpected
+        Test _check_data_len raises error if data lengths are
+        unexpected
         """
-        self.plot.result.spinw_plot_info = {
-            "plot_type": "1d_cuts",
-            "n_cuts": 3,
-            "q_cens": [0.5, 1, 1.5],
-            "ebin_cens": [0.9],
-        }
         data = self.df[("Fake_Test_Data", "prob_1")]
         y_best = data["y"][data["best"]]
-
-        fig = self.plot.plotly_spinw(data, "m10_[s1]_jj0", y_best)
-        _, cols = fig._get_subplot_rows_columns()
-
-        assert len(cols) == self.plot.result.spinw_plot_info["n_cuts"]
-
-        self.plot.result.spinw_plot_info["ebin_cens"] = [2, 4]
+        x_best = 10 * data["x"][data["best"]].to_list()
 
         with self.assertRaises(PlottingError):
-            self.plot.plotly_spinw(data, "m10_[s1]_jj0", y_best)
+            self.plot._check_data_len(y_best, x_best)
 
     def test_plot_posteriors_create_files(self):
         """
@@ -292,7 +291,6 @@ class PlotTests(unittest.TestCase):
         """
         Test that plot_residuals creates a file
         """
-
         file_name = self.plot.plot_residuals(
             categories=self.fr,
             title="",
@@ -304,35 +302,169 @@ class PlotTests(unittest.TestCase):
         path = os.path.join(self.figures_dir, file_name)
         self.assertTrue(os.path.exists(path))
 
-    def test_create_empty_residuals_plot_spinw(self):
+    def test__create_empty_residuals_plots(self):
         """
         Test that create_empty_residuals_plot_spinw creates correct
-        number of subplots and raises error if data lengths are unexpected
+        number of subplots
         """
         result = next(iter(self.fr.values()))[0]
-        result.spinw_plot_info = {
+        result.plot_info = {
             "plot_type": "1d_cuts",
-            "n_cuts": 3,
-            "q_cens": [0.5, 1, 1.5],
+            "n_plots": 3,
+            "subplot_titles": [
+                "0.5 Å<sup>-1</sup>",
+                "1 Å<sup>-1</sup>",
+                "1.5 Å<sup>-1</sup>",
+            ],
             "ebin_cens": [0.9],
         }
+        titles = 3 * ["Q_value"]
 
-        fig = self.plot._create_empty_residuals_plot_spinw(
+        fig = self.plot._create_empty_residuals_plots(
             categories=self.fr,
-            n_plots_per_row=result.spinw_plot_info["n_cuts"],
+            subplot_titles=titles,
         )
         rows, cols = fig._get_subplot_rows_columns()
 
         assert len(rows) == len(self.fr.keys())
-        assert len(cols) == result.spinw_plot_info["n_cuts"]
+        assert len(cols) == result.plot_info["n_plots"]
 
-        result.spinw_plot_info["ebin_cens"] = [2, 4]
+        result.plot_info["ebin_cens"] = [2, 4]
 
-        with self.assertRaises(PlottingError):
-            self.plot._create_empty_residuals_plot_spinw(
-                categories=self.fr,
-                n_plots_per_row=result.spinw_plot_info["n_cuts"],
-            )
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._create_empty_residuals_plots"
+    )
+    def test__create_empty_residuals_plots_not_called_when_no_subplots(
+        self, create_empty_residuals
+    ):
+        """
+        Test that _create_empty_residuals is not called when there is only
+        one plot (no subplots).
+        """
+        self.plot.plot_residuals(
+            categories=self.fr,
+            title="",
+            options=self.opts,
+            figures_dir=self.figures_dir,
+        )
+        create_empty_residuals.assert_not_called()
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._add_residual_traces"
+    )
+    def test__add_residual_traces_called_by_plot_residuals(
+        self, add_residual_traces
+    ):
+        """
+        Test that _add_residual_traces gets called by plot_residuals
+        the right number of times (based on number of categories and results).
+        """
+        self.plot.plot_residuals(
+            categories=self.fr,
+            title="",
+            options=self.opts,
+            figures_dir=self.figures_dir,
+        )
+        expected = len(self.fr["Fake_Test_Data"])
+        self.assertEqual(add_residual_traces.call_count, expected)
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._plot_minimizer_results"
+    )
+    def test__plot_minimizer_results_called_by_plot_summary(
+        self, plot_minimizer_results
+    ):
+        """
+        Test that _plot_minimizer_results gets called by plot_summary.
+        """
+        self.plot.plot_summary(
+            categories=self.fr,
+            title="",
+            options=self.opts,
+            figures_dir=self.figures_dir,
+        )
+        expected = len(self.fr["Fake_Test_Data"])
+        self.assertEqual(plot_minimizer_results.call_count, expected)
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._add_data_points"
+    )
+    def test__add_data_points_called_by_plot_summary(self, add_data_points):
+        """
+        Test that _add_data_points gets called once by plot_summary.
+        """
+        self.plot.plot_summary(
+            categories=self.fr,
+            title="",
+            options=self.opts,
+            figures_dir=self.figures_dir,
+        )
+        add_data_points.assert_called_once()
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._add_data_points"
+    )
+    def test__add_data_points_called_by_plot_initial_guess(
+        self, add_data_points
+    ):
+        """
+        Test that _add_data_points gets called once by
+        plot_initial_guess.
+        """
+        self.plot.plot_initial_guess(self.df[("Fake_Test_Data", "prob_1")])
+        add_data_points.assert_called_once()
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._add_data_points"
+    )
+    def test__add_data_points_called_by_plotly_fit(self, add_data_points):
+        """
+        Test that _add_data_points gets called the right number of times
+        by plotly_fit.
+        """
+        input_df = self.df[("Fake_Test_Data", "prob_1")]
+        self.plot.plotly_fit(input_df)
+        minimizers = input_df["minimizer"][
+            ~input_df.minimizer.isin(["Data", "Starting Guess"])
+        ].unique()
+        self.assertEqual(add_data_points.call_count, len(minimizers))
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._add_starting_guess"
+    )
+    def test__add_starting_guess_called_by_plot_initial_guess(
+        self, add_starting_guess
+    ):
+        """
+        Test that _add_starting_guess gets called by plot_initial_guess.
+        """
+        self.plot.plot_initial_guess(self.df[("Fake_Test_Data", "prob_1")])
+        add_starting_guess.assert_called_once()
+
+    @mock.patch(
+        "fitbenchmarking.results_processing.plots.Plot._add_starting_guess"
+    )
+    def test__add_starting_guess_called_by_plotly_fit(
+        self, add_starting_guess
+    ):
+        """
+        Test that _add_starting_guess gets called by plotly_fit, the
+        correct number of times.
+        """
+        input_df = self.df[("Fake_Test_Data", "prob_1")]
+        self.plot.plotly_fit(input_df)
+        minimizers = input_df["minimizer"][
+            ~input_df.minimizer.isin(["Data", "Starting Guess"])
+        ].unique()
+        self.assertEqual(add_starting_guess.call_count, len(minimizers))
+
+    def test__sample_colours(self):
+        """
+        Test that _sample_colours produces the expected output.
+        """
+        exp_colours = ["rgb(9, 173, 234)", "rgb(213, 242, 0)"]
+        colours = self.plot._sample_colours([0.4, 0.7])
+        self.assertEqual(exp_colours, colours)
 
 
 if __name__ == "__main__":
diff --git a/fitbenchmarking/test_files/checkpoint.json b/fitbenchmarking/test_files/checkpoint.json
index 9df3aa26..6b1bfadf 100644
--- a/fitbenchmarking/test_files/checkpoint.json
+++ b/fitbenchmarking/test_files/checkpoint.json
@@ -52,7 +52,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m10",
         "jacobian": "j0",
@@ -84,7 +84,7 @@
         "func_evals": 15,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m11",
         "jacobian": "j0",
@@ -116,7 +116,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m01",
         "jacobian": "j0",
@@ -146,7 +146,7 @@
         "func_evals": null,
         "flag": 4,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m00",
         "jacobian": null,
@@ -178,7 +178,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m10",
         "jacobian": "j1",
@@ -210,7 +210,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m11",
         "jacobian": "j1",
@@ -240,7 +240,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m01",
         "jacobian": "j1",
@@ -270,7 +270,7 @@
         "func_evals": 15,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m10",
         "jacobian": "j0",
@@ -300,7 +300,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m11",
         "jacobian": "j0",
@@ -331,7 +331,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m01",
         "jacobian": "j0",
@@ -361,7 +361,7 @@
         "func_evals": null,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m00",
         "jacobian": "j0",
@@ -393,7 +393,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m10",
         "jacobian": "j1",
@@ -425,7 +425,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s1",
         "minimizer": "m11",
         "jacobian": "j1",
@@ -457,7 +457,7 @@
         "func_evals": 15,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m01",
         "jacobian": "j1",
@@ -487,7 +487,7 @@
         "func_evals": null,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "s0",
         "minimizer": "m00",
         "jacobian": "j1",
diff --git a/fitbenchmarking/test_files/regression_checkpoint.json b/fitbenchmarking/test_files/regression_checkpoint.json
index 34a6d8d3..6d09cf8d 100644
--- a/fitbenchmarking/test_files/regression_checkpoint.json
+++ b/fitbenchmarking/test_files/regression_checkpoint.json
@@ -310,7 +310,7 @@
         "func_evals": 23,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -344,7 +344,7 @@
         "func_evals": 22,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -378,7 +378,7 @@
         "func_evals": 22,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -412,7 +412,7 @@
         "func_evals": 22,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -446,7 +446,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -480,7 +480,7 @@
         "func_evals": 20,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -514,7 +514,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -548,7 +548,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -582,7 +582,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -616,7 +616,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -650,7 +650,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -684,7 +684,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -718,7 +718,7 @@
         "func_evals": 46,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -752,7 +752,7 @@
         "func_evals": 43,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -786,7 +786,7 @@
         "func_evals": 19,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -820,7 +820,7 @@
         "func_evals": 12,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -854,7 +854,7 @@
         "func_evals": 12,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -888,7 +888,7 @@
         "func_evals": 12,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -922,7 +922,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -956,7 +956,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -990,7 +990,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1024,7 +1024,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1058,7 +1058,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1092,7 +1092,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1126,7 +1126,7 @@
         "func_evals": 109,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1160,7 +1160,7 @@
         "func_evals": 78,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1194,7 +1194,7 @@
         "func_evals": 46,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1228,7 +1228,7 @@
         "func_evals": 9,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1262,7 +1262,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1296,7 +1296,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1330,7 +1330,7 @@
         "func_evals": 104,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1364,7 +1364,7 @@
         "func_evals": 80,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1398,7 +1398,7 @@
         "func_evals": 21,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1432,7 +1432,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1466,7 +1466,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1500,7 +1500,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1534,7 +1534,7 @@
         "func_evals": 500,
         "flag": 1,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1568,7 +1568,7 @@
         "func_evals": 500,
         "flag": 1,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1602,7 +1602,7 @@
         "func_evals": 12,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1636,7 +1636,7 @@
         "func_evals": 18,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1670,7 +1670,7 @@
         "func_evals": 21,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1704,7 +1704,7 @@
         "func_evals": 10,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1738,7 +1738,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1772,7 +1772,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1806,7 +1806,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1840,7 +1840,7 @@
         "func_evals": 5,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1874,7 +1874,7 @@
         "func_evals": 5,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -1908,7 +1908,7 @@
         "func_evals": 5,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -1942,7 +1942,7 @@
         "func_evals": 7,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -1976,7 +1976,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -2010,7 +2010,7 @@
         "func_evals": 8,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",
@@ -2044,7 +2044,7 @@
         "func_evals": 4,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "lm-scipy",
         "jacobian": "best_available",
@@ -2078,7 +2078,7 @@
         "func_evals": 4,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "trf",
         "jacobian": "best_available",
@@ -2112,7 +2112,7 @@
         "func_evals": 4,
         "flag": 0,
         "params_pdfs": null,
-        "spinw_plot_info": null,
+        "plot_info": null,
         "software": "scipy_ls",
         "minimizer": "dogbox",
         "jacobian": "best_available",