From 3e8030e7d16f1131687ce9845a30a13e76eddf8b Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Mon, 17 Jan 2022 14:55:02 +0000 Subject: [PATCH 1/8] comment is just comment body, scpicommand convenience --- src/qslib/protocol.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/qslib/protocol.py b/src/qslib/protocol.py index f149ffc..4aa30e6 100644 --- a/src/qslib/protocol.py +++ b/src/qslib/protocol.py @@ -955,7 +955,7 @@ def stepped_ramp( ) @classmethod - def hold_for( + def hold_at( cls: Type[Stage], temperature: float | str | Sequence[float], total_time: int | str | pint.Quantity[int], @@ -1029,6 +1029,8 @@ def hold_for( repeat=repeat, ) + hold_for = hold_at + def __repr__(self) -> str: s = f"Stage(steps=" if len(self.steps) == 0: From c30bae8bf682e88c26151a2cb5bf2a6e4e66d37f Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Mon, 17 Jan 2022 15:01:40 +0000 Subject: [PATCH 2/8] start splitting out temperature plot --- src/qslib/experiment.py | 42 +++++++++++++++++++++++----------------- tests/test_experiment.py | 11 +++++++++++ 2 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 tests/test_experiment.py diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index fb09e75..79ad8d0 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -1756,7 +1756,7 @@ def plot_over_time( fig = None if ax is None: - if temperatures: + if temperatures == "axes": fig, ax = plt.subplots( 2, 1, @@ -1814,32 +1814,22 @@ def plot_over_time( raise ValueError("Temperature axes requires at least two axes in ax") xlims = ax[0].get_xlim() - tmin, tmax = np.inf, 0.0 + for i, fr in reduceddata.groupby("filter_set", as_index=False): d = fr.loc[i, ("time", "hours")].loc[stages] tmin = min(tmin, d.min()) tmax = max(tmax, d.max()) - assert self.temperatures is not None - - reltemps = self.temperatures.loc[ - lambda x: (tmin <= x[("time", "hours")]) + self.plot_temperatures( + sel=lambda x: (tmin <= x[("time", "hours")]) & (x[("time", "hours")] <= tmax), - :, - ] - - for x in range(1, 7): - ax[1].plot( - reltemps.loc[:, ("time", "hours")], - reltemps.loc[:, ("sample", x)], - ) + ax=ax[1], + ) ax[0].set_xlim(xlims) ax[1].set_xlim(xlims) - ax[1].set_ylabel("temperature (°C)") - ax[0].set_title(_gen_axtitle(self.name, stages, samples, all_wells, filters)) return ax @@ -1851,9 +1841,25 @@ def plot_protocol( return self.protocol.plot_protocol(ax) - def plot_temperatures(self): + def plot_temperatures(self, sel=None, ax: Optional[plt.Axes] = None) -> plt.Axes: """To be implemented.""" - raise NotImplemented + if self.temperatures is None: + raise ValueError("Experiment has no temperature data.") + + if ax is None: + _, ax = plt.subplots() + + reltemps = self.temperatures.loc[sel, :] + + for x in range(1, 7): + ax.plot( + reltemps.loc[:, ("time", "hours")], + reltemps.loc[:, ("sample", x)], + ) + + ax.set_ylabel("temperature (°C)") + + return ax def _normalize_filters( diff --git a/tests/test_experiment.py b/tests/test_experiment.py new file mode 100644 index 0000000..6778205 --- /dev/null +++ b/tests/test_experiment.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2021-2022 Constantine Evans +# SPDX-License-Identifier: AGPL-3.0-only + +import pytest + +from qslib import * + + +def test_create(): + + exp = Experiment(protocol=Protocol([Stage([Step(30, 25)])])) From bd23a51b93840b253919513666f44fbac125c13d Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 20 Jan 2022 00:32:48 +0000 Subject: [PATCH 3/8] plot improvements, stagelines, ylabel, regex --- src/qslib/experiment.py | 226 ++++++++++++++++++++++++++++--------- src/qslib/normalization.py | 11 ++ 2 files changed, 183 insertions(+), 54 deletions(-) diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index 79ad8d0..b1b2448 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -1329,33 +1329,53 @@ def _update_from_log(self) -> None: self.runstate = "INIT" m: Optional[re.Match[str]] + stages = [] for m in ms: ts = datetime.fromtimestamp(float(m["ts"])) if m["msg"] == "Starting": self.runstarttime = ts - elif (m["msg"] == "Stage") and (m["ext"] == "PRERUN"): - self.prerunstart = ts - self.runstate = "RUNNING" - elif ( - (m["msg"] == "Stage") - and (self.activestarttime is None) - and (self.prerunstart is not None) - ): - self.activestarttime = ts - elif (m["msg"] == "Stage") and (m["ext"] == "POSTRun"): - self.activeendtime = ts + elif m["msg"] == "Stage": + if m["ext"] == "PRERUN": + self.prerunstart = ts + self.runstate = "RUNNING" + elif (self.activestarttime is None) and (self.prerunstart is not None): + self.activestarttime = ts + elif m["ext"] == "POSTRun": + self.activeendtime = ts + try: + stages.append([int(m["msg"]), ts]) + except ValueError: + stages.append([m["ext"], ts]) + if len(stages) > 1: + stages[-2].append(ts) elif m["msg"] == "Ended": self.runendtime = ts self.runstate = "COMPLETE" + if len(stages) > 1: + stages[-1].append(ts) elif m["msg"] == "Aborted": self.runstate = "ABORTED" self.activeendtime = ts self.runendtime = ts + if len(stages) > 1: + stages[-1].append(ts) elif m["msg"] == "Stopped": self.runstate = "STOPPED" self.activeendtime = ts self.runendtime = ts + if len(stages) > 1: + stages[-1].append(ts) + + self.stages = pd.DataFrame(stages, columns=["stage", "start_time", "end_time"]) + + if self.activestarttime: + self.stages["start_seconds"] = ( + self.stages["start_time"] - self.activestarttime + ).astype("timedelta64[s]") + self.stages["end_seconds"] = ( + self.stages["end_time"] - self.activestarttime + ).astype("timedelta64[s]") tt = [] @@ -1483,7 +1503,7 @@ def plot_anneal_melt( normalization: Normalizer = NormRaw(), ax: "plt.Axes" | None = None, marker: str | None = None, - legend: bool = True, + legend: bool | Literal["inset", "right"] = True, figure_kw: Mapping[str, Any] | None = None, line_kw: Mapping[str, Any] | None = None, ) -> "plt.Axes": @@ -1555,7 +1575,14 @@ def plot_anneal_melt( filters = self.all_filters if isinstance(samples, str): - samples = [samples] + if samples in self.plate_setup.sample_wells: + samples = [samples] + else: + samples = [ + k for k in self.plate_setup.sample_wells if re.match(samples, k) + ] + if not samples: + raise ValueError(f"Samples not found") elif samples is None: samples = list(self.plate_setup.sample_wells.keys()) @@ -1585,7 +1612,12 @@ def plot_anneal_melt( if ax is None: ax = cast( plt.Axes, - plt.figure(**({} if figure_kw is None else figure_kw)).add_subplot(), + plt.figure( + **( + {"constrained_layout": True} + | (({} if figure_kw is None else figure_kw)) + ) + ).add_subplot(), ) data = normalization.normalize_scoped(self.welldata, "all") @@ -1605,6 +1637,10 @@ def plot_anneal_melt( if len(between_stages) > 0: betweendat: pd.DataFrame = filterdat.loc[between_stages, :] # type: ignore + anneallines = [] + meltlines = [] + betweenlines = [] + for sample in samples: wells = self.plate_setup.get_wells(sample) @@ -1613,41 +1649,54 @@ def plot_anneal_melt( label = _gen_label(sample, well, filter, samples, wells, filters) - anneallines = ax.plot( - annealdat.loc[:, (well, "st")], - annealdat.loc[:, (well, "fl")], - color=color, - label=label, - marker=marker, - **(line_kw if line_kw is not None else {}), - ) - - meltlines = ax.plot( - meltdat.loc[:, (well, "st")], - meltdat.loc[:, (well, "fl")], - color=color, - linestyle="dashed", - marker=marker, - **(line_kw if line_kw is not None else {}), + anneallines.append( + ax.plot( + annealdat.loc[:, (well, "st")], + annealdat.loc[:, (well, "fl")], + color=color, + label=label, + marker=marker, + **(line_kw if line_kw is not None else {}), + ) ) - if len(between_stages) > 0: - betweenlines = ax.plot( - betweendat.loc[:, (well, "st")], - betweendat.loc[:, (well, "fl")], + meltlines.append( + ax.plot( + meltdat.loc[:, (well, "st")], + meltdat.loc[:, (well, "fl")], color=color, - linestyle="dotted", + linestyle="dashed", marker=marker, **(line_kw if line_kw is not None else {}), ) + ) + + if len(between_stages) > 0: + betweenlines.append( + ax.plot( + betweendat.loc[:, (well, "st")], + betweendat.loc[:, (well, "fl")], + color=color, + linestyle="dotted", + marker=marker, + **(line_kw if line_kw is not None else {}), + ) + ) ax.set_xlabel("temperature (°C)") - # FIXME: consider normalization - ax.set_ylabel("fluorescence") + ax.set_ylabel(normalization.ylabel) - if legend: - ax.legend() + if legend is True: + if len(anneallines) < 6: + legend = "inset" + else: + legend = "right" + + if legend == "inset": + ax[0].legend() + elif legend == "right": + ax[0].legend(bbox_to_anchor=(1.04, 1), loc="upper left") ax.set_title( _gen_axtitle( @@ -1668,9 +1717,11 @@ def plot_over_time( stages: slice | int | Sequence[int] = slice(None), normalization: Normalizer = NormRaw(), ax: "plt.Axes" | "Sequence[plt.Axes]" | None = None, - legend: bool = True, - temperatures: Literal[False, "axes", "inset", "twin"] = False, + legend: bool | Literal["inset", "right"] = True, + temperatures: Literal[False, "axes", "inset", "twin"] = "axes", marker: str | None = None, + stage_lines: bool = True, + annotate_stage_lines: bool | float = False, figure_kw: Mapping[str, Any] | None = None, line_kw: Mapping[str, Any] | None = None, ) -> "Sequence[plt.Axes]": @@ -1746,7 +1797,14 @@ def plot_over_time( filters = _normalize_filters(filters) if isinstance(samples, str): - samples = [samples] + if samples in self.plate_setup.sample_wells: + samples = [samples] + else: + samples = [ + k for k in self.plate_setup.sample_wells if re.match(samples, k) + ] + if not samples: + raise ValueError(f"Samples not found") elif samples is None: samples = list(self.plate_setup.sample_wells.keys()) @@ -1762,7 +1820,10 @@ def plot_over_time( 1, sharex="all", gridspec_kw={"height_ratios": [3, 1]}, - **({} if figure_kw is None else figure_kw), + **( + {"constrained_layout": True} + | (({} if figure_kw is None else figure_kw)) + ), ) else: fig, ax = plt.subplots(1, 1, **({} if figure_kw is None else figure_kw)) @@ -1781,6 +1842,7 @@ def plot_over_time( reduceddata = normalization.normalize_scoped(reduceddata, "limited") + lines = [] for filter in filters: filterdat: pd.DataFrame = reduceddata.loc[filter.lowerform, :] # type: ignore @@ -1792,22 +1854,37 @@ def plot_over_time( label = _gen_label(sample, well, filter, samples, wells, filters) - lines = ax[0].plot( - filterdat.loc[stages, ("time", "hours")], - filterdat.loc[stages, (well, "fl")], - color=color, - label=label, - marker=marker, - **(line_kw if line_kw is not None else {}), + lines.append( + ax[0].plot( + filterdat.loc[stages, ("time", "hours")], + filterdat.loc[stages, (well, "fl")], + color=color, + label=label, + marker=marker, + **(line_kw if line_kw is not None else {}), + ) ) ax[-1].set_xlabel("time (hours)") - # FIXME: consider normalization - ax[0].set_ylabel("fluorescence") + ax[0].set_ylabel(normalization.ylabel) + + if legend is True: + if len(lines) < 6: + legend = "inset" + else: + legend = "right" - if legend: + if legend == "inset": ax[0].legend() + elif legend == "right": + ax[0].legend(bbox_to_anchor=(1.04, 1), loc="upper left") + + xlims = ax[0].get_xlim() + + self._annotate_stages( + ax[0], stage_lines, annotate_stage_lines, (xlims[1] - xlims[0]) * 3600.0 + ) if temperatures == "axes": if len(ax) < 2: @@ -1841,8 +1918,16 @@ def plot_protocol( return self.protocol.plot_protocol(ax) - def plot_temperatures(self, sel=None, ax: Optional[plt.Axes] = None) -> plt.Axes: + def plot_temperatures( + self, + sel=slice(None), + ax: Optional[plt.Axes] = None, + stage_lines: bool = True, + annotate_stage_lines: bool | float = True, + ) -> plt.Axes: """To be implemented.""" + import matplotlib.pyplot as plt + if self.temperatures is None: raise ValueError("Experiment has no temperature data.") @@ -1857,10 +1942,43 @@ def plot_temperatures(self, sel=None, ax: Optional[plt.Axes] = None) -> plt.Axes reltemps.loc[:, ("sample", x)], ) + v = reltemps.loc[:, ("time", "hours")] + totseconds = 3600.0 * (v.iloc[-1] - v.iloc[0]) + + self._annotate_stages(ax, stage_lines, annotate_stage_lines, totseconds) + ax.set_ylabel("temperature (°C)") return ax + def _annotate_stages(self, ax, stage_lines, annotate_stage_lines, totseconds): + if stage_lines: + if isinstance(annotate_stage_lines, float): + annotate_frac = annotate_stage_lines + annotate_stage_lines = True + else: + annotate_frac = 0.05 + + for _, s in self.stages.iloc[1:-1].iterrows(): + xtrans = ax.get_xaxis_transform() + ax.axvline( + s.start_seconds / 3600.0, + linestyle="dotted", + color="black", + linewidth=0.5, + ) + durfrac = (s.end_seconds - s.start_seconds) / totseconds + if annotate_stage_lines and (durfrac > annotate_frac): + ax.text( + s.start_seconds / 3600.0 + 0.02, + 0.9, + f"stage {s.stage}", + transform=xtrans, + rotation=90, + verticalalignment="top", + horizontalalignment="left", + ) + def _normalize_filters( filters: str | FilterSet | Collection[str | FilterSet], diff --git a/src/qslib/normalization.py b/src/qslib/normalization.py index d25eea6..3db8b68 100644 --- a/src/qslib/normalization.py +++ b/src/qslib/normalization.py @@ -30,6 +30,11 @@ def normalize_scoped(self, data: pd.DataFrame, scope: ScopeType) -> pd.DataFrame def normalize(self, data: pd.DataFrame) -> pd.DataFrame: ... + @property + @abstractmethod + def ylabel(self) -> str: + ... + class NormRaw(Normalizer): """ @@ -43,6 +48,8 @@ def normalize_scoped(self, data: pd.DataFrame, scope: ScopeType) -> pd.DataFrame def normalize(self, data: pd.DataFrame) -> pd.DataFrame: return data + ylabel = "fluorescence" + @dataclass(init=False) class NormToMeanPerWell(Normalizer): @@ -115,6 +122,8 @@ def normalize(self, data: pd.DataFrame) -> pd.DataFrame: return normdata + ylabel = "norm. fluorescence" + @dataclass class NormToMaxPerWell(Normalizer): @@ -186,3 +195,5 @@ def normalize(self, data: pd.DataFrame) -> pd.DataFrame: normdata.loc[:, (slice(None), "fl")] /= means return normdata + + ylabel = "norm fluorescence" From e566df299340bb6cbf1aa5cd1c21fa5df48d4173 Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 20 Jan 2022 00:42:04 +0000 Subject: [PATCH 4/8] fix ax error in anneal --- src/qslib/experiment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index b1b2448..4518675 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -1694,9 +1694,9 @@ def plot_anneal_melt( legend = "right" if legend == "inset": - ax[0].legend() + ax.legend() elif legend == "right": - ax[0].legend(bbox_to_anchor=(1.04, 1), loc="upper left") + ax.legend(bbox_to_anchor=(1.04, 1), loc="upper left") ax.set_title( _gen_axtitle( From 1fbf509cb26049e75a45157b3cb06ea98be08ac1 Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 20 Jan 2022 00:48:42 +0000 Subject: [PATCH 5/8] don't match partial name for regex --- src/qslib/experiment.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index 4518675..b985040 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -1574,17 +1574,7 @@ def plot_anneal_melt( if filters is None: filters = self.all_filters - if isinstance(samples, str): - if samples in self.plate_setup.sample_wells: - samples = [samples] - else: - samples = [ - k for k in self.plate_setup.sample_wells if re.match(samples, k) - ] - if not samples: - raise ValueError(f"Samples not found") - elif samples is None: - samples = list(self.plate_setup.sample_wells.keys()) + samples = self._get_samples(samples) filters = _normalize_filters(filters) @@ -1710,6 +1700,22 @@ def plot_anneal_melt( return ax + def _get_samples(self, samples: str | Sequence[str] | None) -> Sequence[str]: + if isinstance(samples, str): + if samples in self.plate_setup.sample_wells: + samples = [samples] + else: + samples = [ + k + for k in self.plate_setup.sample_wells + if re.match(samples + "$", k) + ] + if not samples: + raise ValueError(f"Samples not found") + elif samples is None: + samples = list(self.plate_setup.sample_wells.keys()) + return samples + def plot_over_time( self, samples: str | Sequence[str] | None = None, @@ -1796,17 +1802,7 @@ def plot_over_time( filters = _normalize_filters(filters) - if isinstance(samples, str): - if samples in self.plate_setup.sample_wells: - samples = [samples] - else: - samples = [ - k for k in self.plate_setup.sample_wells if re.match(samples, k) - ] - if not samples: - raise ValueError(f"Samples not found") - elif samples is None: - samples = list(self.plate_setup.sample_wells.keys()) + samples = self._get_samples(samples) if isinstance(stages, int): stages = [stages] From 4d8d03d266d0e4048f1137203e9a2cc7023a074a Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 20 Jan 2022 13:35:46 +0000 Subject: [PATCH 6/8] documentation --- src/qslib/experiment.py | 174 +++++++++++++++++++++++++++++++++------- 1 file changed, 147 insertions(+), 27 deletions(-) diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index b985040..f069850 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -21,6 +21,7 @@ from typing import ( IO, Any, + Callable, Collection, List, Literal, @@ -1525,10 +1526,12 @@ def plot_anneal_melt( ---------- samples - Either a reference to a single sample (a string), or a list of sample names. - Well names may also be included, in which case each well will be treated without - regard to the sample name that may refer to it. Note this means you cannot - give your samples names that correspond with well references. + A reference to a single sample (a string), a list of sample names, or a Python + regular expression as a string, matching sample names (full start-to-end matches + only). Well names may also be included, in which case each well will be treated + without regard to the sample name that may refer to it. Note this means you cannot + give your samples names that correspond with well references. If not provided, + all (named) samples will be included. filters Optional. A filterset (string or `FilterSet`) or list of filtersets to include in the @@ -1545,14 +1548,20 @@ def plot_anneal_melt( Optional. A Normalizer instance to apply to the data. By default, this is NormRaw, which passes through raw fluorescence values. NormToMeanPerWell also works well. - marker - ax Optional. An axes to put the plot on. If not provided, the function will - create a new figure. + create a new figure, by default with constrained_layout=True, though this + can be modified with figure_kw. + + marker + The marker format for data points, or None for no markers (default). legend - Optional. Determines whether a legend is included. + Whether to add a legend. True (default) decides whether to have the legend + as an inset or to the right of the axes based on the number of lines. "inset" + and "right" specify the positioning. Note that for "right", you must use + some method to adjust the axes positioning: constrained_layout, tight_layout, + or manually reducing the axes width are all options. figure_kw Optional. A dictionary of options passed through as keyword options to @@ -1726,8 +1735,13 @@ def plot_over_time( legend: bool | Literal["inset", "right"] = True, temperatures: Literal[False, "axes", "inset", "twin"] = "axes", marker: str | None = None, - stage_lines: bool = True, - annotate_stage_lines: bool | float = False, + stage_lines: bool | Literal["fluorescence", "temperature"] = True, + annotate_stage_lines: ( + bool + | float + | Literal["fluorescence", "temperature"] + | Tuple[Literal["fluorescence", "temperature"], float] + ) = False, figure_kw: Mapping[str, Any] | None = None, line_kw: Mapping[str, Any] | None = None, ) -> "Sequence[plt.Axes]": @@ -1745,10 +1759,12 @@ def plot_over_time( ---------- samples - Either a reference to a single sample (a string), or a list of sample names. - Well names may also be included, in which case each well will be treated without - regard to the sample name that may refer to it. Note this means you cannot - give your samples names that correspond with well references. + A reference to a single sample (a string), a list of sample names, or a Python + regular expression as a string, matching sample names (full start-to-end matches + only). Well names may also be included, in which case each well will be treated + without regard to the sample name that may refer to it. Note this means you cannot + give your samples names that correspond with well references. If not provided, + all (named) samples will be included. filters Optional. A filterset (string or `FilterSet`) or list of filtersets to include in the @@ -1768,7 +1784,7 @@ def plot_over_time( passes through raw fluorescence values. NormToMeanPerWell also works well. temperatures - Optional (default False). Several alternatives for displaying temperatures. + Optional (default "axes"). Several alternatives for displaying temperatures. "axes" uses a separate axes (created if ax is not provided, otherwise ax must be a list of two axes). @@ -1779,12 +1795,29 @@ def plot_over_time( ax Optional. An axes to put the plot on. If not provided, the function will - create a new figure. If `temperatures="axes"`, however, you must provide + create a new figure, by default with constrained_layout=True, though this + can be modified with figure_kw. If `temperatures="axes"`, you must provide a list or tuple of *two* axes, the first for fluorescence, the second for temperature. + marker + The marker format for data points, or None for no markers (default). + legend - Optional. Determines whether a legend is included. + Whether to add a legend. True (default) decides whether to have the legend + as an inset or to the right of the axes based on the number of lines. "inset" + and "right" specify the positioning. Note that for "right", you must use + some method to adjust the axes positioning: constrained_layout, tight_layout, + or manually reducing the axes width are all options. + + stage_lines + Whether to include dotted vertical lines on transitions between stages. If + "fluorescence" or "temperature", include only on one of the two axes. + + annotate_stage_lines + Whether to include text annotations for stage lines. Float parameter allows + setting the minimum duration of stage, as a fraction of total plotted time, to + annotate, in order to avoid overlapping annotations (default threshold is 0.05). figure_kw Optional. A dictionary of options passed through as keyword options to @@ -1878,9 +1911,36 @@ def plot_over_time( xlims = ax[0].get_xlim() - self._annotate_stages( - ax[0], stage_lines, annotate_stage_lines, (xlims[1] - xlims[0]) * 3600.0 - ) + if isinstance(annotate_stage_lines, (tuple, list)): + if annotate_stage_lines[0] == "fluorescence": + fl_asl: bool | float = annotate_stage_lines[1] + t_asl: bool | float = False + elif annotate_stage_lines[0] == "temperature": + fl_asl = False + t_asl = annotate_stage_lines[1] + else: + raise ValueError + elif annotate_stage_lines == "temperature": + t_asl = True + fl_asl = False + elif annotate_stage_lines == "fluorescence": + t_asl = False + fl_asl = True + else: + t_asl = annotate_stage_lines + fl_asl = annotate_stage_lines + + if stage_lines == "temperature": + t_sl = True + fl_sl = False + elif stage_lines == "fluorescence": + t_sl = False + fl_sl = True + else: + t_sl = stage_lines + fl_sl = stage_lines + + self._annotate_stages(ax[0], fl_sl, fl_asl, (xlims[1] - xlims[0]) * 3600.0) if temperatures == "axes": if len(ax) < 2: @@ -1895,13 +1955,18 @@ def plot_over_time( tmax = max(tmax, d.max()) self.plot_temperatures( - sel=lambda x: (tmin <= x[("time", "hours")]) - & (x[("time", "hours")] <= tmax), + hours=(tmin, tmax), ax=ax[1], + stage_lines=t_sl, + annotate_stage_lines=t_asl, ) ax[0].set_xlim(xlims) ax[1].set_xlim(xlims) + elif temperatures is False: + pass + else: + raise NotImplementedError ax[0].set_title(_gen_axtitle(self.name, stages, samples, all_wells, filters)) @@ -1916,26 +1981,77 @@ def plot_protocol( def plot_temperatures( self, - sel=slice(None), + *, + sel: slice | Callable[[pd.DataFrame], bool] = slice(None), + hours: tuple[float, float] | None = None, ax: Optional[plt.Axes] = None, stage_lines: bool = True, annotate_stage_lines: bool | float = True, - ) -> plt.Axes: - """To be implemented.""" + legend: bool = False, + figure_kw: Mapping[str, Any] | None = None, + line_kw: Mapping[str, Any] | None = None, + ) -> "plt.Axes": + """Plot sample temperature readings. + + Parameters + ---------- + + sel + A selector for the temperature DataFrame. This is not necessarily + easy to use; `hours` is an easier alternative. + + hours + Constructs a selector to show temperatures for a time range. + :param:`sel` should not be set. + + ax + Optional. An axes to put the plot on. If not provided, the function will + create a new figure, by default with constrained_layout=True, though this + can be modified with figure_kw. + + stage_lines + Whether to include dotted vertical lines on transitions between stages. + + annotate_stage_lines + Whether to include text annotations for stage lines. Float parameter allows + setting the minimum duration of stage, as a fraction of total plotted time, to + annotate, in order to avoid overlapping annotations (default threshold is 0.05). + + legend + Whether to add a legend. + + figure_kw + Optional. A dictionary of options passed through as keyword options to + the figure creation. Only applies if ax is None. + + line_kw + Optional. A dictionary of keywords passed to plot commands. + """ + import matplotlib.pyplot as plt if self.temperatures is None: raise ValueError("Experiment has no temperature data.") + if hours is not None: + if sel != slice(None): + raise ValueError("sel and hours cannot both be set.") + tmin, tmax = hours + sel = lambda x: (tmin <= x[("time", "hours")]) & ( + x[("time", "hours")] <= tmax + ) + if ax is None: - _, ax = plt.subplots() + _, ax = plt.subplots(**(figure_kw or {})) reltemps = self.temperatures.loc[sel, :] - for x in range(1, 7): + for x in range(1, self.num_zones): ax.plot( reltemps.loc[:, ("time", "hours")], reltemps.loc[:, ("sample", x)], + label=f"zone {x}", + **(line_kw or {}), ) v = reltemps.loc[:, ("time", "hours")] @@ -1944,6 +2060,10 @@ def plot_temperatures( self._annotate_stages(ax, stage_lines, annotate_stage_lines, totseconds) ax.set_ylabel("temperature (°C)") + ax.set_xlabel("time (hours)") + + if legend: + ax.legend() return ax From f33c7874ad165439c1491c0eecae7e3db11a863e Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 20 Jan 2022 14:08:18 +0000 Subject: [PATCH 7/8] tests --- tests/test_experiment_file.py | 54 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/tests/test_experiment_file.py b/tests/test_experiment_file.py index c130043..6e866d3 100644 --- a/tests/test_experiment_file.py +++ b/tests/test_experiment_file.py @@ -37,11 +37,59 @@ def test_reload(exp: Experiment, exp_reloaded: Experiment): def test_plots(exp: Experiment): - exp.plot_over_time( - samples="Sample 1", temperatures="axes", normalization=NormToMeanPerWell() + # We need better sample arrangements: + exp.sample_wells["Sample 1"] = ["A7", "A8"] + exp.sample_wells["Sample 2"] = ["A9", "A10"] + exp.sample_wells["othersample"] = ["B7"] + + axf, axt = exp.plot_over_time( + legend=False, figure_kw={"constrained_layout": False}, annotate_stage_lines=True + ) + + # +2 here is for stage lines + assert len(axf.get_lines()) == 5 * len(exp.all_filters) + 2 + assert axf.get_xlim() == (-0.004326652778519521, 0.09085970834891001) + + with pytest.raises(ValueError, match="Samples not found"): + exp.plot_over_time("Sampl(e|a)") + + with pytest.raises(ValueError, match="Samples not found"): + exp.plot_anneal_melt("Sampl(e|a)") + + import matplotlib.pyplot as plt + + _, ax = plt.subplots() + axs = exp.plot_over_time( + "Sample .*", + "x1-m1", + stages=2, + temperatures=False, + stage_lines=False, + ax=ax, + marker=".", + legend=False, ) + + axs2 = exp.plot_over_time( + "Sample .*", + "x1-m1", + stages=2, + temperatures=False, + stage_lines="fluorescence", + annotate_stage_lines=("fluorescence", 0.1), + marker=".", + legend=True, + ) + + assert len(axs) == 1 == len(axs2) + assert len(axs[0].get_lines()) == 4 == len(axs2[0].get_lines()) - 2 + + axs = exp.plot_over_time("Sample .*") + exp.plot_anneal_melt(samples="Sample 1") - exp.protocol.plot_protocol() + + ax1 = exp.protocol.plot_protocol() + ax2 = exp.plot_protocol() def test_rawquant(exp: Experiment): From 5a4480bac0149b5a315c8c99dadfb49ccc13d0a6 Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 20 Jan 2022 14:32:56 +0000 Subject: [PATCH 8/8] tests --- src/qslib/experiment.py | 6 +++--- src/qslib/normalization.py | 2 +- src/qslib/plate_setup.py | 1 + tests/test_experiment.py | 13 +++++++++++++ tests/test_experiment_file.py | 14 +++++++------- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index f069850..d559ff0 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -261,9 +261,9 @@ class Experiment: """ A string describing the software and version used to write the file. """ - _welldata: pd.DataFrame | None + _welldata: pd.DataFrame | None = None - temperatures: pd.DataFrame | None + temperatures: pd.DataFrame | None = None """ A DataFrame of temperature readings, at one second resolution, during the experiment (and potentially slightly before and after, if included in the message log). @@ -2030,7 +2030,7 @@ def plot_temperatures( import matplotlib.pyplot as plt - if self.temperatures is None: + if not hasattr(self, "temperatures") or self.temperatures is None: raise ValueError("Experiment has no temperature data.") if hours is not None: diff --git a/src/qslib/normalization.py b/src/qslib/normalization.py index 3db8b68..9185ee4 100644 --- a/src/qslib/normalization.py +++ b/src/qslib/normalization.py @@ -32,7 +32,7 @@ def normalize(self, data: pd.DataFrame) -> pd.DataFrame: @property @abstractmethod - def ylabel(self) -> str: + def ylabel(self) -> str: # pragma: no cover ... diff --git a/src/qslib/plate_setup.py b/src/qslib/plate_setup.py index 638b07b..7007774 100644 --- a/src/qslib/plate_setup.py +++ b/src/qslib/plate_setup.py @@ -190,6 +190,7 @@ def to_table( ) def update_xml(self, root: ET.Element) -> None: + self._update_samples() samplemap = root.find("FeatureMap/Feature/Id[.='sample']/../..") e: Optional[ET.Element] if not samplemap: diff --git a/tests/test_experiment.py b/tests/test_experiment.py index 6778205..7acf4d0 100644 --- a/tests/test_experiment.py +++ b/tests/test_experiment.py @@ -9,3 +9,16 @@ def test_create(): exp = Experiment(protocol=Protocol([Stage([Step(30, 25)])])) + + +def test_fail_plots(): + exp = Experiment(protocol=Protocol([Stage([Step(30, 25)])])) + + with pytest.raises(ValueError, match="no temperature data"): + exp.plot_temperatures() + + with pytest.raises(ValueError, match="no data available"): + exp.plot_over_time() + + with pytest.raises(ValueError, match="no data available"): + exp.plot_anneal_melt() diff --git a/tests/test_experiment_file.py b/tests/test_experiment_file.py index 6e866d3..43fba13 100644 --- a/tests/test_experiment_file.py +++ b/tests/test_experiment_file.py @@ -11,7 +11,12 @@ @pytest.fixture(scope="module") def exp() -> Experiment: - return Experiment.from_file("tests/test.eds") + exp = Experiment.from_file("tests/test.eds") + # We need better sample arrangements: + exp.sample_wells["Sample 1"] = ["A7", "A8"] + exp.sample_wells["Sample 2"] = ["A9", "A10"] + exp.sample_wells["othersample"] = ["B7"] + return exp @pytest.fixture(scope="module") @@ -37,11 +42,6 @@ def test_reload(exp: Experiment, exp_reloaded: Experiment): def test_plots(exp: Experiment): - # We need better sample arrangements: - exp.sample_wells["Sample 1"] = ["A7", "A8"] - exp.sample_wells["Sample 2"] = ["A9", "A10"] - exp.sample_wells["othersample"] = ["B7"] - axf, axt = exp.plot_over_time( legend=False, figure_kw={"constrained_layout": False}, annotate_stage_lines=True ) @@ -71,7 +71,7 @@ def test_plots(exp: Experiment): ) axs2 = exp.plot_over_time( - "Sample .*", + ["Sample 1", "Sample 2"], "x1-m1", stages=2, temperatures=False,