diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..b03f2a21 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,81 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. +line-length = 115 +indent-width = 4 + +# Assume Python 3.8 +target-version = "py38" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +select = ["E4", "E7", "E9", "F"] +ignore = ["E402", "E741", "F405"] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[lint.per-file-ignores] +"__init__.py" = ["F401", "F403"] +"**/{tests,docs,tools}/*" = ["E402"] + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" diff --git a/CHANGES.rst b/CHANGES.rst index 5df5cddd..0a7afeb9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -31,10 +31,11 @@ describe future plans. release expected by 2024-04-12 New Features - ------------ +------------ + +* Add new plan for edge alignment called edge_align +* Added a mesh grid scan plan that will collect until number of collection points is met - * Added a mesh grid scan plan that will collect until number of collection points is met - Fixes ----- diff --git a/apstools/plans/__init__.py b/apstools/plans/__init__.py index abadee10..0d652387 100644 --- a/apstools/plans/__init__.py +++ b/apstools/plans/__init__.py @@ -2,6 +2,7 @@ from .alignment import TuneResults from .alignment import lineup from .alignment import lineup2 +from .alignment import edge_align from .alignment import tune_axes from .command_list import CommandFileReadError from .command_list import command_list_as_table diff --git a/apstools/plans/alignment.py b/apstools/plans/alignment.py index ce5b19f7..0ad7d295 100644 --- a/apstools/plans/alignment.py +++ b/apstools/plans/alignment.py @@ -16,6 +16,9 @@ import numpy as np import pyRestTable +from scipy.optimize import curve_fit +from scipy.special import erf + from bluesky import plan_stubs as bps from bluesky import plans as bp from bluesky import preprocessors as bpp @@ -213,6 +216,115 @@ def peak_analysis(): scaler.stage_sigs = old_sigs +def edge_align(detectors, mover, start, end, points, cat=None, md={}): + """ + Align to the edge given mover & detector data, relative to absolute position. + + This plan can be used in the queueserver, Jupyter notebooks, and IPython + consoles. + + PARAMETERS + ---------- + detectors *Readable* or [*Readable*]: + Detector object or list of detector objects (each is a Device or + Signal). + + mover *Movable*: + Mover object, such as motor or other positioner. + + start *float*: + Starting point for the scan. This is an absolute motor location. + + end *float*: + Ending point for the scan. This is an absolute motor location. + + points *int*: + Number of points in the scan. + + cat *databroker.temp().v2*: + Catalog where bluesky data is saved and can be retrieved from. + + md *dict*: + User-supplied metadata for this scan. + """ + + def guess_erf_params(x_data, y_data): + """ + Provide an initial guess for the parameters of an error function. + + Parameters + ---------- + x_data : A numpy array of the values on the x_axis + y_data : A numpy array of the values on the y_axis + + Returns + ------- + guess : dict + A dictionary containing the guessed parameters 'low_y_data', 'high_y_data', 'width', and 'midpoint'. + """ + + # Sort data to make finding the mid-point easier and to assist in other estimations + y_data_sorted = np.sort(y_data) + x_data_sorted = np.sort(x_data) + + # Estimate low and high as the first and last elements (assuming sorted data) + low_y_data = np.min(y_data_sorted) + high_y_data = np.max(y_data_sorted) + + low_x_data = np.min(x_data_sorted) + high_x_data = np.max(x_data_sorted) + + # Estimate wid as a fraction of the range. This is very arbitrary and might need tuning! + width = ( + high_x_data - low_x_data + ) / 10 # This is a guess and might need adjustment based on your data's characteristics + + # Estimate the midpoint of the x values + midpoint = x_data[int(len(x_data) / 2)] + + return [low_y_data, high_y_data, width, midpoint] + + def erf_model(x, low, high, width, midpoint): + """ + Create error function for fitting and simulation + + Parameters + ---------- + x : input upon which error function is evaluated + low : min value of error function + high : max value of error function + width : "spread" of error function transition region + midpoint: location of error function's "center" + """ + return (high - low) * 0.5 * (1 - erf((x - midpoint) / width)) + low + + if not isinstance(detectors, (tuple, list)): + detectors = [detectors] + + _md = dict(purpose="edge_align") + _md.update(md or {}) + + uid = yield from bp.scan(detectors, mover, start, end, points, md=_md) + cat = cat or utils.getCatalog() + run = cat[uid] # return uids + ds = run.primary.read() + + x = ds["mover"] + y = ds["noisy"] + + try: + initial_guess = guess_erf_params(x, y) + popt, pcov = curve_fit(erf_model, x, y, p0=initial_guess) + if pcov[3, 3] != np.inf: + print("Significant signal change detected; motor moving to detected edge.") + yield from bps.mv(mover, popt[3]) + else: + raise Exception + except Exception as reason: + print(f"reason: {reason}") + print("No significant signal change detected; motor movement skipped.") + + def lineup2( # fmt: off detectors, mover, rel_start, rel_end, points, diff --git a/docs/source/api/_plans.rst b/docs/source/api/_plans.rst index 2752215e..d87e4f56 100644 --- a/docs/source/api/_plans.rst +++ b/docs/source/api/_plans.rst @@ -31,6 +31,7 @@ Custom Scans .. autosummary:: + ~apstools.plans.alignment.edge_align ~apstools.plans.alignment.lineup ~apstools.plans.alignment.lineup2 ~apstools.plans.alignment.tune_axes diff --git a/docs/source/conf.py b/docs/source/conf.py index fec3591e..a6fc5082 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -110,6 +110,8 @@ psutil pyRestTable pysumreg + scipy spec2nexus + toolz xarray """.split() diff --git a/environment.yml b/environment.yml index cfc4e86e..a628ccb9 100644 --- a/environment.yml +++ b/environment.yml @@ -31,6 +31,7 @@ dependencies: - pysumreg - qt =5 - readline !=8.1.2 + - scipy - setuptools-scm - spec2nexus - sphinx >=5 diff --git a/pyproject.toml b/pyproject.toml index e7786ef8..b8070f75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "pyepics>=3.4.2", "pyRestTable>=2020.0.8", "pysumreg", + "scipy", "spec2nexus>=2021.1.7", "toolz>=0.12.1", "xlrd", diff --git a/requirements.txt b/requirements.txt index ec3a919d..867acfd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,6 @@ psutil pyepics>=3.4.2 pyRestTable pysumreg +scipy spec2nexus>=2021.1.7 xlrd