From 85680e199f00c04b1c97a41ebc9d71a7c788b9df Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 6 Nov 2024 13:29:33 +0000 Subject: [PATCH 01/24] initial commit --- .../callbacks/fitting/LiveFitLogger.py | 90 +++++++++++++++++++ .../callbacks/fitting/__init__.py | 8 +- 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py diff --git a/src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py b/src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py new file mode 100644 index 0000000..fd67ad0 --- /dev/null +++ b/src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py @@ -0,0 +1,90 @@ +import csv +from pathlib import Path +from typing import Optional +from bluesky.callbacks import CallbackBase +from event_model.documents.run_stop import RunStop +from event_model.documents.run_start import RunStart +from ibex_bluesky_core.logger import logger +import numpy as np + +from ibex_bluesky_core.callbacks.fitting import LiveFit + +UID = "uid" + +class LiveFitLogger(CallbackBase): + """Generates files as part of a scan that describe the fit(s) which have been performed.""" + + def __init__(self, livefit: LiveFit, output_dir: Path, postfix: str | None = None) -> None: + """Initialises LiveFitLogger callback. + + Args: + livefit (LiveFit): A reference to LiveFit callback to collect fit info from. + output_dir (str): A path to where the fitting file should be stored. + postfix (str): A small string that should be placed at the end of the file name to prevent overwriting. + """ + + super().__init__() + self.livefit = livefit + self.postfix = postfix + (self.__x_key,) = livefit.independent_vars.keys() + self.output_dir = output_dir + self.current_start_document: Optional[str] = None + + def start(self, doc: RunStart) -> None: + """Create the output directory if it doesn't already exist then setting the filename. + + Args: + doc (RunStart): The start bluesky document. + """ + self.output_dir.mkdir(parents=True, exist_ok=True) + self.current_start_document = doc[UID] + self.filename = self.output_dir / f"{self.current_start_document}{self.postfix}.csv" + + def stop(self, doc: RunStop) -> None: + """Writes to the fitting file. + + Args: + doc (RunStop): The stop bluesky document. + """ + self.x_data = np.array(next(iter(self.livefit.independent_vars_data.values()))) + self.y_data = np.array(self.livefit.ydata) + + self.xmin = np.min(self.x_data) + self.xmax = np.max(self.x_data) + self.num_points = self.y_data.size + + if self.livefit.result is None: + logger.blueskylogger.error("LiveFit.result was None. Could not write to file.") + return + + # Evaluate the model function at equally-spaced points. + x_points = np.linspace(self.xmin, self.xmax, self.num_points) + kwargs = {self.__x_key: x_points} + kwargs.update(self.livefit.result.values) + self.y_fit_data = self.livefit.result.model.eval(**kwargs) + + self.stats = str(self.livefit.result.fit_report()).split("\n") + + # Writing to csv file + try: + with open(self.filename, 'w', newline='') as csvfile: + csvwriter = csv.writer(csvfile) + csvwriter.writerow([self.__x_key, self.livefit.y, "Model"]) + + # Writing the data + for i in range(0, self.num_points): + csvwriter.writerow([self.x_data[i], self.y_data[i], self.y_fit_data[i]]) + + csvwriter.writerow([]) + csvwriter.writerow([]) + + for row in self.stats: + csvwriter.writerow([row]) + + logger.blueskylogger.info(f"Fitting information successfully written to {self.filename}") + + except Exception as e: + logger.blueskylogger.error(f"Could not write fitting information to {self.filename}\n{e}") + + # unit test + # docs \ No newline at end of file diff --git a/src/ibex_bluesky_core/callbacks/fitting/__init__.py b/src/ibex_bluesky_core/callbacks/fitting/__init__.py index 6ef3bd9..ad22910 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/__init__.py +++ b/src/ibex_bluesky_core/callbacks/fitting/__init__.py @@ -34,20 +34,20 @@ def __init__( guess (Callable): The guess function to use. """ + self.guess = guess + if callable(model): - self.model = lmfit.Model(model) + self.model = lmfit.Model(model, name=self.guess.__qualname__.split('.')[0]) else: self.model = model - self.guess = guess - logger = logging.getLogger(__name__) @make_class_safe(logger=logger) # pyright: ignore (pyright doesn't understand this decorator) class LiveFit(_DefaultLiveFit): - """Live fit, customized for IBEX.""" + """LiveFit, customized for IBEX.""" def __init__( self, From c6bfd23e8c33ae5abf6a4690214f1f1972992f76 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 8 Nov 2024 15:24:37 +0000 Subject: [PATCH 02/24] create fit logging unit test module --- tests/callbacks/fitting/test_fit_logging_callback.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/callbacks/fitting/test_fit_logging_callback.py diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py new file mode 100644 index 0000000..e69de29 From ca5ed9b49877ef88c3efdd6b3e0cb5b677bc29e0 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Thu, 14 Nov 2024 16:32:14 +0000 Subject: [PATCH 03/24] LiveFit logger made and unit tests passing --- manual_system_tests/dae_scan.py | 29 +++- .../callbacks/fitting/LiveFitLogger.py | 90 ------------ .../callbacks/fitting/livefit_logger.py | 129 ++++++++++++++++++ .../fitting/test_fit_logging_callback.py | 100 ++++++++++++++ 4 files changed, 253 insertions(+), 95 deletions(-) delete mode 100644 src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py create mode 100644 src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index f4d12cc..608ac97 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -8,12 +8,15 @@ import bluesky.plans as bp import matplotlib import matplotlib.pyplot as plt -from bluesky.callbacks import LiveTable +from bluesky.callbacks import LiveFitPlot, LiveTable from bluesky.preprocessors import subs_decorator from bluesky.utils import Msg from ophyd_async.plan_stubs import ensure_connected from ibex_bluesky_core.callbacks.file_logger import HumanReadableFileCallback +from ibex_bluesky_core.callbacks.fitting import LiveFit +from ibex_bluesky_core.callbacks.fitting.fitting_utils import Linear +from ibex_bluesky_core.callbacks.fitting.livefit_logger import LiveFitLogger from ibex_bluesky_core.callbacks.plotting import LivePlot from ibex_bluesky_core.devices import get_pv_prefix from ibex_bluesky_core.devices.block import block_rw_rbv @@ -27,6 +30,8 @@ from ibex_bluesky_core.devices.simpledae.waiters import GoodFramesWaiter from ibex_bluesky_core.run_engine import get_run_engine +NUM_POINTS: int = 3 + def dae_scan_plan() -> Generator[Msg, None, None]: """Manual system test which moves a block and reads the DAE. @@ -47,7 +52,7 @@ def dae_scan_plan() -> Generator[Msg, None, None]: - The DAE waited for at least 500 good frames at each point """ prefix = get_pv_prefix() - block = block_rw_rbv(float, "mot") + block = block_rw_rbv(float, "bob") controller = RunPerPointController(save_run=True) waiter = GoodFramesWaiter(500) @@ -67,6 +72,11 @@ def dae_scan_plan() -> Generator[Msg, None, None]: controller.run_number.set_name("run number") reducer.intensity.set_name("normalized counts") + _, ax = plt.subplots() + lf = LiveFit( + Linear.fit(), y=reducer.intensity.name, x=block.name + ) + yield from ensure_connected(block, dae, force_reconnect=True) @subs_decorator( @@ -81,7 +91,14 @@ def dae_scan_plan() -> Generator[Msg, None, None]: dae.good_frames.name, ], ), - LivePlot(y=reducer.intensity.name, x=block.name, marker="x", linestyle="none"), + LiveFitPlot(livefit=lf, ax=ax), + LivePlot( + y=reducer.intensity.name, + x=block.name, + marker="x", + linestyle="none", + ax=ax + ), LiveTable( [ block.name, @@ -93,11 +110,13 @@ def dae_scan_plan() -> Generator[Msg, None, None]: dae.good_frames.name, ] ), + LiveFitLogger(lf, y=reducer.intensity.name, x=block.name, output_dir=Path(f"C:\\Instrument\\Var\\logs\\bluesky\\fitting"), yerr=reducer.intensity_stddev.name) ] ) def _inner() -> Generator[Msg, None, None]: - num_points = 3 - yield from bps.mv(dae.number_of_periods, num_points) + num_points = NUM_POINTS + yield from bps.mv(dae.number_of_periods, num_points) # type: ignore + # Pyright does not understand as bluesky isn't typed yet yield from bp.scan([dae], block, 0, 10, num=num_points) yield from _inner() diff --git a/src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py b/src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py deleted file mode 100644 index fd67ad0..0000000 --- a/src/ibex_bluesky_core/callbacks/fitting/LiveFitLogger.py +++ /dev/null @@ -1,90 +0,0 @@ -import csv -from pathlib import Path -from typing import Optional -from bluesky.callbacks import CallbackBase -from event_model.documents.run_stop import RunStop -from event_model.documents.run_start import RunStart -from ibex_bluesky_core.logger import logger -import numpy as np - -from ibex_bluesky_core.callbacks.fitting import LiveFit - -UID = "uid" - -class LiveFitLogger(CallbackBase): - """Generates files as part of a scan that describe the fit(s) which have been performed.""" - - def __init__(self, livefit: LiveFit, output_dir: Path, postfix: str | None = None) -> None: - """Initialises LiveFitLogger callback. - - Args: - livefit (LiveFit): A reference to LiveFit callback to collect fit info from. - output_dir (str): A path to where the fitting file should be stored. - postfix (str): A small string that should be placed at the end of the file name to prevent overwriting. - """ - - super().__init__() - self.livefit = livefit - self.postfix = postfix - (self.__x_key,) = livefit.independent_vars.keys() - self.output_dir = output_dir - self.current_start_document: Optional[str] = None - - def start(self, doc: RunStart) -> None: - """Create the output directory if it doesn't already exist then setting the filename. - - Args: - doc (RunStart): The start bluesky document. - """ - self.output_dir.mkdir(parents=True, exist_ok=True) - self.current_start_document = doc[UID] - self.filename = self.output_dir / f"{self.current_start_document}{self.postfix}.csv" - - def stop(self, doc: RunStop) -> None: - """Writes to the fitting file. - - Args: - doc (RunStop): The stop bluesky document. - """ - self.x_data = np.array(next(iter(self.livefit.independent_vars_data.values()))) - self.y_data = np.array(self.livefit.ydata) - - self.xmin = np.min(self.x_data) - self.xmax = np.max(self.x_data) - self.num_points = self.y_data.size - - if self.livefit.result is None: - logger.blueskylogger.error("LiveFit.result was None. Could not write to file.") - return - - # Evaluate the model function at equally-spaced points. - x_points = np.linspace(self.xmin, self.xmax, self.num_points) - kwargs = {self.__x_key: x_points} - kwargs.update(self.livefit.result.values) - self.y_fit_data = self.livefit.result.model.eval(**kwargs) - - self.stats = str(self.livefit.result.fit_report()).split("\n") - - # Writing to csv file - try: - with open(self.filename, 'w', newline='') as csvfile: - csvwriter = csv.writer(csvfile) - csvwriter.writerow([self.__x_key, self.livefit.y, "Model"]) - - # Writing the data - for i in range(0, self.num_points): - csvwriter.writerow([self.x_data[i], self.y_data[i], self.y_fit_data[i]]) - - csvwriter.writerow([]) - csvwriter.writerow([]) - - for row in self.stats: - csvwriter.writerow([row]) - - logger.blueskylogger.info(f"Fitting information successfully written to {self.filename}") - - except Exception as e: - logger.blueskylogger.error(f"Could not write fitting information to {self.filename}\n{e}") - - # unit test - # docs \ No newline at end of file diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py new file mode 100644 index 0000000..8bff839 --- /dev/null +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -0,0 +1,129 @@ +from event_model.documents.event import Event +import csv +from pathlib import Path +from typing import Optional +from bluesky.callbacks import CallbackBase +from event_model.documents.run_stop import RunStop +from event_model.documents.run_start import RunStart +from ibex_bluesky_core.logger import logger +import numpy as np +from ibex_bluesky_core.callbacks.fitting import LiveFit + +UID = "uid" +DATA = "data" + +class LiveFitLogger(CallbackBase): + """Generates files as part of a scan that describe the fit(s) which have been performed.""" + + def __init__(self, livefit: LiveFit, y: str, x: str, output_dir: Path, postfix: str | None = None, yerr : str | None = None) -> None: + """Initialises LiveFitLogger callback. + + Args: + livefit (LiveFit): A reference to LiveFit callback to collect fit info from. + y (str): The name of the signal pointing to y counts data. + x (str): The name of the signal pointing to x counts data. + output_dir (str): A path to where the fitting file should be stored. + postfix (str): A small string that should be placed at the end of the file name to prevent overwriting. + yerr (str): The name of the signal pointing to y count uncertainties data. + """ + + super().__init__() + self.livefit = livefit + self.postfix = "" if postfix is None else postfix + self.output_dir = output_dir + self.current_start_document: Optional[str] = None + + self.x = x + self.y = y + self.yerr = yerr + + assert self.x != "" + assert self.y != "" + assert self.yerr != "" + + self.x_data = np.array([]) + self.y_data = np.array([]) + self.yerr_data = np.array([]) + + def start(self, doc: RunStart) -> None: + """Create the output directory if it doesn't already exist then setting the filename. + + Args: + doc (RunStart): The start bluesky document. + """ + self.output_dir.mkdir(parents=True, exist_ok=True) + self.current_start_document = doc[UID] + self.filename = self.output_dir / f"{self.current_start_document}{self.postfix}.csv" + + def event(self, doc: Event) -> Event: + """Start collecting, y, x and yerr data. + + Args: + doc: (Event): An event document. + """ + + event_data = doc[DATA] + + assert self.x in event_data + assert self.y in event_data + + self.x_data = np.append(self.x_data, [event_data[self.x]]) + self.y_data = np.append(self.y_data, [event_data[self.y]]) + + if self.yerr is not None: + assert self.yerr in event_data + np.append(self.yerr_data, [event_data[self.yerr]]) + + return super().event(doc) + + def stop(self, doc: RunStop) -> None: + """Writes to the fitting file. + + Args: + doc (RunStop): The stop bluesky document. + """ + + if self.livefit.result is None: + logger.blueskylogger.error("LiveFit.result was None. Could not write to file.") + return + + # Evaluate the model function at equally-spaced points. + kwargs = {"x": self.x_data} + kwargs.update(self.livefit.result.values) + self.y_fit_data = self.livefit.result.model.eval(**kwargs) + + self.stats = str(self.livefit.result.fit_report()).split("\n") + + # Writing to csv file + with open(self.filename, 'w', newline='') as csvfile: + # Writing the data + self.csvwriter = csv.writer(csvfile) + + for row in self.stats: + self.csvwriter.writerow([row]) + + self.csvwriter.writerow([]) + self.csvwriter.writerow([]) + + self.write_fields_table() + + csvfile.close() + logger.blueskylogger.info(f"Fitting information successfully written to {self.filename}") + + def write_fields_table(self): + """Write collected run info to the fitting file. + """ + + if self.yerr is not None: + row = ["x", "y", "y uncertainty", "modelled y"] + else: + row = ["x", "y", "modelled y"] + + self.csvwriter.writerow(row) + + for i in range(0, self.x_data.size): + self.csvwriter.writerow([self.x_data[i], self.y_data[i], self.y_fit_data[i]]) + + # docs + # fix model name?? + # x, indep variable fixed name diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index e69de29..7c172d8 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -0,0 +1,100 @@ +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch +from bluesky.plans import scan +import packaging +import pytest + +from ibex_bluesky_core.callbacks.fitting import FitMethod, LiveFit +from ibex_bluesky_core.callbacks.fitting.fitting_utils import Linear +from ibex_bluesky_core.callbacks.fitting.livefit_logger import LiveFitLogger + +import ophyd +from ophyd.sim import InvariantSignal + +# Taken from bluesky bluesky\tests\test_callbacks.py +@pytest.fixture(scope="function") +def hw(tmp_path): + + from ophyd.sim import hw + + # ophyd 1.4.0 added support for customizing the directory used by simulated + # hardware that generates files + if packaging.version.Version(ophyd.__version__) >= packaging.version.Version("1.4.0"): + return hw(str(tmp_path)) + else: + return hw() + + +def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity(RE, hw): + + invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) + + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + m = mock_open() + + lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) + lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) + + with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): + result = RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) + + assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") + + handle = m() + args = [] + + # Check that it starts writing to the file in the expected way + for i in handle.write.call_args_list: + args.append(i.args[0]) + + assert "x,y,modelled y\r\n" in args + + +def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity(RE, hw): + + invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) + uncertainty = InvariantSignal(func=lambda: 1.0, name="uncertainty", labels={"detectors"}) + + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + m = mock_open() + + lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) + lfl = LiveFitLogger(lf, y="invariant", x="motor", yerr="uncertainty", output_dir=filepath, postfix=postfix) + + with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): + result = RE(scan([invariant, uncertainty], hw.motor, -1, 1, 3), [lf, lfl]) + + assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") + + handle = m() + args = [] + + # Check that it starts writing to the file in the expected way + for i in handle.write.call_args_list: + args.append(i.args[0]) + + assert "x,y,y uncertainty,modelled y\r\n" in args + + +def test_file_not_written_if_no_fitting_result(RE, hw): + + invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) + + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + m = mock_open() + + model = Linear.model() + model.fit = MagicMock() + model.fit.return_value = None + method = FitMethod(model=model, guess=Linear.guess()) + lf = LiveFit(method, y="invariant", x="motor") + lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) + + with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): + + RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) + + assert not m.called From b3ea87ad8401f9e4dfb9500bc69f7b48969d5bdc Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Thu, 14 Nov 2024 16:45:33 +0000 Subject: [PATCH 04/24] Livefit logging docs draft 1 --- doc/fitting/livefit_logger.md | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 doc/fitting/livefit_logger.md diff --git a/doc/fitting/livefit_logger.md b/doc/fitting/livefit_logger.md new file mode 100644 index 0000000..94ef2f9 --- /dev/null +++ b/doc/fitting/livefit_logger.md @@ -0,0 +1,52 @@ +# Fitting Files Callback +## Fitting Files + +The callback (`LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. + +This callback provides you with useful metrics such as `R-squared` and `chi-square`, then providing you with a table of the raw collected data included modelled `y` data and `y` uncertainty. + +### Example +An example of using this could be: + +```{code} python +def some_plan() -> Generator[Msg, None, None]: + ... # Set up prefix, reducers, controllers etc. here + block = block_rw_rbv(float, "mot") + + dae = SimpleDae( + prefix=prefix, + controller=controller, + waiter=waiter, + reducer=reducer, + ) + + lf = LiveFit( + Linear.fit(), y=reducer.intensity.name, x=block.name + ) + + yield from ensure_connected(block, dae, force_reconnect=True) + + @subs_decorator( + [ + LiveFitLogger( + lf, + y=reducer.intensity.name, + x=block.name, + output_dir=Path(f"C:\\Instrument\\Var\\logs\\bluesky\\fitting"), + yerr=reducer.intensity_stddev.name + ) + ... # Other callbacks ie. live table/plot here - you can use multiple! + ] + ) + def _inner() -> Generator[Msg, None, None]: + num_points = 3 + yield from bps.mv(dae.number_of_periods, num_points) + yield from bp.scan([dae], block, 0, 10, num=num_points) + + yield from _inner() + +RE = get_run_engine() +RE(some_plan()) +``` + +This will put the all fitting data collected over the run into a `.csv` file, named after the `uid` of the scan, in the `C:\\Instrument\\Var\\logs\\bluesky\\fitting` path provided to the callback. You may also provide a `postfix` to append to the end of the filename to avoid overwriting fitting files. From f0cf749efad339a8a87564105399c8020d737245 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 10:10:25 +0000 Subject: [PATCH 05/24] Updated unit tests & refactorings --- manual_system_tests/dae_scan.py | 20 +++-- pyproject.toml | 1 + .../callbacks/fitting/__init__.py | 2 +- .../callbacks/fitting/fitting_utils.py | 18 ++--- .../callbacks/fitting/livefit_logger.py | 77 +++++++++++-------- .../fitting/test_fit_logging_callback.py | 50 ++++++------ 6 files changed, 92 insertions(+), 76 deletions(-) diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index 608ac97..428f017 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -73,9 +73,7 @@ def dae_scan_plan() -> Generator[Msg, None, None]: reducer.intensity.set_name("normalized counts") _, ax = plt.subplots() - lf = LiveFit( - Linear.fit(), y=reducer.intensity.name, x=block.name - ) + lf = LiveFit(Linear.fit(), y=reducer.intensity.name, x=block.name) yield from ensure_connected(block, dae, force_reconnect=True) @@ -92,13 +90,7 @@ def dae_scan_plan() -> Generator[Msg, None, None]: ], ), LiveFitPlot(livefit=lf, ax=ax), - LivePlot( - y=reducer.intensity.name, - x=block.name, - marker="x", - linestyle="none", - ax=ax - ), + LivePlot(y=reducer.intensity.name, x=block.name, marker="x", linestyle="none", ax=ax), LiveTable( [ block.name, @@ -110,7 +102,13 @@ def dae_scan_plan() -> Generator[Msg, None, None]: dae.good_frames.name, ] ), - LiveFitLogger(lf, y=reducer.intensity.name, x=block.name, output_dir=Path(f"C:\\Instrument\\Var\\logs\\bluesky\\fitting"), yerr=reducer.intensity_stddev.name) + LiveFitLogger( + lf, + y=reducer.intensity.name, + x=block.name, + output_dir=Path("C:\\Instrument\\Var\\logs\\bluesky\\fitting"), + yerr=reducer.intensity_stddev.name, + ), ] ) def _inner() -> Generator[Msg, None, None]: diff --git a/pyproject.toml b/pyproject.toml index 5efcace..a83fcc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dev = [ "pytest-asyncio", "pytest-cov", "pyqt6", # For dev testing with matplotlib's qt backend. + "ophyd>=1.4.0", ] [project.urls] diff --git a/src/ibex_bluesky_core/callbacks/fitting/__init__.py b/src/ibex_bluesky_core/callbacks/fitting/__init__.py index ad22910..ce6d148 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/__init__.py +++ b/src/ibex_bluesky_core/callbacks/fitting/__init__.py @@ -37,7 +37,7 @@ def __init__( self.guess = guess if callable(model): - self.model = lmfit.Model(model, name=self.guess.__qualname__.split('.')[0]) + self.model = lmfit.Model(model) else: self.model = model diff --git a/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py b/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py index d60a7ff..6bb1949 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py +++ b/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py @@ -70,7 +70,7 @@ def model( return amp * np.exp(-((x - x0) ** 2) / (2 * sigma**2)) + background - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -117,7 +117,7 @@ def model( return amp / (1 + ((x - center) / sigma) ** 2) + background - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -182,7 +182,7 @@ def model(cls, *args: int) -> lmfit.Model: def model(x: npt.NDArray[np.float64], c1: float, c0: float) -> npt.NDArray[np.float64]: return c1 * x + c0 - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -244,7 +244,7 @@ def model( ) -> npt.NDArray[np.float64]: return amp * np.cos((x - center) * freq) * np.exp(-(((x - center) / width) ** 2)) - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -316,7 +316,7 @@ def model( return y - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -375,7 +375,7 @@ def model( ) -> npt.NDArray[np.float64]: return background + scale * scipy.special.erf(stretch * (x - cen)) - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -410,7 +410,7 @@ def model( ) -> npt.NDArray[np.float64]: return background + scale * scipy.special.erfc(stretch * (x - cen)) - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -447,7 +447,7 @@ def model( y[np.abs(x - cen) < width / 2] = height return background + y - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( @@ -498,7 +498,7 @@ def model( y = np.minimum(y, background + height) return y - return lmfit.Model(model) + return lmfit.Model(model, name=__class__.__name__) @classmethod def guess( diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 8bff839..539ee10 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -1,32 +1,46 @@ -from event_model.documents.event import Event +"""Creates a readable .csv file of Bluesky fitting metrics.""" + import csv from pathlib import Path from typing import Optional + +import numpy as np from bluesky.callbacks import CallbackBase -from event_model.documents.run_stop import RunStop +from event_model.documents.event import Event from event_model.documents.run_start import RunStart -from ibex_bluesky_core.logger import logger -import numpy as np +from event_model.documents.run_stop import RunStop + from ibex_bluesky_core.callbacks.fitting import LiveFit +from ibex_bluesky_core.logger import logger UID = "uid" DATA = "data" + class LiveFitLogger(CallbackBase): """Generates files as part of a scan that describe the fit(s) which have been performed.""" - - def __init__(self, livefit: LiveFit, y: str, x: str, output_dir: Path, postfix: str | None = None, yerr : str | None = None) -> None: - """Initialises LiveFitLogger callback. - + + def __init__( + self, + livefit: LiveFit, + y: str, + x: str, + output_dir: Path, + postfix: str | None = None, + yerr: str | None = None, + ) -> None: + """Initialise LiveFitLogger callback. + Args: livefit (LiveFit): A reference to LiveFit callback to collect fit info from. y (str): The name of the signal pointing to y counts data. x (str): The name of the signal pointing to x counts data. output_dir (str): A path to where the fitting file should be stored. - postfix (str): A small string that should be placed at the end of the file name to prevent overwriting. + postfix (str): A small string that should be placed at the end of the + filename to prevent overwriting. yerr (str): The name of the signal pointing to y count uncertainties data. - """ + """ super().__init__() self.livefit = livefit self.postfix = "" if postfix is None else postfix @@ -44,12 +58,13 @@ def __init__(self, livefit: LiveFit, y: str, x: str, output_dir: Path, postfix: self.x_data = np.array([]) self.y_data = np.array([]) self.yerr_data = np.array([]) - + def start(self, doc: RunStart) -> None: """Create the output directory if it doesn't already exist then setting the filename. Args: doc (RunStart): The start bluesky document. + """ self.output_dir.mkdir(parents=True, exist_ok=True) self.current_start_document = doc[UID] @@ -57,11 +72,11 @@ def start(self, doc: RunStart) -> None: def event(self, doc: Event) -> Event: """Start collecting, y, x and yerr data. - + Args: doc: (Event): An event document. + """ - event_data = doc[DATA] assert self.x in event_data @@ -77,12 +92,12 @@ def event(self, doc: Event) -> Event: return super().event(doc) def stop(self, doc: RunStop) -> None: - """Writes to the fitting file. - + """Write to the fitting file. + Args: doc (RunStop): The stop bluesky document. + """ - if self.livefit.result is None: logger.blueskylogger.error("LiveFit.result was None. Could not write to file.") return @@ -95,7 +110,7 @@ def stop(self, doc: RunStop) -> None: self.stats = str(self.livefit.result.fit_report()).split("\n") # Writing to csv file - with open(self.filename, 'w', newline='') as csvfile: + with open(self.filename, "w", newline="") as csvfile: # Writing the data self.csvwriter = csv.writer(csvfile) @@ -108,22 +123,24 @@ def stop(self, doc: RunStop) -> None: self.write_fields_table() csvfile.close() - logger.blueskylogger.info(f"Fitting information successfully written to {self.filename}") - - def write_fields_table(self): - """Write collected run info to the fitting file. - """ + logger.blueskylogger.info( + f"Fitting information successfully written to {self.filename}" + ) - if self.yerr is not None: - row = ["x", "y", "y uncertainty", "modelled y"] - else: - row = ["x", "y", "modelled y"] - + def write_fields_table(self) -> None: + """Write collected run info to the fitting file.""" + row = ["x", "y", "modelled y"] self.csvwriter.writerow(row) for i in range(0, self.x_data.size): self.csvwriter.writerow([self.x_data[i], self.y_data[i], self.y_fit_data[i]]) - # docs - # fix model name?? - # x, indep variable fixed name + def write_fields_table_uncertainty(self) -> None: + """Write collected run info to the fitting file with uncertainties.""" + row = ["x", "y", "y uncertainty", "modelled y"] + self.csvwriter.writerow(row) + + for i in range(0, self.x_data.size): + self.csvwriter.writerow( + [self.x_data[i], self.y_data[i], self.yerr_data[i], self.y_fit_data[i]] + ) diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index 7c172d8..b5a77ab 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -1,32 +1,28 @@ +# pyright: reportMissingParameterType=false from pathlib import Path from unittest.mock import MagicMock, mock_open, patch -from bluesky.plans import scan -import packaging + import pytest +from bluesky.plans import scan +from ophyd.sim import InvariantSignal +from ibex_bluesky_core import run_engine from ibex_bluesky_core.callbacks.fitting import FitMethod, LiveFit from ibex_bluesky_core.callbacks.fitting.fitting_utils import Linear from ibex_bluesky_core.callbacks.fitting.livefit_logger import LiveFitLogger -import ophyd -from ophyd.sim import InvariantSignal # Taken from bluesky bluesky\tests\test_callbacks.py @pytest.fixture(scope="function") -def hw(tmp_path): - +def hw(tmp_path: str): from ophyd.sim import hw - # ophyd 1.4.0 added support for customizing the directory used by simulated - # hardware that generates files - if packaging.version.Version(ophyd.__version__) >= packaging.version.Version("1.4.0"): - return hw(str(tmp_path)) - else: - return hw() - + return hw(str(tmp_path)) -def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity(RE, hw): +def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( + RE: run_engine.RunEngine, hw +): invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) filepath = Path("C:\\") / "instrument" / "var" / "logs" @@ -37,9 +33,10 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity(RE lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): - result = RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) + result = RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) # type: ignore + # "InvariantSignal" is incompatible with protocol "Readable" - assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") + assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore handle = m() args = [] @@ -51,8 +48,9 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity(RE assert "x,y,modelled y\r\n" in args -def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity(RE, hw): - +def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( + RE: run_engine.RunEngine, hw +): invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) uncertainty = InvariantSignal(func=lambda: 1.0, name="uncertainty", labels={"detectors"}) @@ -61,12 +59,15 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( m = mock_open() lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) - lfl = LiveFitLogger(lf, y="invariant", x="motor", yerr="uncertainty", output_dir=filepath, postfix=postfix) + lfl = LiveFitLogger( + lf, y="invariant", x="motor", yerr="uncertainty", output_dir=filepath, postfix=postfix + ) with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): - result = RE(scan([invariant, uncertainty], hw.motor, -1, 1, 3), [lf, lfl]) + result = RE(scan([invariant, uncertainty], hw.motor, -1, 1, 3), [lf, lfl]) # type: ignore + # "InvariantSignal" is incompatible with protocol "Readable" - assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") + assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore handle = m() args = [] @@ -78,8 +79,7 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( assert "x,y,y uncertainty,modelled y\r\n" in args -def test_file_not_written_if_no_fitting_result(RE, hw): - +def test_file_not_written_if_no_fitting_result(RE: run_engine.RunEngine, hw): invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) filepath = Path("C:\\") / "instrument" / "var" / "logs" @@ -94,7 +94,7 @@ def test_file_not_written_if_no_fitting_result(RE, hw): lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): - - RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) + RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) # type: ignore + # "InvariantSignal" is incompatible with protocol "Readable" assert not m.called From ff7f2d9b8bd09dbe8501961da3e7fbda13d25e1a Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 10:47:15 +0000 Subject: [PATCH 06/24] Sphynx fixes --- .../images_fits/dampedosc.PNG | Bin doc/{fitting => _static}/images_fits/erf.png | Bin doc/{fitting => _static}/images_fits/erfc.png | Bin .../images_fits/gaussian.png | Bin .../images_fits/lorentzian.png | Bin .../images_fits/slitscan.png | Bin doc/{fitting => _static}/images_fits/tophat.png | Bin .../images_fits/trapezoid.png | Bin doc/fitting/standard_fits.md | 16 ++++++++-------- 9 files changed, 8 insertions(+), 8 deletions(-) rename doc/{fitting => _static}/images_fits/dampedosc.PNG (100%) rename doc/{fitting => _static}/images_fits/erf.png (100%) rename doc/{fitting => _static}/images_fits/erfc.png (100%) rename doc/{fitting => _static}/images_fits/gaussian.png (100%) rename doc/{fitting => _static}/images_fits/lorentzian.png (100%) rename doc/{fitting => _static}/images_fits/slitscan.png (100%) rename doc/{fitting => _static}/images_fits/tophat.png (100%) rename doc/{fitting => _static}/images_fits/trapezoid.png (100%) diff --git a/doc/fitting/images_fits/dampedosc.PNG b/doc/_static/images_fits/dampedosc.PNG similarity index 100% rename from doc/fitting/images_fits/dampedosc.PNG rename to doc/_static/images_fits/dampedosc.PNG diff --git a/doc/fitting/images_fits/erf.png b/doc/_static/images_fits/erf.png similarity index 100% rename from doc/fitting/images_fits/erf.png rename to doc/_static/images_fits/erf.png diff --git a/doc/fitting/images_fits/erfc.png b/doc/_static/images_fits/erfc.png similarity index 100% rename from doc/fitting/images_fits/erfc.png rename to doc/_static/images_fits/erfc.png diff --git a/doc/fitting/images_fits/gaussian.png b/doc/_static/images_fits/gaussian.png similarity index 100% rename from doc/fitting/images_fits/gaussian.png rename to doc/_static/images_fits/gaussian.png diff --git a/doc/fitting/images_fits/lorentzian.png b/doc/_static/images_fits/lorentzian.png similarity index 100% rename from doc/fitting/images_fits/lorentzian.png rename to doc/_static/images_fits/lorentzian.png diff --git a/doc/fitting/images_fits/slitscan.png b/doc/_static/images_fits/slitscan.png similarity index 100% rename from doc/fitting/images_fits/slitscan.png rename to doc/_static/images_fits/slitscan.png diff --git a/doc/fitting/images_fits/tophat.png b/doc/_static/images_fits/tophat.png similarity index 100% rename from doc/fitting/images_fits/tophat.png rename to doc/_static/images_fits/tophat.png diff --git a/doc/fitting/images_fits/trapezoid.png b/doc/_static/images_fits/trapezoid.png similarity index 100% rename from doc/fitting/images_fits/trapezoid.png rename to doc/_static/images_fits/trapezoid.png diff --git a/doc/fitting/standard_fits.md b/doc/fitting/standard_fits.md index 0744bb3..a1ccd4c 100644 --- a/doc/fitting/standard_fits.md +++ b/doc/fitting/standard_fits.md @@ -29,7 +29,7 @@ y = c_{n}x^n + c_{n-1}x^n-1 + ... + c_1 * x^1 + c_0 y = \text{amp} * e^{-\frac{(x - x0) ^ 2}{2 * \text{sigma}^2}} + \text{background} ``` -![GaussianModel](./images_fits/gaussian.png) +![GaussianModel](../_static/images_fits/gaussian.png) ## Lorentzian @@ -42,7 +42,7 @@ y = \text{amp} * e^{-\frac{(x - x0) ^ 2}{2 * \text{sigma}^2}} + \text{background y = \frac{\text{amp}}{1 + \frac{x - \text{center}}{\text{sigma}}^2} + \text{background} ``` -![LorentzianModel](./images_fits/lorentzian.png) +![LorentzianModel](../_static/images_fits/lorentzian.png) ## Damped Oscillator (DampedOsc) @@ -55,7 +55,7 @@ y = \frac{\text{amp}}{1 + \frac{x - \text{center}}{\text{sigma}}^2} + \text{back y = \text{amp} * \cos((x - \text{center}) * \text{freq}) * e^{-\frac{x - \text{center}}{\text{width}^ 2}} ``` -![DampedOscModel](./images_fits/dampedosc.png) +![DampedOscModel](../_static/images_fits/dampedosc.png) ## Slit Scan (SlitScan) @@ -77,7 +77,7 @@ y = \text{amp} * \cos((x - \text{center}) * \text{freq}) * e^{-\frac{x - \text{c y = \min(\text{lin_seg}, \text{exp_seg}) ``` -![SlitScanModel](./images_fits/slitscan.png) +![SlitScanModel](../_static/images_fits/slitscan.png) ## Error Function (ERF) @@ -90,7 +90,7 @@ y = \min(\text{lin_seg}, \text{exp_seg}) y = background + scale * erf(stretch * (x - cen)) ``` -![ERFModel](./images_fits/erf.png) +![ERFModel](../_static/images_fits/erf.png) ## Complementary Error Function (ERFC) @@ -103,7 +103,7 @@ y = background + scale * erf(stretch * (x - cen)) y = background + scale * erfc(stretch * (x - cen)) ``` -![ERFCModel](./images_fits/erfc.png) +![ERFCModel](../_static/images_fits/erfc.png) ## Top Hat (TopHat) @@ -120,7 +120,7 @@ y = \end{cases} ``` -![TopHatModel](./images_fits/tophat.png) +![TopHatModel](../_static/images_fits/tophat.png) ## Trapezoid @@ -140,4 +140,4 @@ g(x) = \max(f(x), \text{background}) y = \min(g(x), \text{background} + \text{height}) ``` -![TrapezoidModel](./images_fits/trapezoid.png) \ No newline at end of file +![TrapezoidModel](../_static/images_fits/trapezoid.png) \ No newline at end of file From f7fcb930abc472b691fee76595e4e841984ec69b Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 10:53:46 +0000 Subject: [PATCH 07/24] Sphynx fixes --- .../images_fits/{dampedosc.PNG => damped_osc.png} | Bin .../images_fits/{slitscan.png => slit_scan.png} | Bin doc/fitting/standard_fits.md | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename doc/_static/images_fits/{dampedosc.PNG => damped_osc.png} (100%) rename doc/_static/images_fits/{slitscan.png => slit_scan.png} (100%) diff --git a/doc/_static/images_fits/dampedosc.PNG b/doc/_static/images_fits/damped_osc.png similarity index 100% rename from doc/_static/images_fits/dampedosc.PNG rename to doc/_static/images_fits/damped_osc.png diff --git a/doc/_static/images_fits/slitscan.png b/doc/_static/images_fits/slit_scan.png similarity index 100% rename from doc/_static/images_fits/slitscan.png rename to doc/_static/images_fits/slit_scan.png diff --git a/doc/fitting/standard_fits.md b/doc/fitting/standard_fits.md index a1ccd4c..3ea4530 100644 --- a/doc/fitting/standard_fits.md +++ b/doc/fitting/standard_fits.md @@ -55,7 +55,7 @@ y = \frac{\text{amp}}{1 + \frac{x - \text{center}}{\text{sigma}}^2} + \text{back y = \text{amp} * \cos((x - \text{center}) * \text{freq}) * e^{-\frac{x - \text{center}}{\text{width}^ 2}} ``` -![DampedOscModel](../_static/images_fits/dampedosc.png) +![DampedOscModel](../_static/images_fits/damped_osc.png) ## Slit Scan (SlitScan) @@ -77,7 +77,7 @@ y = \text{amp} * \cos((x - \text{center}) * \text{freq}) * e^{-\frac{x - \text{c y = \min(\text{lin_seg}, \text{exp_seg}) ``` -![SlitScanModel](../_static/images_fits/slitscan.png) +![SlitScanModel](../_static/images_fits/slit_scan.png) ## Error Function (ERF) From 44abcf4a40c3a018fb0145abd54b6c3d9939af39 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 11:06:38 +0000 Subject: [PATCH 08/24] Change comment --- manual_system_tests/dae_scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index 428f017..c096ec6 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -114,7 +114,7 @@ def dae_scan_plan() -> Generator[Msg, None, None]: def _inner() -> Generator[Msg, None, None]: num_points = NUM_POINTS yield from bps.mv(dae.number_of_periods, num_points) # type: ignore - # Pyright does not understand as bluesky isn't typed yet + # Pyright does not understand as bluesky isn't fully typed yet yield from bp.scan([dae], block, 0, 10, num=num_points) yield from _inner() From 4afb5690448f393ed4dbee549d95d6edff106927 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 11:21:59 +0000 Subject: [PATCH 09/24] Logging changes --- src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 539ee10..bfab4c2 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -11,11 +11,11 @@ from event_model.documents.run_stop import RunStop from ibex_bluesky_core.callbacks.fitting import LiveFit -from ibex_bluesky_core.logger import logger +import logging UID = "uid" DATA = "data" - +logger = logging.getLogger(__name__) class LiveFitLogger(CallbackBase): """Generates files as part of a scan that describe the fit(s) which have been performed.""" @@ -99,7 +99,7 @@ def stop(self, doc: RunStop) -> None: """ if self.livefit.result is None: - logger.blueskylogger.error("LiveFit.result was None. Could not write to file.") + logger.error("LiveFit.result was None. Could not write to file.") return # Evaluate the model function at equally-spaced points. @@ -123,7 +123,7 @@ def stop(self, doc: RunStop) -> None: self.write_fields_table() csvfile.close() - logger.blueskylogger.info( + logger.info( f"Fitting information successfully written to {self.filename}" ) From c150a08b99947d3f736ef1a318dd7b91c8c4a39c Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 11:23:20 +0000 Subject: [PATCH 10/24] Ruff --- src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index bfab4c2..86e9ef1 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -1,6 +1,7 @@ """Creates a readable .csv file of Bluesky fitting metrics.""" import csv +import logging from pathlib import Path from typing import Optional @@ -11,12 +12,12 @@ from event_model.documents.run_stop import RunStop from ibex_bluesky_core.callbacks.fitting import LiveFit -import logging UID = "uid" DATA = "data" logger = logging.getLogger(__name__) + class LiveFitLogger(CallbackBase): """Generates files as part of a scan that describe the fit(s) which have been performed.""" @@ -123,9 +124,7 @@ def stop(self, doc: RunStop) -> None: self.write_fields_table() csvfile.close() - logger.info( - f"Fitting information successfully written to {self.filename}" - ) + logger.info(f"Fitting information successfully written to {self.filename}") def write_fields_table(self) -> None: """Write collected run info to the fitting file.""" From 2d2263f50452934280444577b6862cd80b185d68 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 15 Nov 2024 11:35:07 +0000 Subject: [PATCH 11/24] Fixes uncertainty logging --- src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py | 7 +++++-- tests/callbacks/fitting/test_fit_logging_callback.py | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 86e9ef1..e69aef4 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -88,7 +88,7 @@ def event(self, doc: Event) -> Event: if self.yerr is not None: assert self.yerr in event_data - np.append(self.yerr_data, [event_data[self.yerr]]) + self.yerr_data = np.append(self.yerr_data, [event_data[self.yerr]]) return super().event(doc) @@ -121,7 +121,10 @@ def stop(self, doc: RunStop) -> None: self.csvwriter.writerow([]) self.csvwriter.writerow([]) - self.write_fields_table() + if self.yerr is None: + self.write_fields_table() + else: + self.write_fields_table_uncertainty() csvfile.close() logger.info(f"Fitting information successfully written to {self.filename}") diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index b5a77ab..566e248 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -75,7 +75,6 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( # Check that it starts writing to the file in the expected way for i in handle.write.call_args_list: args.append(i.args[0]) - assert "x,y,y uncertainty,modelled y\r\n" in args From dd0e158bc8aec6897ee3c70f0a2a5d9b15829ca1 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 20 Nov 2024 11:14:21 +0000 Subject: [PATCH 12/24] Some review changes --- .../callbacks/fitting/livefit_logger.py | 26 ++++++------- .../fitting/test_fit_logging_callback.py | 39 +++++++------------ 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index e69aef4..55aa5e5 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -27,7 +27,7 @@ def __init__( y: str, x: str, output_dir: Path, - postfix: str | None = None, + postfix: str = "", yerr: str | None = None, ) -> None: """Initialise LiveFitLogger callback. @@ -44,7 +44,7 @@ def __init__( """ super().__init__() self.livefit = livefit - self.postfix = "" if postfix is None else postfix + self.postfix = postfix # can be none or no? self.output_dir = output_dir self.current_start_document: Optional[str] = None @@ -52,13 +52,9 @@ def __init__( self.y = y self.yerr = yerr - assert self.x != "" - assert self.y != "" - assert self.yerr != "" - - self.x_data = np.array([]) - self.y_data = np.array([]) - self.yerr_data = np.array([]) + self.x_data = [] + self.y_data = [] + self.yerr_data = [] def start(self, doc: RunStart) -> None: """Create the output directory if it doesn't already exist then setting the filename. @@ -83,12 +79,12 @@ def event(self, doc: Event) -> Event: assert self.x in event_data assert self.y in event_data - self.x_data = np.append(self.x_data, [event_data[self.x]]) - self.y_data = np.append(self.y_data, [event_data[self.y]]) + self.x_data.append(event_data[self.x]) + self.y_data.append(event_data[self.y]) if self.yerr is not None: assert self.yerr in event_data - self.yerr_data = np.append(self.yerr_data, [event_data[self.yerr]]) + self.yerr_data.append(event_data[self.yerr]) return super().event(doc) @@ -104,7 +100,7 @@ def stop(self, doc: RunStop) -> None: return # Evaluate the model function at equally-spaced points. - kwargs = {"x": self.x_data} + kwargs = {"x": np.array(self.x_data)} kwargs.update(self.livefit.result.values) self.y_fit_data = self.livefit.result.model.eval(**kwargs) @@ -134,7 +130,7 @@ def write_fields_table(self) -> None: row = ["x", "y", "modelled y"] self.csvwriter.writerow(row) - for i in range(0, self.x_data.size): + for i in range(0, len(self.x_data)): self.csvwriter.writerow([self.x_data[i], self.y_data[i], self.y_fit_data[i]]) def write_fields_table_uncertainty(self) -> None: @@ -142,7 +138,7 @@ def write_fields_table_uncertainty(self) -> None: row = ["x", "y", "y uncertainty", "modelled y"] self.csvwriter.writerow(row) - for i in range(0, self.x_data.size): + for i in range(0, len(self.x_data)): self.csvwriter.writerow( [self.x_data[i], self.y_data[i], self.yerr_data[i], self.y_fit_data[i]] ) diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index 566e248..2f8703a 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -2,9 +2,8 @@ from pathlib import Path from unittest.mock import MagicMock, mock_open, patch -import pytest from bluesky.plans import scan -from ophyd.sim import InvariantSignal +from ophyd_async.core import soft_signal_rw from ibex_bluesky_core import run_engine from ibex_bluesky_core.callbacks.fitting import FitMethod, LiveFit @@ -12,18 +11,11 @@ from ibex_bluesky_core.callbacks.fitting.livefit_logger import LiveFitLogger -# Taken from bluesky bluesky\tests\test_callbacks.py -@pytest.fixture(scope="function") -def hw(tmp_path: str): - from ophyd.sim import hw - - return hw(str(tmp_path)) - - def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( - RE: run_engine.RunEngine, hw + RE: run_engine.RunEngine, ): - invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) + invariant = soft_signal_rw(float, 0.5, name="invariant") + mot = soft_signal_rw(float, name="motor") filepath = Path("C:\\") / "instrument" / "var" / "logs" postfix = "fit1" @@ -33,8 +25,7 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): - result = RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) # type: ignore - # "InvariantSignal" is incompatible with protocol "Readable" + result = RE(scan([invariant], mot, -1, 1, 3), [lf, lfl]) assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore @@ -49,10 +40,11 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( - RE: run_engine.RunEngine, hw + RE: run_engine.RunEngine, ): - invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) - uncertainty = InvariantSignal(func=lambda: 1.0, name="uncertainty", labels={"detectors"}) + uncertainty = soft_signal_rw(float, 1.0, name="uncertainty") + invariant = soft_signal_rw(float, 0.5, name="invariant") + mot = soft_signal_rw(float, name="motor") filepath = Path("C:\\") / "instrument" / "var" / "logs" postfix = "fit1" @@ -60,12 +52,11 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) lfl = LiveFitLogger( - lf, y="invariant", x="motor", yerr="uncertainty", output_dir=filepath, postfix=postfix + lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix, yerr="uncertainty" ) with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): - result = RE(scan([invariant, uncertainty], hw.motor, -1, 1, 3), [lf, lfl]) # type: ignore - # "InvariantSignal" is incompatible with protocol "Readable" + result = RE(scan([invariant, uncertainty], mot, -1, 1, 3), [lf, lfl]) assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore @@ -78,8 +69,9 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( assert "x,y,y uncertainty,modelled y\r\n" in args -def test_file_not_written_if_no_fitting_result(RE: run_engine.RunEngine, hw): - invariant = InvariantSignal(func=lambda: 0.5, name="invariant", labels={"detectors"}) +def test_file_not_written_if_no_fitting_result(RE: run_engine.RunEngine): + invariant = soft_signal_rw(float, 0.5, name="invariant") + mot = soft_signal_rw(float, name="motor") filepath = Path("C:\\") / "instrument" / "var" / "logs" postfix = "fit1" @@ -93,7 +85,6 @@ def test_file_not_written_if_no_fitting_result(RE: run_engine.RunEngine, hw): lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) with patch("ibex_bluesky_core.callbacks.fitting.livefit_logger.open", m): - RE(scan([invariant], hw.motor, -1, 1, 3), [lf, lfl]) # type: ignore - # "InvariantSignal" is incompatible with protocol "Readable" + RE(scan([invariant], mot, -1, 1, 3), [lf, lfl]) assert not m.called From 3b1649f4a32db7c104150d3584546f3ad0fe01d7 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 20 Nov 2024 15:17:59 +0000 Subject: [PATCH 13/24] Requested changes --- doc/fitting/livefit_logger.md | 35 +++++-------------- manual_system_tests/dae_scan.py | 1 + pyproject.toml | 1 - .../callbacks/fitting/livefit_logger.py | 20 +++++------ 4 files changed, 19 insertions(+), 38 deletions(-) diff --git a/doc/fitting/livefit_logger.md b/doc/fitting/livefit_logger.md index 94ef2f9..637dc70 100644 --- a/doc/fitting/livefit_logger.md +++ b/doc/fitting/livefit_logger.md @@ -1,7 +1,7 @@ # Fitting Files Callback ## Fitting Files -The callback (`LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. +The callback (`LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. These are designed to be human readable files rather than machine readable. This callback provides you with useful metrics such as `R-squared` and `chi-square`, then providing you with a table of the raw collected data included modelled `y` data and `y` uncertainty. @@ -11,42 +11,25 @@ An example of using this could be: ```{code} python def some_plan() -> Generator[Msg, None, None]: ... # Set up prefix, reducers, controllers etc. here - block = block_rw_rbv(float, "mot") - - dae = SimpleDae( - prefix=prefix, - controller=controller, - waiter=waiter, - reducer=reducer, - ) - - lf = LiveFit( - Linear.fit(), y=reducer.intensity.name, x=block.name - ) - - yield from ensure_connected(block, dae, force_reconnect=True) @subs_decorator( [ LiveFitLogger( - lf, + lf, # LiveFit y=reducer.intensity.name, x=block.name, output_dir=Path(f"C:\\Instrument\\Var\\logs\\bluesky\\fitting"), - yerr=reducer.intensity_stddev.name + postfix="bob" # Make sure to have different postfixes if using + # more than 1 LiveFitLogger per run + yerr=reducer.intensity_stddev.name, # Not required ) ... # Other callbacks ie. live table/plot here - you can use multiple! ] ) def _inner() -> Generator[Msg, None, None]: - num_points = 3 - yield from bps.mv(dae.number_of_periods, num_points) - yield from bp.scan([dae], block, 0, 10, num=num_points) - - yield from _inner() - -RE = get_run_engine() -RE(some_plan()) + ... # Continue to plan ``` -This will put the all fitting data collected over the run into a `.csv` file, named after the `uid` of the scan, in the `C:\\Instrument\\Var\\logs\\bluesky\\fitting` path provided to the callback. You may also provide a `postfix` to append to the end of the filename to avoid overwriting fitting files. +This will put the all fitting data collected over the run into a `.csv` file, named after the `uid` of the scan, in the `C:\\Instrument\\Var\\logs\\bluesky\\fitting` path provided to the callback. You should provide a `postfix` to append to the end of the filename to disambiguate different fits and to avoid overwriting fitting files- it is only one file per fit completed. + +If you provide a signal name for the `yerr` argument then an extra column for `y uncertainty` will be displayed in the fitting file. You have the option to not provide anything for this argument if you do not want to have uncertainty information in your fitting file. Keep in mind that even if you provide `yerr` in `LiveFitLogger`, you will still need to provide `yerr` in `LiveFit` if you want uncertainty/weight per point to influence the fit. diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index c096ec6..b4fd2f8 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -108,6 +108,7 @@ def dae_scan_plan() -> Generator[Msg, None, None]: x=block.name, output_dir=Path("C:\\Instrument\\Var\\logs\\bluesky\\fitting"), yerr=reducer.intensity_stddev.name, + postfix="manual_test", ), ] ) diff --git a/pyproject.toml b/pyproject.toml index 6c287c7..b1d25b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,6 @@ dev = [ "pytest-asyncio", "pytest-cov", "pyqt6", # For dev testing with matplotlib's qt backend. - "ophyd>=1.4.0", ] [project.urls] diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 55aa5e5..455a833 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -27,7 +27,7 @@ def __init__( y: str, x: str, output_dir: Path, - postfix: str = "", + postfix: str, yerr: str | None = None, ) -> None: """Initialise LiveFitLogger callback. @@ -38,7 +38,7 @@ def __init__( x (str): The name of the signal pointing to x counts data. output_dir (str): A path to where the fitting file should be stored. postfix (str): A small string that should be placed at the end of the - filename to prevent overwriting. + filename to disambiguate multiple fits and avoid overwriting. yerr (str): The name of the signal pointing to y count uncertainties data. """ @@ -76,14 +76,14 @@ def event(self, doc: Event) -> Event: """ event_data = doc[DATA] - assert self.x in event_data - assert self.y in event_data + assert self.x in event_data, f"{self.x} is not in event document" + assert self.y in event_data, f"{self.y} is not in event document" self.x_data.append(event_data[self.x]) self.y_data.append(event_data[self.y]) if self.yerr is not None: - assert self.yerr in event_data + assert self.yerr in event_data, f"{self.yerr} is not in event document" self.yerr_data.append(event_data[self.yerr]) return super().event(doc) @@ -130,15 +130,13 @@ def write_fields_table(self) -> None: row = ["x", "y", "modelled y"] self.csvwriter.writerow(row) - for i in range(0, len(self.x_data)): - self.csvwriter.writerow([self.x_data[i], self.y_data[i], self.y_fit_data[i]]) + rows = zip(self.x_data, self.y_data, self.y_fit_data, strict=True) + self.csvwriter.writerows(rows) def write_fields_table_uncertainty(self) -> None: """Write collected run info to the fitting file with uncertainties.""" row = ["x", "y", "y uncertainty", "modelled y"] self.csvwriter.writerow(row) - for i in range(0, len(self.x_data)): - self.csvwriter.writerow( - [self.x_data[i], self.y_data[i], self.yerr_data[i], self.y_fit_data[i]] - ) + rows = zip(self.x_data, self.y_data, self.yerr_data, self.y_fit_data, strict=True) + self.csvwriter.writerows(rows) From 576187a8f785e6360d3820b9174c2cf92183cd47 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 20 Nov 2024 15:53:38 +0000 Subject: [PATCH 14/24] ruff --- manual_system_tests/dae_scan.py | 1 - 1 file changed, 1 deletion(-) diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index 9237d32..80b8cb0 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -122,7 +122,6 @@ def dae_scan_plan() -> Generator[Msg, None, None]: ] ) def _inner() -> Generator[Msg, None, None]: - yield from bps.mv(dae.number_of_periods, NUM_POINTS) # type: ignore # Pyright does not understand as bluesky isn't typed yet yield from bp.scan([dae], block, 0, 10, num=NUM_POINTS) From 44aa75316e9c4ae53443eedb28b74d02a9a54fb3 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 20 Nov 2024 16:07:19 +0000 Subject: [PATCH 15/24] update comments --- .../callbacks/fitting/livefit_logger.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 455a833..1fc7c7e 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -44,7 +44,7 @@ def __init__( """ super().__init__() self.livefit = livefit - self.postfix = postfix # can be none or no? + self.postfix = postfix self.output_dir = output_dir self.current_start_document: Optional[str] = None @@ -76,14 +76,14 @@ def event(self, doc: Event) -> Event: """ event_data = doc[DATA] - assert self.x in event_data, f"{self.x} is not in event document" - assert self.y in event_data, f"{self.y} is not in event document" + assert self.x in event_data, f"{self.x} is not in event document." + assert self.y in event_data, f"{self.y} is not in event document." self.x_data.append(event_data[self.x]) self.y_data.append(event_data[self.y]) if self.yerr is not None: - assert self.yerr in event_data, f"{self.yerr} is not in event document" + assert self.yerr in event_data, f"{self.yerr} is not in event document." self.yerr_data.append(event_data[self.yerr]) return super().event(doc) @@ -114,7 +114,7 @@ def stop(self, doc: RunStop) -> None: for row in self.stats: self.csvwriter.writerow([row]) - self.csvwriter.writerow([]) + self.csvwriter.writerow([]) # Space out file self.csvwriter.writerow([]) if self.yerr is None: From 666519c2f7569d083761192445029779c566b2ed Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 20 Nov 2024 16:10:17 +0000 Subject: [PATCH 16/24] ruff --- src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 1fc7c7e..31e930e 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -114,7 +114,7 @@ def stop(self, doc: RunStop) -> None: for row in self.stats: self.csvwriter.writerow([row]) - self.csvwriter.writerow([]) # Space out file + self.csvwriter.writerow([]) # Space out file self.csvwriter.writerow([]) if self.yerr is None: From eb67d7474557c9ebdb791111fdf864eb8d60ef3a Mon Sep 17 00:00:00 2001 From: Jack Doughty <56323305+jackbdoughty@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:17:41 +0000 Subject: [PATCH 17/24] Update dae_scan.py --- manual_system_tests/dae_scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index 80b8cb0..dd8a55c 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -52,7 +52,7 @@ def dae_scan_plan() -> Generator[Msg, None, None]: - The DAE waited for at least 500 good frames at each point """ prefix = get_pv_prefix() - block = block_rw_rbv(float, "bob") + block = block_rw_rbv(float, "mot") controller = RunPerPointController(save_run=True) waiter = GoodFramesWaiter(500) From ce807d132b48eea17b476a446f3aecdcaa14d126 Mon Sep 17 00:00:00 2001 From: Jack Doughty <56323305+jackbdoughty@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:51:22 +0000 Subject: [PATCH 18/24] Update livefit_logger.py --- src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 31e930e..7f5cb8d 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -122,7 +122,6 @@ def stop(self, doc: RunStop) -> None: else: self.write_fields_table_uncertainty() - csvfile.close() logger.info(f"Fitting information successfully written to {self.filename}") def write_fields_table(self) -> None: From 049f82568b4fb563a31009df9d068fd49c851f6b Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Fri, 22 Nov 2024 17:14:56 +0000 Subject: [PATCH 19/24] model equations placed in fitting file --- .../callbacks/fitting/fitting_utils.py | 44 +++++++++++++++---- .../fitting/test_fit_logging_callback.py | 15 ++++--- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py b/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py index 47906cf..fd47aa0 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py +++ b/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py @@ -17,6 +17,8 @@ class Fit(ABC): """Base class for all fits.""" + equation: str = "" + @classmethod @abstractmethod def model(cls, *args: int) -> lmfit.Model: @@ -58,6 +60,8 @@ def fit(cls, *args: int) -> FitMethod: class Gaussian(Fit): """Gaussian Fitting.""" + equation = "amp * exp(-((x - x0) ** 2) / (2 * sigma**2)) + background" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Gaussian Model.""" @@ -70,7 +74,7 @@ def model( return amp * np.exp(-((x - x0) ** 2) / (2 * sigma**2)) + background - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -105,6 +109,8 @@ def guess( class Lorentzian(Fit): """Lorentzian Fitting.""" + equation = "amp / (1 + ((x - center) / sigma) ** 2) + background" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Lorentzian Model.""" @@ -117,7 +123,7 @@ def model( return amp / (1 + ((x - center) / sigma) ** 2) + background - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -175,6 +181,8 @@ def guess( class Linear(Fit): """Linear Fitting.""" + equation = "c1 * x + c0" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Linear Model.""" @@ -182,7 +190,7 @@ def model(cls, *args: int) -> lmfit.Model: def model(x: npt.NDArray[np.float64], c1: float, c0: float) -> npt.NDArray[np.float64]: return c1 * x + c0 - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -195,6 +203,8 @@ def guess( class Polynomial(Fit): """Polynomial Fitting.""" + equation = "cn * x^n + ... + c1 * x^1 + c0" + @classmethod def _check_degree(cls, args: tuple[int, ...]) -> int: """Check that polynomial degree is valid.""" @@ -234,6 +244,8 @@ def guess( class DampedOsc(Fit): """Damped Oscillator Fitting.""" + equation = "amp * cos((x - center) * freq) * exp(-(((x - center) / width) ** 2))" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Damped Oscillator Model.""" @@ -243,7 +255,7 @@ def model( ) -> npt.NDArray[np.float64]: return amp * np.cos((x - center) * freq) * np.exp(-(((x - center) / width) ** 2)) - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -272,6 +284,10 @@ def guess( class SlitScan(Fit): """Slit Scan Fitting.""" + equation = """See + https://isiscomputinggroup.github.io/ibex_bluesky_core/fitting/standard_fits.html#slit-scan-slitscan + for model function""" + @classmethod def _check_input(cls, args: tuple[int, ...]) -> int: """Check that provided maximum slit size is atleast 0.""" @@ -315,7 +331,7 @@ def model( return y - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -368,6 +384,8 @@ def guess( class ERF(Fit): """Error Function Fitting.""" + equation = "background + scale * erf(stretch * (x - cen))" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Error Function Model.""" @@ -377,7 +395,7 @@ def model( ) -> npt.NDArray[np.float64]: return background + scale * scipy.special.erf(stretch * (x - cen)) - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -403,6 +421,8 @@ def guess( class ERFC(Fit): """Complementary Error Function Fitting.""" + equation = "background + scale * erfc(stretch * (x - cen))" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Complementary Error Function Model.""" @@ -412,7 +432,7 @@ def model( ) -> npt.NDArray[np.float64]: return background + scale * scipy.special.erfc(stretch * (x - cen)) - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -438,6 +458,8 @@ def guess( class TopHat(Fit): """Top Hat Fitting.""" + equation = "if (abs(x - cen) < width / 2) { background + height } else { background }" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Top Hat Model.""" @@ -449,7 +471,7 @@ def model( y[np.abs(x - cen) < width / 2] = height return background + y - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( @@ -483,6 +505,10 @@ def guess( class Trapezoid(Fit): """Trapezoid Fitting.""" + equation = """ + y = clip(y_offset + height + background - gradient * abs(x - cen), + background, background + height)""" + @classmethod def model(cls, *args: int) -> lmfit.Model: """Trapezoid Model.""" @@ -500,7 +526,7 @@ def model( y = np.minimum(y, background + height) return y - return lmfit.Model(model, name=__class__.__name__) + return lmfit.Model(model, name=f"{cls.__name__} [{cls.equation}]") @classmethod def guess( diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index 2f8703a..14d54d5 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -30,13 +30,14 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore handle = m() - args = [] + rows = [] # Check that it starts writing to the file in the expected way for i in handle.write.call_args_list: - args.append(i.args[0]) + rows.append(i.args[0]) - assert "x,y,modelled y\r\n" in args + assert f" Model({Linear.__name__} [{Linear.equation}])\r\n" in rows + assert "x,y,modelled y\r\n" in rows def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( @@ -61,12 +62,14 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore handle = m() - args = [] + rows = [] # Check that it starts writing to the file in the expected way for i in handle.write.call_args_list: - args.append(i.args[0]) - assert "x,y,y uncertainty,modelled y\r\n" in args + rows.append(i.args[0]) + + assert f" Model({Linear.__name__} [{Linear.equation}])\r\n" in rows + assert "x,y,y uncertainty,modelled y\r\n" in rows def test_file_not_written_if_no_fitting_result(RE: run_engine.RunEngine): From 1f06cd034c6287c935b8abdc439c212ee4630bfa Mon Sep 17 00:00:00 2001 From: Jack Doughty <56323305+jackbdoughty@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:24:52 +0000 Subject: [PATCH 20/24] Update livefit_logger.py --- src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index 7f5cb8d..beb5d7b 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -99,7 +99,7 @@ def stop(self, doc: RunStop) -> None: logger.error("LiveFit.result was None. Could not write to file.") return - # Evaluate the model function at equally-spaced points. + # Evaluate the model function for each x point kwargs = {"x": np.array(self.x_data)} kwargs.update(self.livefit.result.values) self.y_fit_data = self.livefit.result.model.eval(**kwargs) From d8645795d26c8306c2d7647b5b0d74dc50c41e66 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 4 Dec 2024 15:44:29 +0000 Subject: [PATCH 21/24] requested changes --- doc/fitting/livefit_logger.md | 2 +- .../callbacks/fitting/livefit_logger.py | 18 ++++-- .../fitting/test_fit_logging_callback.py | 63 +++++++++++++++++-- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/doc/fitting/livefit_logger.md b/doc/fitting/livefit_logger.md index 637dc70..900c6e7 100644 --- a/doc/fitting/livefit_logger.md +++ b/doc/fitting/livefit_logger.md @@ -1,7 +1,7 @@ # Fitting Files Callback ## Fitting Files -The callback (`LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. These are designed to be human readable files rather than machine readable. +The callback ({py:obj}`ibex_bluesky_core.fitting.livefit_logger.LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. These are designed to be human readable files rather than machine readable. This callback provides you with useful metrics such as `R-squared` and `chi-square`, then providing you with a table of the raw collected data included modelled `y` data and `y` uncertainty. diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index beb5d7b..d8b9f49 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -76,14 +76,18 @@ def event(self, doc: Event) -> Event: """ event_data = doc[DATA] - assert self.x in event_data, f"{self.x} is not in event document." - assert self.y in event_data, f"{self.y} is not in event document." + if self.x not in event_data: + raise OSError(f"{self.x} is not in event document.") + + if self.y not in event_data: + raise OSError(f"{self.y} is not in event document.") self.x_data.append(event_data[self.x]) self.y_data.append(event_data[self.y]) if self.yerr is not None: - assert self.yerr in event_data, f"{self.yerr} is not in event document." + if self.yerr not in event_data: + raise OSError(f"{self.yerr} is not in event document.") self.yerr_data.append(event_data[self.yerr]) return super().event(doc) @@ -104,10 +108,11 @@ def stop(self, doc: RunStop) -> None: kwargs.update(self.livefit.result.values) self.y_fit_data = self.livefit.result.model.eval(**kwargs) - self.stats = str(self.livefit.result.fit_report()).split("\n") + self.stats = str(self.livefit.result.fit_report()) + self.stats = self.stats.replace('"', "").split("\n") # Writing to csv file - with open(self.filename, "w", newline="") as csvfile: + with open(self.filename, "w", newline="", encoding="utf-8") as csvfile: # Writing the data self.csvwriter = csv.writer(csvfile) @@ -122,7 +127,8 @@ def stop(self, doc: RunStop) -> None: else: self.write_fields_table_uncertainty() - logger.info(f"Fitting information successfully written to {self.filename}") + logging.basicConfig(format="%(message)s", level=logging.INFO) + logger.info("Fitting information successfully written to: %s", self.filename.name) def write_fields_table(self) -> None: """Write collected run info to the fitting file.""" diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index 14d54d5..3bf2910 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -2,6 +2,7 @@ from pathlib import Path from unittest.mock import MagicMock, mock_open, patch +import pytest from bluesky.plans import scan from ophyd_async.core import soft_signal_rw @@ -30,11 +31,9 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore handle = m() - rows = [] + rows = [i.args[0] for i in handle.write.call_args_list] # Check that it starts writing to the file in the expected way - for i in handle.write.call_args_list: - rows.append(i.args[0]) assert f" Model({Linear.__name__} [{Linear.equation}])\r\n" in rows assert "x,y,modelled y\r\n" in rows @@ -62,11 +61,9 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( assert m.call_args_list[0].args == (filepath / f"{result.run_start_uids[0]}{postfix}.csv", "w") # type: ignore handle = m() - rows = [] + rows = [i.args[0] for i in handle.write.call_args_list] # Check that it starts writing to the file in the expected way - for i in handle.write.call_args_list: - rows.append(i.args[0]) assert f" Model({Linear.__name__} [{Linear.equation}])\r\n" in rows assert "x,y,y uncertainty,modelled y\r\n" in rows @@ -91,3 +88,57 @@ def test_file_not_written_if_no_fitting_result(RE: run_engine.RunEngine): RE(scan([invariant], mot, -1, 1, 3), [lf, lfl]) assert not m.called + + +def test_error_thrown_if_no_x_data_in_event(RE: run_engine.RunEngine): + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + + lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) + lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) + + with pytest.raises(IOError, match="motor is not in event document."): + lfl.event( + { + "data": { # type: ignore + "invariant": 2, + } + } + ) + + +def test_error_thrown_if_no_y_data_in_event(RE: run_engine.RunEngine): + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + + lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) + lfl = LiveFitLogger(lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix) + + with pytest.raises(IOError, match="invariant is not in event document."): + lfl.event( + { + "data": { # type: ignore + "motor": 2, + } + } + ) + + +def test_error_thrown_if_no_y_err_data_in_event(RE: run_engine.RunEngine): + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + + lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) + lfl = LiveFitLogger( + lf, y="invariant", x="motor", output_dir=filepath, postfix=postfix, yerr="yerr" + ) + + with pytest.raises(IOError, match="yerr is not in event document."): + lfl.event( + { + "data": { # type: ignore + "motor": 2, + "invariant": 2, + } + } + ) From 3323b321b69b4dbf90aa552f27e30f8cab4c0280 Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 4 Dec 2024 15:53:41 +0000 Subject: [PATCH 22/24] Fixes docs --- doc/fitting/livefit_logger.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/fitting/livefit_logger.md b/doc/fitting/livefit_logger.md index 900c6e7..d92aff5 100644 --- a/doc/fitting/livefit_logger.md +++ b/doc/fitting/livefit_logger.md @@ -1,7 +1,7 @@ # Fitting Files Callback ## Fitting Files -The callback ({py:obj}`ibex_bluesky_core.fitting.livefit_logger.LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. These are designed to be human readable files rather than machine readable. +The callback ({py:obj}`ibex_bluesky_core.callbacks.fitting.livefit_logger.LiveFitLogger`) exists to write all fitting metrics from `LiveFit` to file. These are designed to be human readable files rather than machine readable. This callback provides you with useful metrics such as `R-squared` and `chi-square`, then providing you with a table of the raw collected data included modelled `y` data and `y` uncertainty. From 152a5dd0df829ad241bbd7bdad35d2ff3c4b5c5f Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Wed, 4 Dec 2024 16:56:44 +0000 Subject: [PATCH 23/24] Fixes file formatting --- manual_system_tests/dae_scan.py | 1 + .../callbacks/fitting/livefit_logger.py | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index e488c1f..e2c750e 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -127,6 +127,7 @@ def _inner() -> Generator[Msg, None, None]: yield from bps.mv(dae.number_of_periods, NUM_POINTS) # type: ignore # Pyright does not understand as bluesky isn't typed yet yield from bp.scan([dae], block, 0, 10, num=NUM_POINTS) + print(lf.result.fit_report()) yield from _inner() diff --git a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py index d8b9f49..6523b4a 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py +++ b/src/ibex_bluesky_core/callbacks/fitting/livefit_logger.py @@ -2,6 +2,7 @@ import csv import logging +import os from pathlib import Path from typing import Optional @@ -108,8 +109,7 @@ def stop(self, doc: RunStop) -> None: kwargs.update(self.livefit.result.values) self.y_fit_data = self.livefit.result.model.eval(**kwargs) - self.stats = str(self.livefit.result.fit_report()) - self.stats = self.stats.replace('"', "").split("\n") + self.stats = self.livefit.result.fit_report().split("\n") # Writing to csv file with open(self.filename, "w", newline="", encoding="utf-8") as csvfile: @@ -117,18 +117,17 @@ def stop(self, doc: RunStop) -> None: self.csvwriter = csv.writer(csvfile) for row in self.stats: - self.csvwriter.writerow([row]) + csvfile.write(row + os.linesep) - self.csvwriter.writerow([]) # Space out file - self.csvwriter.writerow([]) + csvfile.write(os.linesep) # Space out file + csvfile.write(os.linesep) if self.yerr is None: self.write_fields_table() else: self.write_fields_table_uncertainty() - logging.basicConfig(format="%(message)s", level=logging.INFO) - logger.info("Fitting information successfully written to: %s", self.filename.name) + logger.info("Fitting information successfully written to: %s", self.filename.resolve()) def write_fields_table(self) -> None: """Write collected run info to the fitting file.""" From bafb31bf5fd06bc252d97ab8388c6c854191163d Mon Sep 17 00:00:00 2001 From: Jack Doughty Date: Mon, 9 Dec 2024 10:04:48 +0000 Subject: [PATCH 24/24] line ending changes --- tests/callbacks/fitting/test_fit_logging_callback.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index 3bf2910..2eb59b7 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -1,4 +1,5 @@ # pyright: reportMissingParameterType=false +import os from pathlib import Path from unittest.mock import MagicMock, mock_open, patch @@ -35,7 +36,7 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainity( # Check that it starts writing to the file in the expected way - assert f" Model({Linear.__name__} [{Linear.equation}])\r\n" in rows + assert f" Model({Linear.__name__} [{Linear.equation}])" + os.linesep in rows assert "x,y,modelled y\r\n" in rows @@ -65,7 +66,7 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainity( # Check that it starts writing to the file in the expected way - assert f" Model({Linear.__name__} [{Linear.equation}])\r\n" in rows + assert f" Model({Linear.__name__} [{Linear.equation}])" + os.linesep in rows assert "x,y,y uncertainty,modelled y\r\n" in rows