From cc193013d9bc16b74500814c25e39c445dfa8682 Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Wed, 28 Aug 2024 16:28:39 +0100 Subject: [PATCH] Fix stage plotting and tests. --- src/qslib/__init__.py | 8 ++- src/qslib/cli.py | 6 +- src/qslib/experiment.py | 108 ++++++++++++++++++++------------ src/qslib/machine.py | 56 ++++++++++++----- src/qslib/monitor.py | 14 +++-- src/qslib/protocol.py | 29 ++++----- src/qslib/qsconnection_async.py | 35 ++++------- src/qslib/scpi_commands.py | 36 ++++++----- tests/test_experiment_file.py | 2 +- 9 files changed, 172 insertions(+), 122 deletions(-) diff --git a/src/qslib/__init__.py b/src/qslib/__init__.py index 8cfefce..6517d1e 100644 --- a/src/qslib/__init__.py +++ b/src/qslib/__init__.py @@ -6,7 +6,13 @@ from .experiment import Experiment from .machine import Machine, MachineStatus, RunStatus from .plate_setup import PlateSetup, Sample -from .processors import NormRaw, NormToMaxPerWell, NormToMeanPerWell, SmoothEMWMean, SmoothWindowMean +from .processors import ( + NormRaw, + NormToMaxPerWell, + NormToMeanPerWell, + SmoothEMWMean, + SmoothWindowMean, +) from .protocol import CustomStep, Protocol, Stage, Step from .scpi_commands import AccessLevel diff --git a/src/qslib/cli.py b/src/qslib/cli.py index 6b1873c..12849db 100644 --- a/src/qslib/cli.py +++ b/src/qslib/cli.py @@ -337,12 +337,10 @@ def error(self, s: str): click.secho(s, fg="red") -class NoNewAccess(BaseException): - ... +class NoNewAccess(BaseException): ... -class NoAccess(BaseException): - ... +class NoAccess(BaseException): ... def set_default_access(m: Machine, p: OutP): diff --git a/src/qslib/experiment.py b/src/qslib/experiment.py index 3f9494e..3c05feb 100644 --- a/src/qslib/experiment.py +++ b/src/qslib/experiment.py @@ -487,6 +487,8 @@ def info_html(self) -> str: summary = self.info(plate="table") import matplotlib.pyplot as plt + from matplotlib.axes import Axes + from matplotlib.lines import Line2D fig, ax = plt.subplots(figsize=(21.0 / 2.54, 15.0 / 2.54)) self.protocol.plot_protocol(ax) @@ -1152,9 +1154,9 @@ def _new_xml_files(self) -> None: ET.SubElement(tp, "Name").text = "Custom" ET.SubElement(tp, "Description").text = "Custom QSLib experiment" ET.SubElement(tp, "ResultPersisterName").text = "scAnalysisResultPersister" - ET.SubElement( - tp, "ContributedResultPersisterName" - ).text = "mcAnalysisResultPersister" + ET.SubElement(tp, "ContributedResultPersisterName").text = ( + "mcAnalysisResultPersister" + ) ET.SubElement(e.getroot(), "ChemistryType").text = "Other" ET.SubElement(e.getroot(), "TCProtocolMode").text = "Standard" ET.SubElement(e.getroot(), "DNATemplateType").text = "WET_DNA" @@ -1274,10 +1276,8 @@ def from_file(cls, file: str | os.PathLike[str] | IO[bytes]) -> Experiment: except ValueError: exp.spec_major_version = 1 - z.extractall(exp._dir_base) - exp._update_from_files() return exp @@ -1644,9 +1644,11 @@ def _update_from_data(self) -> None: json.load(f), self.plate_type, quant_files_path=(Path(self.root_dir) / "run/quant"), - start_time=self.activestarttime.timestamp() - if self.activestarttime - else None, + start_time=( + self.activestarttime.timestamp() + if self.activestarttime + else None + ), ) mdp = os.path.join(self._dir_base, "primary/multicomponent_data.json") if os.path.isfile(mdp): @@ -2034,11 +2036,11 @@ def plot_anneal_melt( if ax is None: ax = plt.figure( - **( - {"constrained_layout": True} - | (({} if figure_kw is None else figure_kw)) - ) - ).add_subplot() + **( + {"constrained_layout": True} + | (({} if figure_kw is None else figure_kw)) + ) + ).add_subplot() data = self.welldata @@ -2067,7 +2069,6 @@ def plot_anneal_melt( else: betweendat = None - for sample in samples: wells = self.plate_setup.get_wells(sample) @@ -2183,8 +2184,8 @@ def plot_over_time( annotate_events: bool = True, figure_kw: dict[str, Any] | None = None, line_kw: dict[str, Any] | None = None, - start_time = None, - time_units: Literal["hours","seconds"] = "hours" + start_time=None, + time_units: Literal["hours", "seconds"] = "hours", ) -> "Sequence[Axes]": """ Plots fluorescence over time, optionally with temperatures over time. @@ -2355,7 +2356,8 @@ def plot_over_time( lines.append( ax[0].plot( - filterdat.loc[stages, ("time", time_units)] - start_time_value, + filterdat.loc[stages, ("time", time_units)] + - start_time_value, filterdat.loc[stages, (well, "fl")], label=label, marker=marker, @@ -2412,10 +2414,12 @@ def plot_over_time( t_sl = stage_lines fl_sl = stage_lines - self._annotate_stages(ax[0], fl_sl, fl_asl, (xlims[1] - xlims[0]) * 3600.0) + self._annotate_stages( + ax[0], fl_sl, fl_asl, (xlims[1] - xlims[0]) * 3600.0, stages=stages + ) if annotate_events: - self._annotate_events(ax[0]) + self._annotate_events(ax[0], stages=stages) if temperatures == "axes": if len(ax) < 2: @@ -2509,6 +2513,7 @@ def plot_temperatures( """ import matplotlib.pyplot as plt + from matplotlib.axes import Axes if not hasattr(self, "temperatures") or self.temperatures is None: raise ValueError("Experiment has no temperature data.") @@ -2538,10 +2543,12 @@ def plot_temperatures( v = reltemps.loc[:, ("time", "hours")] totseconds = 3600.0 * (v.iloc[-1] - v.iloc[0]) - self._annotate_stages(ax, stage_lines, annotate_stage_lines, totseconds) + self._annotate_stages( + ax, stage_lines, annotate_stage_lines, totseconds, stages=False + ) if annotate_events: - self._annotate_events(ax) + self._annotate_events(ax, stages=False) ax.set_ylabel("temperature (°C)") ax.set_xlabel("time (hours)") @@ -2552,7 +2559,12 @@ def plot_temperatures( return ax def _annotate_stages( - self, ax, stage_lines: bool, annotate_stage_lines: bool | float, totseconds + self, + ax: "Axes", + stage_lines: bool, + annotate_stage_lines: bool | float, + totseconds, + stages: slice | Sequence[int] | bool = slice(None), ): if stage_lines: if isinstance(annotate_stage_lines, float): @@ -2562,16 +2574,18 @@ def _annotate_stages( annotate_frac = 0.05 for _, s in self.stages.iterrows(): - if s.stage == "PRERUN" or s.stage == "POSTRUN": - continue + if s.stage == "PRERUN" or s.stage == "POSTRUN" or s.stage == "POSTRun": + continue # FIXME: maybe we should include these # xlim = ax.get_xlim() - if not (xlim[0] <= s.start_seconds / 3600.0 <= xlim[1]): + if (stages != slice(None)) and ( + not (xlim[0] <= s.start_seconds / 3600.0 <= xlim[1]) + ): continue xtrans = ax.get_xaxis_transform() - ax.axvline( + vline = ax.axvline( s.start_seconds / 3600.0, linestyle="dotted", color="black", @@ -2579,17 +2593,21 @@ def _annotate_stages( ) 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, + ax.annotate( f"stage {s.stage}", - transform=xtrans, + xy=(1, 0.9), + xycoords=vline, + xytext=(5, 0), + textcoords="offset points", rotation=90, verticalalignment="top", horizontalalignment="left", ) - def _annotate_events(self, ax): + def _annotate_events(self, ax, stages: slice | Sequence[int] | bool = slice(None)): + first_time = self.stages.iloc[0, :]["start_seconds"] / 3600.0 + last_time = self.stages.iloc[-1, :]["end_seconds"] / 3600.0 + opi = self.events.index[ (self.events["type"] == "Cover") & (self.events["message"] == "Raising") ] @@ -2599,12 +2617,18 @@ def _annotate_events(self, ax): cli = [cl[cl > x][0] if len(cl[cl > x]) > 0 else None for x in opi] for x1, x2 in zip(opi, cli): xlim = ax.get_xlim() - if not (xlim[0] <= self.events.loc[x1, "hours"] <= xlim[1] or - (x2 is not None and xlim[0] <= self.events.loc[x2, "hours"] <= xlim[1])): - continue + if not ( + xlim[0] <= self.events.loc[x1, "hours"] <= xlim[1] + or ( + x2 is not None + and xlim[0] <= self.events.loc[x2, "hours"] <= xlim[1] + ) + ): + if stages != slice(None): + continue ax.axvspan( self.events.loc[x1, "hours"], - self.events.loc[x2, "hours"] if x2 is not None else None, + self.events.loc[x2, "hours"] if x2 is not None else last_time, alpha=0.5, color="yellow", ) @@ -2618,12 +2642,18 @@ def _annotate_events(self, ax): cli = [cl[cl > x][0] if len(cl[cl > x]) > 0 else None for x in opi] for x1, x2 in zip(opi, cli): xlim = ax.get_xlim() - if not (xlim[0] <= self.events.loc[x1, "hours"] <= xlim[1] or - (x2 is not None and xlim[0] <= self.events.loc[x2, "hours"] <= xlim[1])): - continue + if not ( + xlim[0] <= self.events.loc[x1, "hours"] <= xlim[1] + or ( + x2 is not None + and xlim[0] <= self.events.loc[x2, "hours"] <= xlim[1] + ) + ): + if stages != slice(None): + continue ax.axvspan( self.events.loc[x1, "hours"], - self.events.loc[x2, "hours"] if x2 is not None else None, + self.events.loc[x2, "hours"] if x2 is not None else last_time, alpha=0.5, color="red", ) diff --git a/src/qslib/machine.py b/src/qslib/machine.py index b11edd6..0bb4293 100644 --- a/src/qslib/machine.py +++ b/src/qslib/machine.py @@ -251,7 +251,9 @@ def run_command_to_ack(self, command: str | SCPICommand) -> str: raise ConnectionError(f"Not connected to {self.host}") loop = asyncio.get_event_loop() try: - return loop.run_until_complete(self.connection.run_command(command, just_ack=True)) + return loop.run_until_complete( + self.connection.run_command(command, just_ack=True) + ) except CommandError as e: e.__traceback__ = None raise e @@ -324,8 +326,7 @@ def list_files( leaf: str = "FILE", verbose: Literal[True], recursive: bool = False, - ) -> list[dict[str, Any]]: - ... + ) -> list[dict[str, Any]]: ... @overload def list_files( @@ -335,8 +336,7 @@ def list_files( leaf: str = "FILE", verbose: Literal[False] = False, recursive: bool = False, - ) -> list[str]: - ... + ) -> list[str]: ... @_ensure_connection(AccessLevel.Observer) def list_files( @@ -349,11 +349,15 @@ def list_files( ) -> list[str] | list[dict[str, Any]]: loop = asyncio.get_event_loop() return loop.run_until_complete( - self.connection.list_files(path, leaf=leaf, verbose=verbose, recursive=recursive) + self.connection.list_files( + path, leaf=leaf, verbose=verbose, recursive=recursive + ) ) @_ensure_connection(AccessLevel.Observer) - def read_file(self, path: str, context: str | None = None, leaf: str = "FILE") -> bytes: + def read_file( + self, path: str, context: str | None = None, leaf: str = "FILE" + ) -> bytes: """Read a file. Parameters @@ -369,7 +373,9 @@ def read_file(self, path: str, context: str | None = None, leaf: str = "FILE") - bytes returned file """ - return asyncio.get_event_loop().run_until_complete(self.connection.read_file(path, context, leaf)) + return asyncio.get_event_loop().run_until_complete( + self.connection.read_file(path, context, leaf) + ) @_ensure_connection(AccessLevel.Controller) def write_file(self, path: str, data: str | bytes) -> None: @@ -377,7 +383,11 @@ def write_file(self, path: str, data: str | bytes) -> None: data = data.encode() self.run_command_bytes( - b"FILE:WRITE " + path.encode() + b" \n" + base64.encodebytes(data) + b"\n" + b"FILE:WRITE " + + path.encode() + + b" \n" + + base64.encodebytes(data) + + b"\n" ) @_ensure_connection(AccessLevel.Observer) @@ -393,7 +403,9 @@ def list_runs_in_storage(self) -> list[str]: """ x = self.run_command("FILE:LIST? public_run_complete:") a = x.split("\n")[1:-1] - return [re.sub("^public_run_complete:", "", s)[:-4] for s in a if s.endswith(".eds")] + return [ + re.sub("^public_run_complete:", "", s)[:-4] for s in a if s.endswith(".eds") + ] @_ensure_connection(AccessLevel.Observer) def load_run_from_storage(self, path: str) -> "Experiment": # type: ignore @@ -404,7 +416,9 @@ def load_run_from_storage(self, path: str) -> "Experiment": # type: ignore return Experiment.from_machine_storage(self, path) @_ensure_connection(AccessLevel.Guest) - def save_run_from_storage(self, machine_path: str, download_path: str | IO[bytes], overwrite: bool = False) -> None: + def save_run_from_storage( + self, machine_path: str, download_path: str | IO[bytes], overwrite: bool = False + ) -> None: """Download a file from run storage on the machine. Parameters @@ -435,7 +449,9 @@ def save_run_from_storage(self, machine_path: str, download_path: str | IO[bytes @_ensure_connection(AccessLevel.Observer) def _get_log_from_byte(self, name: str | bytes, byte: int) -> bytes: - logfuture: Future[tuple[bytes, bytes, Future[tuple[bytes, bytes, None]] | None]] = asyncio.Future() + logfuture: Future[ + tuple[bytes, bytes, Future[tuple[bytes, bytes, None]] | None] + ] = asyncio.Future() if self.connection is None: raise Exception if isinstance(name, bytes): @@ -471,7 +487,9 @@ def machine_status(self) -> MachineStatus: @_ensure_connection(AccessLevel.Observer) def get_running_protocol(self) -> Protocol: p = _unwrap_tags(self.run_command("PROT? ${Protocol}")) - pn, svs, rm = self.run_command("RET ${Protocol} ${SampleVolume} ${RunMode}").split() + pn, svs, rm = self.run_command( + "RET ${Protocol} ${SampleVolume} ${RunMode}" + ).split() p = f"PROT -volume={svs} -runmode={rm} {pn} " + p return Protocol.from_scpicommand(SCPICommand.from_string(p)) @@ -489,7 +507,9 @@ def set_access_level( " Change max_access level to continue." ) - self.run_command(f"ACC -stealth={stealth} -exclusive={exclusive} {access_level}") + self.run_command( + f"ACC -stealth={stealth} -exclusive={exclusive} {access_level}" + ) log.debug(f"Took access level {access_level} {exclusive=} {stealth=}") self._current_access_level = access_level @@ -707,10 +727,14 @@ def at_access( log.debug(f"Took access level {access_level} {exclusive=} {stealth=}.") yield self self.set_access_level(fac, fex, fst) - log.debug(f"Dropped access level {access_level}, returning to {fac} exclusive={fex} stealth={fst}.") + log.debug( + f"Dropped access level {access_level}, returning to {fac} exclusive={fex} stealth={fst}." + ) @contextmanager - def ensured_connection(self, access_level: AccessLevel = AccessLevel.Observer) -> Generator[Machine, None, None]: + def ensured_connection( + self, access_level: AccessLevel = AccessLevel.Observer + ) -> Generator[Machine, None, None]: if self.automatic: was_connected = self.connected if not was_connected: diff --git a/src/qslib/monitor.py b/src/qslib/monitor.py index 981413e..9b17a6a 100644 --- a/src/qslib/monitor.py +++ b/src/qslib/monitor.py @@ -359,9 +359,9 @@ async def docollect( connection: QSConnectionAsync, ) -> None: if state.run.plate_setup: - pa: npt.NDArray[ - np.object_ - ] | None = state.run.plate_setup.well_samples_as_array() + pa: npt.NDArray[np.object_] | None = ( + state.run.plate_setup.well_samples_as_array() + ) else: pa = None @@ -662,9 +662,11 @@ async def monitor(self, connected_fut: asyncio.Future[bool] | None = None) -> No async with QSConnectionAsync( host=self.config.machine.host, - port=int(self.config.machine.port) - if self.config.machine.port is not None - else None, + port=( + int(self.config.machine.port) + if self.config.machine.port is not None + else None + ), password=self.config.machine.password, ) as c: log.info("monitor connected") diff --git a/src/qslib/protocol.py b/src/qslib/protocol.py index 0c076cc..5798674 100644 --- a/src/qslib/protocol.py +++ b/src/qslib/protocol.py @@ -132,11 +132,9 @@ def _wrap_degC_or_none( def _wrapunitmaybelist_degC( - val: int - | float - | str - | pint.Quantity - | Sequence[int | float | str | pint.Quantity], + val: ( + int | float | str | pint.Quantity | Sequence[int | float | str | pint.Quantity] + ), ) -> pint.Quantity: unit: pint.Unit = UR.Unit("degC") @@ -394,8 +392,7 @@ def from_scpicommand(cls, sc: SCPICommand) -> Hold: class XMLable(ABC): @abstractmethod - def to_xml(self, **kwargs: Any) -> ET.Element: - ... + def to_xml(self, **kwargs: Any) -> ET.Element: ... G = TypeVar("G") @@ -408,12 +405,10 @@ def __init__(self, val_list: list[G]): self._list = val_list @overload - def _translate_key(self, key: int | str) -> int: - ... + def _translate_key(self, key: int | str) -> int: ... @overload - def _translate_key(self, key: slice) -> slice: - ... + def _translate_key(self, key: slice) -> slice: ... def _translate_key(self, key: int | str | slice) -> int | slice: if isinstance(key, int): @@ -427,12 +422,10 @@ def __getitem__(self, key: int | str | slice) -> G | list[G]: return self._list[self._translate_key(key)] @overload - def __setitem__(self, key: int | str, val: G) -> None: - ... + def __setitem__(self, key: int | str, val: G) -> None: ... @overload - def __setitem__(self, key: slice, val: Sequence[G]) -> None: - ... + def __setitem__(self, key: slice, val: Sequence[G]) -> None: ... def __setitem__(self, key, val): self._list.__setitem__(self._translate_key(key), val) @@ -1711,9 +1704,9 @@ def to_xml( " placeholder for the real protocol, contained as" " an SCPI command in QSLibProtocolCommand." ) - _set_or_create( - qe, "QSLibProtocolCommand" - ).text = self.to_scpicommand().to_string() + _set_or_create(qe, "QSLibProtocolCommand").text = ( + self.to_scpicommand().to_string() + ) _set_or_create(qe, "QSLibProtocol").text = str(attr.asdict(self)) _set_or_create(qe, "QSLibVerson").text = __version__ _set_or_create(e, "CoverTemperature").text = str(covertemperature) diff --git a/src/qslib/qsconnection_async.py b/src/qslib/qsconnection_async.py index 72812a0..9d26406 100644 --- a/src/qslib/qsconnection_async.py +++ b/src/qslib/qsconnection_async.py @@ -25,6 +25,7 @@ log = logging.getLogger(__name__) + def _gen_auth_response(password: str, challenge_string: str) -> str: return hmac.digest(password.encode(), challenge_string.encode(), "md5").hex() @@ -43,12 +44,10 @@ def _parse_argstring(argstring: str) -> Dict[str, str]: return args -class AlreadyCollectedError(Exception): - ... +class AlreadyCollectedError(Exception): ... -class RunNotFinishedError(Exception): - ... +class RunNotFinishedError(Exception): ... @dataclass(frozen=True, order=True, eq=True) @@ -125,8 +124,7 @@ async def list_files( leaf: str = "FILE", verbose: Literal[True], recursive: bool = False, - ) -> list[dict[str, Any]]: - ... + ) -> list[dict[str, Any]]: ... @overload async def list_files( @@ -136,8 +134,7 @@ async def list_files( leaf: str = "FILE", verbose: Literal[False], recursive: bool = False, - ) -> list[str]: - ... + ) -> list[str]: ... @overload async def list_files( @@ -147,8 +144,7 @@ async def list_files( leaf: str = "FILE", verbose: bool = False, recursive: bool = False, - ) -> list[str] | list[dict[str, Any]]: - ... + ) -> list[str] | list[dict[str, Any]]: ... async def list_files( self, @@ -270,7 +266,6 @@ async def connect( if initial_access_level is not None: self._initial_access_level = initial_access_level - CTX = ssl.create_default_context() CTX.check_hostname = False CTX.verify_mode = ssl.CERT_NONE @@ -467,8 +462,7 @@ async def get_filterdata_one( *, run: Optional[str] = None, return_files: Literal[True], - ) -> tuple[data.FilterDataReading, list[tuple[str, bytes]]]: - ... + ) -> tuple[data.FilterDataReading, list[tuple[str, bytes]]]: ... @overload async def get_filterdata_one( @@ -477,8 +471,7 @@ async def get_filterdata_one( *, run: Optional[str] = None, return_files: Literal[False] = False, - ) -> data.FilterDataReading: - ... + ) -> data.FilterDataReading: ... async def get_filterdata_one( self, @@ -486,9 +479,9 @@ async def get_filterdata_one( *, run: Optional[str] = None, return_files: bool = False, - ) -> data.FilterDataReading | tuple[ - data.FilterDataReading, list[tuple[str, bytes]] - ]: + ) -> ( + data.FilterDataReading | tuple[data.FilterDataReading, list[tuple[str, bytes]]] + ): if run is None: run = await self.get_run_title() @@ -520,14 +513,12 @@ async def get_filterdata_one( @overload async def get_all_filterdata( self, run: Optional[str], as_list: Literal[True] - ) -> List[data.FilterDataReading]: - ... + ) -> List[data.FilterDataReading]: ... @overload async def get_all_filterdata( self, run: Optional[str], as_list: Literal[False] - ) -> pd.DataFrame: - ... + ) -> pd.DataFrame: ... async def get_all_filterdata( self, run: str | None = None, as_list: bool = False diff --git a/src/qslib/scpi_commands.py b/src/qslib/scpi_commands.py index ce146a1..0639e0b 100644 --- a/src/qslib/scpi_commands.py +++ b/src/qslib/scpi_commands.py @@ -73,9 +73,11 @@ def _make_multi_keyword(kwd_str: str, kwd_value: Any) -> ParserElement: _ovcl = pp.delimited_list(_opt_value_one, ",") _opt_value = (_commands_block | _ovcl | _opt_value_one).setParseAction( - lambda toks: toks[0] - if not isinstance(toks[0], SCPICommand) and len(toks) == 1 - else (list(toks[:]),) + lambda toks: ( + toks[0] + if not isinstance(toks[0], SCPICommand) and len(toks) == 1 + else (list(toks[:]),) + ) ) _opt_kv_pair = ( @@ -256,11 +258,13 @@ def __init__( | Sequence[str | int | float | np.number[Any]] | Sequence["SCPICommand"], comment: str | None = None, - **kwargs: str - | int - | float - | np.number[Any] - | Sequence[str | int | float | np.number[Any]], + **kwargs: ( + str + | int + | float + | np.number[Any] + | Sequence[str | int | float | np.number[Any]] + ), ) -> None: if " " in command: if args or comment or kwargs: @@ -279,13 +283,15 @@ def __init__( def _optformat( self, - opt_val: str - | int - | float - | np.number[Any] - | Sequence[str | int | float | np.number[Any]] - | Sequence[SCPICommand] - | SCPICommand, + opt_val: ( + str + | int + | float + | np.number[Any] + | Sequence[str | int | float | np.number[Any]] + | Sequence[SCPICommand] + | SCPICommand + ), ) -> str: if isinstance(opt_val, SCPICommand): opt_val = [opt_val] diff --git a/tests/test_experiment_file.py b/tests/test_experiment_file.py index b9b9bbd..5e6be3f 100644 --- a/tests/test_experiment_file.py +++ b/tests/test_experiment_file.py @@ -75,7 +75,7 @@ def test_plots(exp: Experiment) -> None: ) # +2 here is for stage lines - assert len(axf.get_lines()) == 5 * len(exp.all_filters) + 3 + assert len(axf.get_lines()) == 5 * len(exp.all_filters) + 2 assert np.allclose(axf.get_xlim(), (-0.004825680553913112, 0.10133929163217542)) with pytest.raises(ValueError, match="Samples not found"):