diff --git a/csaxs_bec/scans/scans_v4/cont_grid.py b/csaxs_bec/scans/scans_v4/cont_grid.py index 2863fbb..691c823 100644 --- a/csaxs_bec/scans/scans_v4/cont_grid.py +++ b/csaxs_bec/scans/scans_v4/cont_grid.py @@ -75,58 +75,21 @@ class ContGrid(ScanBase): } def __init__( + # fmt: off self, - fast_axis: Annotated[ - DeviceBase, - ScanArgument(display_name="Fast Axis", description="Axis with continuous motion."), - ], - fast_start: Annotated[ - float, - ScanArgument( - display_name="Fast Start", - description="Start position for measurement points of the fast axis.", - ), - ], - fast_end: Annotated[ - float, - ScanArgument( - display_name="Fast End", - description="End position for measurement points of the fast axis.", - ), - ], - fast_step_size: Annotated[ - float, - ScanArgument( - display_name="Fast Step Size", - description="Step size for points of the continuous motion axis.", - ), - ], - stepper_axis: Annotated[ - DeviceBase, - ScanArgument( - display_name="Step Axis", - description="Step axis of the grid scan, stepping through the lines.", - ), - ], - stepper_start: Annotated[ - float, - ScanArgument(display_name="Step Start", description="Start position of the step axis."), - ], - stepper_stop: Annotated[ - float, - ScanArgument(display_name="Step Stop", description="End position of the step axis."), - ], - stepper_step_size: Annotated[ - float, - ScanArgument( - display_name="Step Step Size", - description="Step size of the step axis in units of the motor.", - ), - ], + fast_axis: Annotated[DeviceBase, ScanArgument(display_name="Fast Axis", description="Axis with continuous motion.")], + fast_start: Annotated[float, ScanArgument(display_name="Fast Start", description="Start position for measurement points of the fast axis.")], + fast_end: Annotated[float, ScanArgument(display_name="Fast End", description="End position for measurement points of the fast axis.")], + fast_step_size: Annotated[float, ScanArgument(display_name="Fast Step Size", description="Step size for points of the continuous motion axis.")], + stepper_axis: Annotated[DeviceBase, ScanArgument(display_name="Step Axis", description="Step axis of the grid scan, stepping through the lines.")], + stepper_start: Annotated[float, ScanArgument(display_name="Step Start", description="Start position of the step axis.")], + stepper_stop: Annotated[float, ScanArgument(display_name="Step Stop", description="End position of the step axis.")], + stepper_step_size: Annotated[float, ScanArgument(display_name="Step Step Size", description="Step size of the step axis in units of the motor.")], exp_time: DefaultArgType.ExposureTime, relative: DefaultArgType.Relative = False, fast_axis_always_in_pos_dir: bool = True, **kwargs, + # fmt: on ): """ Continuous grid scan with 2-axis. The scan requires the fast axis to properly implement base velocity as well as high velocity and high acceleration time @@ -186,14 +149,13 @@ class ContGrid(ScanBase): ) positions = position_generators.nd_grid_positions( [ - (self.stepper_start, self.stepper_stop, self._cont_motor_params["num_lines"]), (self.fast_start, self.fast_end, frames_per_trigger), + (self.stepper_start, self.stepper_stop, self._cont_motor_params["num_lines"]), ], snaked=False, ) # Count only the end point of each line as a valid position, as the fast axis is continuously moving and only triggered at # the beginning of the line moving to the end point. - positions = positions[:, ::-1] # Get device specific parameters self._fetch_device_params() diff --git a/docs/developer/developer.md b/docs/developer/developer.md index 5d65d8a..d97f29d 100644 --- a/docs/developer/developer.md +++ b/docs/developer/developer.md @@ -1,3 +1,33 @@ (developer)= -# Developer +# Developer +Information for developers and maintainers of the cSAXS beamline documentation and software. +```{toctree} +--- +maxdepth: 2 +hidden: true +--- + +editing_docs + +``` + + +*** + +````{grid} 2 +:gutter: 5 + +```{grid-item-card} +:link: developer.editing_docs +:link-type: ref +:img-top: /assets/index_contribute.svg +:text-align: center +:class-item: index-card + +## Editing the documentation + +Conventions for writing these MyST/Sphinx docs and how changes go live on Read the Docs. +``` + +```` diff --git a/docs/developer/editing_docs.md b/docs/developer/editing_docs.md new file mode 100644 index 0000000..6481740 --- /dev/null +++ b/docs/developer/editing_docs.md @@ -0,0 +1,143 @@ +(developer.editing_docs)= + +# Editing the documentation + +This documentation is built with [Sphinx](https://www.sphinx-doc.org) using the +[MyST](https://myst-parser.readthedocs.io) Markdown parser, and hosted on +[Read the Docs](https://sls-csaxs.readthedocs.io). The source lives in `docs/` of +the `csaxs_bec` repository. + +:::{important} +The pages use **MyST Markdown**, which is a superset of CommonMark — *not* +Gitea/GitHub-flavored Markdown. Most plain Markdown works as expected, but the +conventions below are specific to this build and need to be followed for links, +admonitions, and navigation to render correctly. +::: + +## How a change goes live + +There are two ways to propose an edit: + +- **In the browser (easiest, no git required).** Open the page in Gitea and click + **Edit**. Gitea commits your change to a new branch and opens a pull request for you; + if you do not have write access it first creates a personal fork automatically. +- **Locally.** Create a branch, edit the `.md` files under `docs/`, push, and open a + pull request. + +A documentation maintainer then reviews and merges the pull request into `main`. +Merging is restricted to a few people by design — opening the pull request is all that +is expected of an author. + +Once merged, the `.gitea/workflows/rtd_deploy.yml` action fires on the push to `main` +and triggers a Read the Docs build of the `latest` version; the site updates a minute +or two later. Nothing needs to be built or uploaded by hand. + +## Anatomy of a page + +Every content page starts with a **cross-reference label** followed by a single +top-level heading: + +```md +(user.saxs.data_analysis)= +# Data analysis +``` + +- The label `(user.saxs.data_analysis)=` is what other pages, the section landing + pages, and the `{ref}` role link to. **Do not delete or rename it** without + updating every reference, or links silently break. +- The `# Title` is the page's H1. Sphinx needs exactly one; section pages do not + inherit a title from the file name the way the old wiki did. + +## Conventions + +### Code blocks — always tag the language + +Use a language on every fenced block so syntax highlighting and the copy button work: + +````md +```bash +module load Python/3.11.11 +pip install "pyFAI[gui]" +``` +```` + +Use ```` ```text ```` for plain output (e.g. error messages) and ```` ```python ```` +for Python. + +### Call-outs — use admonitions, not bold "Note:" + +Instead of `**Note:** …`, use a [colon-fence admonition](https://myst-parser.readthedocs.io/en/latest/syntax/admonitions.html): + +```md +:::{note} +Jungfraujoch currently only accepts `uint32` TIFF mask files. +::: +``` + +Available types include `note`, `tip`, `important`, `warning`, `caution`, `danger`. + +### Links and cross-references + +- **To another page in this documentation**, link by its label, not by file path: + + ```md + See {ref}`the data analysis guide `. + ``` + +- **External links** use ordinary Markdown: `[pyFAI](https://pyfai.readthedocs.io)`. +- **Downloadable files** (PDFs, etc.) must use the `{download}` role — a plain link + to a non-image file is *not* copied into the build and will 404: + + ```md + - {download}`SAXS standards plate (PDF) ` + ``` + +- **Images** use the `{figure}` directive (or standard `![alt](file.png)`); place the + image file next to the page that uses it: + + ````md + ```{figure} omny_shuttle.png + The OMNY sample shuttle. + ``` + ```` + +### Adding a new page + +1. Create `docs/
/.md` with a label and H1 as above. +2. Add it to the parent section's `{toctree}` so it appears in the navigation, e.g. + in `docs/user/saxs/saxs.md`: + + ````md + ```{toctree} + --- + maxdepth: 2 + hidden: true + --- + + data_analysis + + ``` + ```` + + List entries by file name **without** the `.md` extension. + +## Preview locally + +```bash +cd docs +pip install -r requirements.txt # plus `pip install .` from the repo root for the API reference +make html +``` + +Open `docs/_build/html/index.html` in a browser. + +## Pitfalls + +- **Broken cross-references are warnings, not errors.** The build still succeeds and + publishes, but the link won't work. Check the Read the Docs build log (or the local + `make html` output) for `WARNING` lines after editing. +- **Everything here is public.** The site is world-readable. Avoid credentials, license + keys, personal logins, and anything not meant for a general audience. Internal + hostnames and `/sls/...` paths are fine if you intend them to be public. +- **Placeholders.** Bracketed values such as `[p-group]` or `[ra-c-110]` are meant to be + substituted by the reader; keep them consistent across a page. diff --git a/docs/user/saxs/SAXS_standards_plate.pdf b/docs/user/saxs/SAXS_standards_plate.pdf new file mode 100644 index 0000000..a4ba1c4 Binary files /dev/null and b/docs/user/saxs/SAXS_standards_plate.pdf differ diff --git a/docs/user/saxs/data_analysis.md b/docs/user/saxs/data_analysis.md new file mode 100644 index 0000000..d50caf3 --- /dev/null +++ b/docs/user/saxs/data_analysis.md @@ -0,0 +1,149 @@ +(user.saxs.data_analysis)= +# Data analysis + +We can access the online computing resources from the beamline consoles when logged in as the e-account. + +## Allocation of nodes + +Check available nodes and allocate one: + +```bash +sinfo +salloc -A csaxs -p csaxs -w [ra-c-110] +ssh -Y [ra-c-110] +``` + +Or for a GPU node: + +```bash +salloc -A csaxs -p gpu-csaxs --gpus=4 [-w ra-gpu-003] +ssh -Y [ra-gpu-003] +``` + +(note that we need to ask them to explicitly use one of our GPU nodes there, and this needs a couple of days to set up) + +**The home directory is the p group.** To go to the raw directory we need to navigate there: + +```bash +cd /sls/x12sa/data/[p22598]/raw +``` + +## Python tools + +### Python package installation + +Anaconda is no longer supported. Packages should now be installed directly using `pip`. + +For example, `pyFAI` can be installed as follows: + +```bash +module load Python/3.11.11 +pip install "pyFAI[gui]" +``` + +To launch the `pyFAI` applications: + +```bash +# Detector calibration +python -m pyFAI.app.calib2 + +# Batch integration +python -m pyFAI.app.integrate +``` + +### Recommended: using a Python virtual environment + +For long-term maintainability and reproducibility, it is recommended to use a dedicated Python virtual environment. + +#### Create a virtual environment + +```bash +cd /sls/x12sa/data/[p-group]/raw + +module load Python/3.11.11 +mkdir python-env +cd python-env + +# Create a virtual environment named ".venv" +python3 -m venv .venv + +# Activate the environment +source .venv/bin/activate + +# Install required packages +pip install "pyFAI[gui]" +``` + +#### Use an existing virtual environment + +```bash +cd /sls/x12sa/data/[p-group]/raw/python-env +source .venv/bin/activate +``` + +### Troubleshooting + +If you encounter the following error when launching GUI applications (e.g. `pyFAI-calib2`): + +```text +X11 connection rejected +``` + +reconnect to the analysis node with X11 forwarding enabled: + +```bash +ssh -X [ra-c-110] +``` + +and then reactivate the virtual environment before launching the application. + +## Use Jupyter Notebook / Lab + +Create a virtual environment following the instructions above, then: + +```bash +pip install jupyter +``` + +To run Jupyter Lab: + +```bash +jupyter lab --port 6006 --ip $(hostname) --no-browser +``` + +## Setup online radial integration on Jungfraujoch + +1. **Calibrate the detector using pyFAI.** + + Analyze a calibration scan carefully with `pyFAI` and save the resulting **PONI** file and **pixel mask**. Also determine the beam center position. + + :::{note} + As of 2026-06-18, Jungfraujoch only accepts `uint32` TIFF mask files. If the mask generated by `pyFAI` is stored as `uint8`, it needs to be converted to the required format before uploading. + To do this (from the home directory `/sls/x12sa/data/[p-group]/raw/`): + + - Activate the python environment: `source mask/.mask/bin/activate` + - Run `python convert_mask_format.py -i -o ` + ::: + +2. **Update the BEC configuration.** + + Update the detector distance and beam center coordinates in the BEC configuration files using the calibration results. + +3. **Configure Jungfraujoch.** + + Open the **Jungfraujoch Expert Configuration** panel in the frontend interface, for example: + + ```text + http://sls-jfjoch-001:8080/frontend + ``` + + - Upload the pixel mask under **Pixel Mask**. + - Fill in the required parameters under **Azimuthal Integration Settings**. + - Make sure to enable **Force CPU calculation in FPGA workflow**. + - After uploading a new configuration, do not press "initialize", as this will remove all the previously uploaded configuration. + +4. **Detector rotation (if required).** + + :::{note} + Adding rotation of the detector still needs to be documented (likely configured in the BEC configuration). + ::: diff --git a/docs/user/saxs/saxs.md b/docs/user/saxs/saxs.md index b04a583..5bef4d6 100644 --- a/docs/user/saxs/saxs.md +++ b/docs/user/saxs/saxs.md @@ -1,5 +1,39 @@ (user.saxs)= -# Saxs +# SAXS +Welcome to the SAXS section of the cSAXS beamline. + +```{toctree} +--- +maxdepth: 2 +hidden: true +--- + +data_analysis + +``` -## Beamtime allocation \ No newline at end of file +*** + +````{grid} 2 +:gutter: 5 + +```{grid-item-card} +:link: user.saxs.data_analysis +:link-type: ref +:img-top: /assets/biotech.svg +:text-align: center +:class-item: index-card + +## Data analysis + +Allocating compute nodes, pyFAI installation and usage, and online radial integration on Jungfraujoch. +``` + +```` + + +## Reference documents + +- {download}`SAXS standards plate (PDF) ` + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5868e25 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +from bec_server.scan_server.tests.scan_fixtures import * diff --git a/tests/tests_scans/test_cont_grid_scan.py b/tests/tests_scans/test_cont_grid_scan.py new file mode 100644 index 0000000..90ece7d --- /dev/null +++ b/tests/tests_scans/test_cont_grid_scan.py @@ -0,0 +1,125 @@ +from unittest import mock + +import numpy as np +import pytest +from bec_server.scan_server.tests.scan_fixtures import MockCustomDevice +from bec_server.scan_server.tests.scan_hook_tests import ( + DEFAULT_HOOK_TESTS, + PREMOVE_HOOK_TESTS, + run_scan_tests, +) + +CONT_GRID_HOOK_TESTS = [*DEFAULT_HOOK_TESTS, *PREMOVE_HOOK_TESTS] + + +def _assemble_cont_grid_scan(v4_scan_assembler, device_manager): + custom_samx = MockCustomDevice( + "samx", + device_info={ + "signals": { + "readback": { + "obj_name": "samx", + "kind_str": "hinted", + "describe": {"precision": 3}, + }, + "velocity": { + "obj_name": "samx_velocity", + "kind_str": "config", + "describe": {"precision": 3}, + }, + "acceleration": { + "obj_name": "samx_acceleration", + "kind_str": "config", + "describe": {"precision": 3}, + }, + "base_velocity": { + "obj_name": "samx_base_velocity", + "kind_str": "config", + "describe": {"precision": 3}, + }, + } + }, + signal_read_values={ + "samx": 0.0, + "samx_velocity": 10.0, + "samx_acceleration": 2.0, + "samx_base_velocity": 0.0, + }, + ) + custom_samy = MockCustomDevice( + "samy", + device_info={ + "signals": { + "readback": {"obj_name": "samy", "kind_str": "hinted", "describe": {"precision": 3}} + } + }, + signal_read_values={"samy": 0.0}, + ) + custom_ddg1 = MockCustomDevice( + "ddg1", + device_info={ + "signals": { + "readback": {"obj_name": "ddg1", "kind_str": "hinted", "describe": {"precision": 3}} + } + }, + signal_read_values={"ddg1": 0.0}, + ) + custom_ddg1.get_shutter_to_open_delay = mock.MagicMock(return_value=2e-3) + custom_mcs = MockCustomDevice( + "mcs", + device_info={ + "signals": { + "readback": {"obj_name": "mcs", "kind_str": "hinted", "describe": {"precision": 3}} + } + }, + signal_read_values={"mcs": 0.0}, + ) + device_manager.add_device(custom_samx, replace=True) + device_manager.add_device(custom_samy, replace=True) + device_manager.add_device(custom_mcs, replace=True) + device_manager.add_device(custom_ddg1, replace=True) + return v4_scan_assembler("cont_grid", "samx", -1.0, 1.0, 1, "samy", -2.0, 2.0, 2, exp_time=0.1) + + +@pytest.mark.parametrize(("hook_name", "hook_tests"), CONT_GRID_HOOK_TESTS) +def test_cont_grid_default_hooks( + v4_scan_assembler, device_manager, nth_done_status_mock, hook_name, hook_tests +): + scan = _assemble_cont_grid_scan(v4_scan_assembler, device_manager) + + run_scan_tests(scan, [(hook_name, hook_tests)], nth_done_status_mock=nth_done_status_mock) + + +def test_cont_grid_post_scan_waits_for_completion_and_moves_back_when_relative( + v4_scan_assembler, device_manager, nth_done_status_mock +): + scan = _assemble_cont_grid_scan(v4_scan_assembler, device_manager) + + completion_status = nth_done_status_mock(resolve_after=3) + scan.relative = True + scan.start_positions = [1.2, -0.7] + scan.actions.complete_all_devices = mock.MagicMock(return_value=completion_status) + scan.components.move_and_wait = mock.MagicMock() + scan._restore_motor_properties = mock.MagicMock() + + scan.post_scan() + + scan._restore_motor_properties.assert_called_once_with() + scan.actions.complete_all_devices.assert_called_once_with(wait=False) + scan.components.move_and_wait.assert_called_once_with(scan.motors, scan.start_positions) + assert completion_status.wait_calls == 1 + + +def test_cont_grid_prepare_scan_keeps_generated_positions_stable(v4_scan_assembler, device_manager): + scan = _assemble_cont_grid_scan(v4_scan_assembler, device_manager) + + scan.prepare_scan() + + assert np.array_equal(scan.positions, np.array([[1.0, -2.0], [1.0, 2.0]])) + assert scan.scan_info.frames_per_trigger == 2 + assert scan._cont_motor_params["num_lines"] == 2 + assert scan.scan_info.additional_scan_parameters["num_lines"] == 2 + assert np.array_equal( + scan.scan_info.additional_scan_parameters["computed_positions"], + np.array([[-1.0, -2.0], [1.0, -2.0], [-1.0, 2.0], [1.0, 2.0]]), + )