diff --git a/doc/architectural_decisions/005-variance-addition.md b/doc/architectural_decisions/005-variance-addition.md new file mode 100644 index 0000000..d9ee84f --- /dev/null +++ b/doc/architectural_decisions/005-variance-addition.md @@ -0,0 +1,58 @@ +# Variance addition to counts data + +## Status + +Current + +## Context + +For counts data, the uncertainty on counts is typically defined by poisson counting statistics, i.e. the standard deviation on `N` counts is `sqrt(N)`. + +This can be problematic in cases where zero counts have been collected, as the standard deviation will then be zero, which will subsequently lead to "infinite" point weightings in downstream fitting routines for example. + +A number of possible approaches were considered: + +| Option | Description | +| --- | --- | +| A | Reject data with zero counts, i.e. explicitly throw an exception if any data with zero counts is seen as part of a scan. | +| B | Use a standard deviation of `NaN` for points with zero counts. | +| C | Define the standard deviation of `N` counts as `1` if counts are zero, otherwise `sqrt(N)`. This is one of the approaches available in mantid for example. | +| D | Define the standard deviation of `N` counts as `sqrt(N+0.5)` unconditionally - on the basis that "half a count" is smaller than the smallest possible actual measurement which can be taken. | +| E | No special handling, calculate std. dev. as `sqrt(N)`. | + +For clarity, the following table shows the value and associated uncertainty for each option: + +| Counts | Std. Dev. (A) | Std. Dev. (B) | Std. Dev. (C) | Std. Dev. (D) | Std. Dev. (E) | +| ------- | ------ | ------- | ------- | ------- | --- | +| 0 | raise exception | NaN | 1 | 0.707 | 0 | +| 1 | 1 | 1 | 1 | 1.224745 | 1 | +| 2 | 1.414214 | 1.414214 | 1.414214 | 1.581139 | 1.414214 | +| 3 | 1.732051 | 1.732051 | 1.732051 | 1.870829 | 1.732051 | +| 4 | 2 | 2 | 2 | 2.12132 | 2 | +| 5 | 2.236068 | 2.236068 | 2.236068 | 2.345208 | 2.236068 | +| 10 | 3.162278 | 3.162278 | 3.162278 | 3.24037 | 3.162278 | +| 50 | 7.071068 | 7.071068 | 7.071068 | 7.106335 | 7.071068 | +| 100 | 10 | 10 | 10 | 10.02497 | 10 | +| 500 | 22.36068 | 22.36068 | 22.36068 | 22.37186 | 22.36068 | +| 1000 | 31.62278 | 31.62278 | 31.62278 | 31.63068 | 31.62278 | +| 5000 | 70.71068 | 70.71068 | 70.71068 | 70.71421 | 70.71068 | +| 10000 | 100 | 100 | 100 | 100.0025 | 100 | + +## Present + +These approaches were discussed in a regular project update meeting including +- TW & FA (Experiment controls) +- CK (Reflectometry) +- JL (Muons) +- RD (SANS) + +## Decision + +The consensus was to go with Option D. + +## Justification + +- Option A will cause real-life scans to crash in low counts regions. +- Option B involves `NaN`s, which have many surprising floating-point characteristics and are highly likely to be a source of future bugs. +- Option D was preferred to option C by scientists present. +- Option E causes surprising results and/or crashes downstream, for example fitting may consider points with zero uncertainty to have "infinite" weight, therefore effectively disregarding all other data. diff --git a/doc/callbacks/plotting.md b/doc/callbacks/plotting.md index 0bca52d..5bfffba 100644 --- a/doc/callbacks/plotting.md +++ b/doc/callbacks/plotting.md @@ -38,9 +38,12 @@ ax = plt.gca() # Set the y-scale to logarithmic ax.set_yscale("log") # Use the above axes in a LivePlot callback -plot_callback = LivePlot(y="y_variable", x="x_variable", ax=ax) +plot_callback = LivePlot(y="y_variable", x="x_variable", ax=ax, yerr="yerr_variable") +# yerr is the uncertanties of each y value, producing error bars ``` +By providing a signal name to the `yerr` argument you can pass uncertainties to LivePlot, by not providing anything for this argument means that no errorbars will be drawn. Errorbars are drawn after each point collected, displaying their standard deviation- uncertainty data is collected from Bluesky event documents and errorbars are updated after every new point added. + The `plot_callback` object can then be subscribed to the run engine, using either: - An explicit callback when calling the run engine: `RE(some_plan(), plot_callback)` - Be subscribed in a plan using `@subs_decorator` from bluesky **(recommended)** diff --git a/doc/fitting/fitting.md b/doc/fitting/fitting.md index f9acbb7..d192b64 100644 --- a/doc/fitting/fitting.md +++ b/doc/fitting/fitting.md @@ -24,14 +24,17 @@ plt.figure() ax = plt.gca() # ax is shared by fit_callback and plot_callback -plot_callback = LivePlot(y="y_variable", x="x_variable", ax=ax) -fit_callback = LiveFit(Gaussian.fit(), y="y_variable", x="x_variable", update_every=0.5) +plot_callback = LivePlot(y="y_signal", x="x_signal", ax=ax, yerr="yerr_signal") +fit_callback = LiveFit(Gaussian.fit(), y="y_signal", x="x_signal", yerr="yerr_signal", update_every=0.5) +# Using the yerr parameter allows you to use error bars. # update_every = in seconds, how often to recompute the fit. If `None`, do not compute until the end. Default is 1. fit_plot_callback = LiveFitPlot(fit_callback, ax=ax, color="r") ``` **Note:** that the `LiveFit` callback doesn't directly do the plotting, it will return function parameters of the model its trying to fit to; a `LiveFit` object must be passed to `LiveFitPlot` which can then be subscribed to the `RunEngine`. See the [Bluesky Documentation](https://blueskyproject.io/bluesky/main/callbacks.html#livefitplot) for information on the various arguments that can be passed to the `LiveFitPlot` class. +Using the `yerr` argument allows you to pass uncertainties via a signal to LiveFit, so that the "weight" of each point influences the fit produced. By not providing a signal name you choose not to use uncertainties/weighting in the fitting calculation. Each weight is computed as `1/(standard deviation at point)` and is taken into account to determine how much a point affects the overall fit of the data. Same as the rest of `LiveFit`, the fit will be updated after every new point collected now taking into account the weights of each point. Uncertainty data is collected from Bluesky event documents after each new point. + The `plot_callback` and `fit_plot_callback` objects can then be subscribed to the `RunEngine`, using the same methods as described in [`LivePlot`](../callbacks/plotting.md). See the following example using `@subs_decorator`: ```py @@ -79,7 +82,7 @@ from bluesky.callbacks import LiveFitPlot from ibex_bluesky_core.callbacks.fitting.fitting_utils import [FIT] # Pass [FIT].fit() to the first parameter of LiveFit -lf = LiveFit([FIT].fit(), y="y_variable", x="x_variable", update_every=0.5) +lf = LiveFit([FIT].fit(), y="y_signal", x="x_signal", update_every=0.5) # Then subscribe to LiveFitPlot(lf, ...) ``` @@ -89,7 +92,7 @@ The `[FIT].fit()` function will pass the `FitMethod` object straight to the `Liv **Note:** that for the fits in the above table that require parameters, you will need to pass value(s) to their `.fit` method. For example Polynomial fitting: ```py -lf = LiveFit(Polynomial.fit(3), y="y_variable", x="x_variable", update_every=0.5) +lf = LiveFit(Polynomial.fit(3), y="y_signal", x="x_signal", update_every=0.5) # For a polynomial of degree 3 ``` @@ -138,7 +141,7 @@ def guess(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]) -> dict[str, l fit_method = FitMethod(model, guess) #Pass the model and guess function to FitMethod -lf = LiveFit(fit_method, y="y_variable", x="x_variable", update_every=0.5) +lf = LiveFit(fit_method, y="y_signal", x="x_signal", update_every=0.5) # Then subscribe to LiveFitPlot(lf, ...) ``` @@ -163,7 +166,7 @@ def different_model(x: float, c1: float, c0: float) -> float: fit_method = FitMethod(different_model, Linear.guess()) # Uses the user defined model and the standard Guessing. function for linear models -lf = LiveFit(fit_method, y="y_variable", x="x_variable", update_every=0.5) +lf = LiveFit(fit_method, y="y_signal", x="x_signal", update_every=0.5) # Then subscribe to LiveFitPlot(lf, ...) ``` @@ -188,7 +191,7 @@ def different_guess(x: float, c1: float, c0: float) -> float: fit_method = FitMethod(Linear.model(), different_guess) # Uses the standard linear model and the user defined Guessing. function -lf = LiveFit(fit_method, y="y_variable", x="x_variable", update_every=0.5) +lf = LiveFit(fit_method, y="y_signal", x="x_signal", update_every=0.5) # Then subscribe to LiveFitPlot(lf, ...) ``` diff --git a/manual_system_tests/dae_scan.py b/manual_system_tests/dae_scan.py index f4d12cc..eb9e32a 100644 --- a/manual_system_tests/dae_scan.py +++ b/manual_system_tests/dae_scan.py @@ -8,12 +8,14 @@ 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.plotting import LivePlot from ibex_bluesky_core.devices import get_pv_prefix from ibex_bluesky_core.devices.block import block_rw_rbv @@ -27,6 +29,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. @@ -67,6 +71,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, yerr=reducer.intensity_stddev.name + ) + yield from ensure_connected(block, dae, force_reconnect=True) @subs_decorator( @@ -81,7 +90,15 @@ 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, + yerr=reducer.intensity_stddev.name, + ), LiveTable( [ block.name, @@ -96,9 +113,9 @@ def dae_scan_plan() -> Generator[Msg, None, None]: ] ) 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 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/__init__.py b/src/ibex_bluesky_core/callbacks/fitting/__init__.py index af489f9..3d18ea3 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/__init__.py +++ b/src/ibex_bluesky_core/callbacks/fitting/__init__.py @@ -1,6 +1,7 @@ """For IBEX Bluesky scan fitting.""" import logging +import warnings from typing import Callable import lmfit @@ -8,6 +9,7 @@ import numpy.typing as npt from bluesky.callbacks import LiveFit as _DefaultLiveFit from bluesky.callbacks.core import make_class_safe +from event_model.documents.event import Event logger = logging.getLogger(__name__) @@ -49,12 +51,7 @@ class LiveFit(_DefaultLiveFit): """Live fit, customized for IBEX.""" def __init__( - self, - method: FitMethod, - y: str, - x: str, - *, - update_every: int = 1, + self, method: FitMethod, y: str, x: str, *, update_every: int = 1, yerr: str | None = None ) -> None: """Call Bluesky LiveFit with assumption that there is only one independant variable. @@ -62,18 +59,41 @@ def __init__( method (FitMethod): The FitMethod (Model & Guess) to use when fitting. y (str): The name of the dependant variable. x (str): The name of the independant variable. - update_every (int): How often to update the fit. (seconds) + update_every (int, optional): How often to update the fit. (seconds) + yerr (str or None, optional): Name of field in the Event document + that provides standard deviation for each Y value. None meaning + do not use uncertainties in fit. """ self.method = method + self.yerr = yerr + self.weight_data = [] super().__init__( - model=method.model, - y=y, - independent_vars={"x": x}, - update_every=update_every, + model=method.model, y=y, independent_vars={"x": x}, update_every=update_every ) + def event(self, doc: Event) -> None: + """When an event is received, update caches.""" + weight = None + if self.yerr is not None: + try: + weight = 1 / doc["data"][self.yerr] + except ZeroDivisionError: + warnings.warn( + "standard deviation for y is 0, therefore applying weight of 0 on fit", + stacklevel=1, + ) + weight = 0.0 + + self.update_weight(weight) + super().event(doc) + + def update_weight(self, weight: float | None = 0.0) -> None: + """Update uncertainties cache.""" + if self.yerr is not None: + self.weight_data.append(weight) + def update_fit(self) -> None: """Use the provided guess function with the most recent x and y values after every update. @@ -84,12 +104,26 @@ def update_fit(self) -> None: None """ - logger.debug("updating guess for %s ", self.method) - self.init_guess = self.method.guess( - np.array(next(iter(self.independent_vars_data.values()))), - np.array(self.ydata), - # Calls the guess function on the set of data already collected in the run - ) - logger.info("new guess for %s: %s", self.method, self.init_guess) - - super().update_fit() + n = len(self.model.param_names) + if len(self.ydata) < n: + warnings.warn( + f"LiveFitPlot cannot update fit until there are at least {n} data points", + stacklevel=1, + ) + else: + logger.debug("updating guess for %s ", self.method) + self.init_guess = self.method.guess( + np.array(next(iter(self.independent_vars_data.values()))), + np.array(self.ydata), + # Calls the guess function on the set of data already collected in the run + ) + + logger.info("new guess for %s: %s", self.method, self.init_guess) + + kwargs = {} + kwargs.update(self.independent_vars_data) + kwargs.update(self.init_guess) + self.result = self.model.fit( + self.ydata, weights=None if self.yerr is None else self.weight_data, **kwargs + ) + self.__stale = False diff --git a/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py b/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py index 25e0cda..4bacdc7 100644 --- a/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py +++ b/src/ibex_bluesky_core/callbacks/fitting/fitting_utils.py @@ -190,7 +190,6 @@ def guess( ) -> Callable[[npt.NDArray[np.float64], npt.NDArray[np.float64]], dict[str, lmfit.Parameter]]: """Linear Guessing.""" return Polynomial.guess(1) - # Uses polynomial guessing with a degree of 1 class Polynomial(Fit): diff --git a/src/ibex_bluesky_core/callbacks/plotting.py b/src/ibex_bluesky_core/callbacks/plotting.py index 2aec29d..5547346 100644 --- a/src/ibex_bluesky_core/callbacks/plotting.py +++ b/src/ibex_bluesky_core/callbacks/plotting.py @@ -1,11 +1,12 @@ """IBEX plotting callbacks.""" import logging +from typing import Any import matplotlib import matplotlib.pyplot as plt from bluesky.callbacks import LivePlot as _DefaultLivePlot -from bluesky.callbacks.core import make_class_safe +from bluesky.callbacks.core import get_obj_fields, make_class_safe from event_model.documents import Event, RunStart logger = logging.getLogger(__name__) @@ -15,19 +16,60 @@ class LivePlot(_DefaultLivePlot): """Live plot, customized for IBEX.""" + def __init__( + self, + y: str, + x: str | None = None, + yerr: str | None = None, + *args: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialise LivePlot. + + Args: + y (str): The name of the dependant variable. + x (str or None, optional): The name of the independant variable. + yerr (str or None, optional): Name of uncertainties signal. + Providing None means do not plot uncertainties. + *args: As per mpl_plotting.py + **kwargs: As per mpl_plotting.py + + """ + super().__init__(y=y, x=x, *args, **kwargs) # noqa: B026 + if yerr is not None: + self.yerr, *others = get_obj_fields([yerr]) + else: + self.yerr = None + self.yerr_data = [] + def _show_plot(self) -> None: - # Play nicely with the "normal" backends too - only force show if we're - # actually using our custom backend. + """Call plt.show(). + + Play nicely with the "normal" backends too + - only force show if we're actually using our custom backend. + """ if "genie_python" in matplotlib.get_backend(): logger.debug("Explicitly show()ing plot for IBEX") plt.show() - def start(self, doc: RunStart) -> None: - """Process an start document (delegate to superclass, then show the plot).""" - super().start(doc) - self._show_plot() - def event(self, doc: Event) -> None: """Process an event document (delegate to superclass, then show the plot).""" + new_yerr = None if self.yerr is None else doc["data"][self.yerr] + self.update_yerr(new_yerr) super().event(doc) self._show_plot() + + def update_plot(self) -> None: + """Create error bars if needed, then update plot.""" + if self.yerr is not None: + self.ax.errorbar(x=self.x_data, y=self.y_data, yerr=self.yerr_data, fmt="none") # type: ignore + super().update_plot() + + def update_yerr(self, yerr: float | None) -> None: + """Update uncertainties data.""" + self.yerr_data.append(yerr) + + def start(self, doc: RunStart) -> None: + """Process an start document (delegate to superclass, then show the plot).""" + super().start(doc) + self._show_plot() diff --git a/src/ibex_bluesky_core/devices/dae/dae_spectra.py b/src/ibex_bluesky_core/devices/dae/dae_spectra.py index 1b4ba6d..2c6ed14 100644 --- a/src/ibex_bluesky_core/devices/dae/dae_spectra.py +++ b/src/ibex_bluesky_core/devices/dae/dae_spectra.py @@ -10,6 +10,7 @@ from ophyd_async.core import SignalR, StandardReadable from ophyd_async.epics.signal import epics_signal_r +VARIANCE_ADDITION = 0.5 logger = logging.getLogger(__name__) @@ -115,7 +116,15 @@ async def read_spectrum_dataarray(self) -> sc.DataArray: if unit is None: raise ValueError("Could not determine engineering units of tof edges.") + # See doc\architectural_decisions\005-variance-addition.md + # for justfication of the VARIANCE_ADDITION to variances + return sc.DataArray( - data=sc.Variable(dims=["tof"], values=counts, variances=counts, unit=sc.units.counts), + data=sc.Variable( + dims=["tof"], + values=counts, + variances=counts + VARIANCE_ADDITION, + unit=sc.units.counts, + ), coords={"tof": sc.array(dims=["tof"], values=tof_edges, unit=sc.Unit(unit))}, ) diff --git a/tests/callbacks/fitting/test_fitting_callback.py b/tests/callbacks/fitting/test_fitting_callback.py index a59da49..05d2d31 100644 --- a/tests/callbacks/fitting/test_fitting_callback.py +++ b/tests/callbacks/fitting/test_fitting_callback.py @@ -1,10 +1,13 @@ +import warnings from unittest import mock +from unittest.mock import MagicMock import lmfit import numpy as np import numpy.typing as npt from ibex_bluesky_core.callbacks.fitting import FitMethod, LiveFit +from ibex_bluesky_core.callbacks.fitting.fitting_utils import Linear def test_guess_called(): @@ -92,3 +95,70 @@ def model(x: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: ) model_mock.assert_called() + + +def test_model_called_with_weights_if_yerr_is_given(): + def guess(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]) -> dict[str, lmfit.Parameter]: + return {} + + model = lmfit.Model(lambda x: x) + model.fit = MagicMock() + method = FitMethod(model=model, guess=guess) + lf = LiveFit(method, y="y", x="x", yerr="yerr") + + x = 1 + y = 2 + yerr = 3 + + lf.event( + { + "data": { # type: ignore + "y": y, + "x": x, + "yerr": yerr, + } + } + ) + + model.fit.assert_called_with([y], weights=[1 / yerr], x=[1]) + + +def test_warning_given_if_yerr_is_0(): + def guess(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]) -> dict[str, lmfit.Parameter]: + return {} + + model = lmfit.Model(lambda x: x) + model.fit = MagicMock() + method = FitMethod(model=model, guess=guess) + lf = LiveFit(method, y="y", x="x", yerr="yerr") + + x = 1 + y = 2 + yerr = 0 + + with warnings.catch_warnings(record=True) as w: + lf.event( + { + "data": { # type: ignore + "y": y, + "x": x, + "yerr": yerr, + } + } + ) + + model.fit.assert_called_with([y], weights=[0.0], x=[1]) + + assert len(w) == 1 + assert "standard deviation for y is 0, therefore applying weight of 0 on fit" in str( + w[-1].message + ) + + +def test_warning_if_no_y_data(): + with warnings.catch_warnings(record=True) as w: + lf = LiveFit(Linear.fit(), y="y", x="x", yerr="yerr") + lf.update_fit() + + assert len(w) == 1 + assert "LiveFitPlot cannot update fit until there are at least" in str(w[-1].message) diff --git a/tests/callbacks/test_plotting_callback.py b/tests/callbacks/test_plotting_callback.py index e5b486a..c15658c 100644 --- a/tests/callbacks/test_plotting_callback.py +++ b/tests/callbacks/test_plotting_callback.py @@ -1,6 +1,8 @@ from typing import Any from unittest.mock import MagicMock, patch +from matplotlib import pyplot as plt + from ibex_bluesky_core.callbacks.plotting import LivePlot @@ -42,3 +44,54 @@ def test_show_plot_only_shows_if_backend_is_genie(): mock_get_backend.return_value = "simulated_genie_python_backend" lp._show_plot() mock_plt_show.assert_called_once() + + +def test_errorbars_created_if_yerr_is_given(): + _, ax = plt.subplots() + ax.errorbar = MagicMock() + + lp = LivePlot(y="y", x="x", yerr="yerr", ax=ax) + + x = 1 + y = 2 + yerr = 3 + + # Fake just enough of an event document. + lp.start( + { + "time": 0, + "uid": "0", + "scan_id": 0, + } + ) + + # Fake just enough of an event document. + lp.event( + { + "data": { + "y": y, + "x": x, + "yerr": yerr, + } + } # type: ignore + ) + + ax.errorbar.assert_called_with(y=[y], x=[x], yerr=[yerr], fmt="none") + + +def test_errorbars_not_created_if_no_yerr(): + _, ax = plt.subplots() + ax.errorbar = MagicMock() + + lp = LivePlot(y="y", x="x", ax=ax) + + lp.start( + { + "time": 0, + "uid": "0", + "scan_id": 0, + } + ) + + lp.update_plot() + assert not ax.errorbar.called diff --git a/tests/devices/test_dae.py b/tests/devices/test_dae.py index a2a9f6c..4042dd8 100644 --- a/tests/devices/test_dae.py +++ b/tests/devices/test_dae.py @@ -25,7 +25,7 @@ DaeSettingsData, TimingSource, ) -from ibex_bluesky_core.devices.dae.dae_spectra import DaeSpectra +from ibex_bluesky_core.devices.dae.dae_spectra import VARIANCE_ADDITION, DaeSpectra from ibex_bluesky_core.devices.dae.dae_tcb_settings import ( CalculationMethod, DaeTCBSettings, @@ -979,7 +979,11 @@ async def test_read_spectrum_dataarray(spectrum: DaeSpectra): data=sc.Variable( dims=["tof"], values=[1000, 2000, 3000], - variances=[1000, 2000, 3000], + variances=[ + 1000 + VARIANCE_ADDITION, + 2000 + VARIANCE_ADDITION, + 3000 + VARIANCE_ADDITION, + ], unit=sc.units.counts, dtype="float32", ),