diff --git a/.github/workflows/package.yaml b/.github/workflows/package.yaml index 0f53e2dff..4667f83e2 100644 --- a/.github/workflows/package.yaml +++ b/.github/workflows/package.yaml @@ -1,6 +1,6 @@ name: Pixi Packaging and Deployment -on: +on: # yamllint disable-line rule:truthy workflow_dispatch: push: branches: [main] diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index 9b487187d..0641ecae4 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -1,6 +1,6 @@ name: unit-test -on: +on: # yamllint disable-line rule:truthy workflow_dispatch: pull_request: # Run on pull requests targeting any base branch push: diff --git a/.gitignore b/.gitignore index 32643ca9a..2907a4f96 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,8 @@ instance/ # Sphinx documentation docs/_build/ +# ... ignore auto-generated Sphinx API docs +docs/developer/source/api/modules.rst # PyBuilder target/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cca64ad98..7661e4282 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,28 +14,36 @@ repos: exclude: "conda.recipe/meta.yaml" - id: end-of-file-fixer - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.14 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format + +# yamllint disable # - repo: https://github.com/codespell-project/codespell # rev: v2.4.1 # hooks: # - id: codespell +# yamllint disable + - repo: https://github.com/gitleaks/gitleaks rev: v8.30.0 hooks: - id: gitleaks + - repo: https://github.com/adrienverge/yamllint/ rev: v1.38.0 hooks: - id: yamllint + - repo: https://github.com/ComPWA/taplo-pre-commit rev: v0.9.3 hooks: - id: taplo-format + - repo: local hooks: - id: pixi-lock-check diff --git a/.readthedocs.yml b/.readthedocs.yml index dfc7b96b9..70ce21d37 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,8 +2,11 @@ version: 2 conda: - environment: docs/rtd/environment.yml + environment: docs/user/environment.yml + +sphinx: + configuration: docs/user/source/conf.py python: - version: 3.7 + version: 3.11 system_packages: true diff --git a/codecov.yaml b/codecov.yaml index 25c50c35a..9dcc36ac0 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -1,7 +1,7 @@ # Configuration file for codecov reporting code coverage # Disable codecov comments in every PR -comment: off +comment: false # files to ignore ignore: diff --git a/docs/developer/source/_static/.gitkeep b/docs/developer/source/_static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/developer/source/api/.gitignore b/docs/developer/source/api/.gitignore new file mode 100644 index 000000000..d181ece1b --- /dev/null +++ b/docs/developer/source/api/.gitignore @@ -0,0 +1,7 @@ +# Ignore autogenerated API documentation +pyrs*.rst + +# ... but keep specific handwritten files: +!modules.rst +!manual_api.rst +!pyRS.rst diff --git a/docs/developer/source/api/modules.rst b/docs/developer/source/api/modules.rst new file mode 100644 index 000000000..480979b14 --- /dev/null +++ b/docs/developer/source/api/modules.rst @@ -0,0 +1,7 @@ +pyrs +==== + +.. toctree:: + :maxdepth: 4 + + pyrs diff --git a/docs/developer/source/bibliography.rst b/docs/developer/source/bibliography.rst new file mode 100644 index 000000000..4fe95efa7 --- /dev/null +++ b/docs/developer/source/bibliography.rst @@ -0,0 +1,21 @@ +:Author: Chris Fancher +:Date: 05/12/2021 +:Keywords: list of references + +:: + DUPLICATE from `docs/user/source/bibliography.rst` to prevent an out-of-tree reference. + +Bibliography +============ + +.. [NumPy] Oliphant T E 2007 Comput. Sci. Eng. vol9 p10–20 + +.. [Matplotlib] Hunter J D 2007 Comput. Sci. Eng. vol9 p90–95 ISSN 1521-9615 + +.. [SciPy] Jones E, Oliphant T, Peterson P et al. + 2001– SciPy: Open source scientific tools for Python URL + http://www.scipy.org/ + +.. [mantid] O. Arnold et al., + Nucl. Instruments Methods Phys. Res. Set. A Accel. Spectrometers, Detect. Assoc. Equip. 764, 156 (2014) + https://www.mantidproject.org/ diff --git a/docs/developer/source/conf.py b/docs/developer/source/conf.py new file mode 100644 index 000000000..a0d4d0853 --- /dev/null +++ b/docs/developer/source/conf.py @@ -0,0 +1,394 @@ +import os +import sys +import warnings + +from unittest import mock + +# Ensure the 'pyrs' package is in the path: +# - current dir is `docs/developer/source`. +current_dir = os.path.dirname(__file__) +project_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..")) +sys.path.insert(0, project_dir) + +# Add extensions +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", # If using Google/NumPy style docstrings + "sphinx.ext.viewcode", + "sphinx.ext.todo", + "sphinxcontrib.programoutput", + "sphinx.ext.mathjax", +] + +MOCK_MODULES = [ + "mantid", + "mantid.kernel", + "mantid.simpleapi", + "mantid.api", + "h5py", + "qtpy", + "qtpy.QtCore", + "qtpy.QtGui", + "qtpy.QtWidgets", + "qtpy.uic", + # Prevent `sphinx` from trying to import modules which start any GUI operations: + # - not ideal, but for the moment we skip the entire `pyrs.interface` tree. + "pyrs.interface", + # additional `h5py` consuming modules, used by `NXstress` / `nexusformat`: + "hdf5plugin", + "nexusformat", + "nexusformat.nexus", +] +for mod_name in MOCK_MODULES: + sys.modules[mod_name] = mock.Mock() + +# Filter specific Pydantic warnings about Mocks +warnings.filterwarnings("ignore", message=".*Mock name.*is not a Python type.*") + + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "PyRS" +copyright = "2026, Oak Ridge National Laboratory" +author = "Oak Ridge National Laboratory" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = "1" +# The full version, including alpha/beta/rc tags. +release = "1.0.0" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [".pixi", "**/.pixi", "_build", "build", "reference"] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# html_theme = 'alabaster' +html_theme = "sphinx_rtd_theme" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = "PyRSdevdoc" + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', + # Latex figure (float) alignment + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, "PyRS_dev.tex", "PyRS Developer Documentation", "PyRS developers", "manual"), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(master_doc, "PyRS_dev", "PyRS Developer Documentation", [author], 1)] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "PyRS_dev", + "PyRS Developer Documentation", + author, + "PyRS_dev", + "PyRS developer documentation.", + "Miscellaneous", + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# texinfo_no_detailmenu = False + + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The basename for the epub file. It defaults to the project name. +# epub_basename = project + +# The HTML theme for the epub output. Since the default themes are not +# optimized for small screen space, using the same theme for HTML and epub +# output is usually not wise. This defaults to 'epub', a theme designed to save +# visual space. +# epub_theme = 'epub' + +# The language of the text. It defaults to the language option +# or 'en' if the language is not set. +# epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +# epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# epub_identifier = '' + +# A unique identification for the text. +# epub_uid = '' + +# A tuple containing the cover image and cover page html template filenames. +# epub_cover = () + +# A sequence of (type, uri, title) tuples for the guide element of content.opf. +# epub_guide = () + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +# epub_pre_files = [] + +# HTML files that should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +# epub_post_files = [] + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ["search.html"] + +# The depth of the table of contents in toc.ncx. +# epub_tocdepth = 3 + +# Allow duplicate toc entries. +# epub_tocdup = True + +# Choose between 'default' and 'includehidden'. +# epub_tocscope = 'default' + +# Fix unsupported image types using the Pillow. +# epub_fix_images = False + +# Scale large images. +# epub_max_image_width = 0 + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# epub_show_urls = 'inline' + +# If false, no index is generated. +# epub_use_index = True + + +def run_apidoc(_): + from sphinx.ext.apidoc import main + import os + + # Define input and output paths + # current_dir = docs/developer (where conf.py is) + current_dir = os.path.abspath(os.path.dirname(__file__)) + + # module_dir = neutrons/PyRS/pyrs (the python source code) + module_dir = os.path.join(project_dir, "pyrs") + + # output_dir = docs/developer/api + output_dir = os.path.join(current_dir, "api") + + # Run sphinx-apidoc + # -f: force overwrite + # -e: put documentation for each module on its own page + # -o: output directory + main(["-f", "-e", "-o", output_dir, module_dir]) + + +def setup(app): + app.connect("builder-inited", run_apidoc) diff --git a/docs/developer/source/design/nexus/IO_prototype.rst b/docs/developer/source/design/nexus/IO_prototype.rst new file mode 100644 index 000000000..20efac6ec --- /dev/null +++ b/docs/developer/source/design/nexus/IO_prototype.rst @@ -0,0 +1,94 @@ +.. _IO_prototype: + +================== +NeXus IO prototype +================== + +.. contents + :local: + +Overview +-------- + +The objective of the NeXus IO-prototype work was to provide a first-pass implementation of NeXus-compliant output using the existing ``NXstress`` schema. Both *output*, and the corresponding *input* methods are now implemented. +Primarily, the classes ``HidraProjectFile``, ``HidraWorkspace``, and ``PeakCollection`` are used to obtain information about the reduced data. Supporting classes such as ``SampleLogs``, and ``InstrumentSetup`` are also used, where information about the instrument and the experiment specifics is required. + +A sub-objective was to provide an output format that could include *all* of the data associated with an experiment, such as input-data and any additional normalization spectra. It should be noted however, that including this information is *optional* with respect to the output format. (Also, As an alternative, it is quite common to simply specify input-data by noting the *file-names* in appropriate fields.) + +The next sections provide a correspondance between the python classes, and sections within the ``NXstress`` schema. Any place where there's still confusion, or there is simply not enough information to meet the requirements of the schema, will be indicated using bold text. + +For purposes of the prototype, the ``nexusformat`` python package is used in the implementation, and that working group's validator has been used for validation of compliance. With respect to validation, its important to use a validator that allows *overriding* NeXus base-class definitions, which ``NXstress`` does extensively. In this regard, NeXus International Advisory Committee's (NIAC) C-language validator is an *incomplete* implementation, and gives misleading results. Also noted were several *bugs* in the implementation of the ``nexusformat`` validator: during validation of role-specified groups (noted as ``UPPERCASE`` in the schema), which allow any desired name to be used for the group. Unfortunately, in this case the validator actually requires ``UPPERCASE``, and won't allow *custom* names. + +Primary ``NXentry`` group +------------------------- + +Issues found: + +#. **start_time** and **end_time**: These are specified as lists by scan-point (aka *subrun* number in PyRS). + We could alternatively use the minimum and maximum over all of the sub-run times to obtain these values. + The validator has trouble with the placement of *lists* for these fields, but *technically* our use of lists + should be compliant with `NXstress`, and this is a _defect_ with the validator itself + +Single ``PEAKS`` group +---------------------- + +This group is intended to contain the canonical (or *reference*) peak values. + +Issues found: + +#. ``PEAKS`` group: only a single PEAKS group is allowed by the ``NXstress`` schema. This meant that in order to include *multiple* ``PeakCollection`` we needed to use a *flattened*-indexing scheme. Such a scheme is allowed by the ``NXstress``-schema, but it is highly unlikely that any *generic* NeXus application will be able to decode these indices automatically. **Further, since any data reduction (and associated peak fitting) would normally depend on the mask used, a *mask* field has been added to ``PeakCollection``.** In order to not overly modify the existing code, this ``mask`` field is *optional*, with a *default* value corresponding to the *default* mask. + +#. **Converting from ``PyRS`` format to and ``(h, k, l)`` (Miller indices) tags**. At present we make this conversion automatically using a regular-expression based parser, however this is not an ideal solution. Here it would be better if these values were specified *explicitly* by PyRS as *separate* fields in the ``PeakCollection``. + +#. **It's assumed that ``PeakCollection.d_reference`` provides the required values to include in this section**. + +#. **``(sx, sy, sz)`` are included from the logs**, but mostly just because the logs had the same variable names -- **this is probably incorrect**! + +#. **``(qx, qy, qz)`` are required by ``NXstress``** (, components of the normalized scattering vector Q in the sample reference frame)**. These seem to have no correspondance in the current PyRS codebase -- these values are initialized to ``NaN``. + +``FIT`` (NXprocess) group +------------------------- + +This group contains the fitting results from the selected *peak-profile* / *background-function* combination. In order to include results from multiple ``PeakCollection``, this group uses the *identical* flattened-indexing scheme as the ``PEAKS`` group. Note that the ``PEAKS`` group includes all of the field-values which define this index, and those values are not repeated in the ``FIT`` group. + +#. The splitting of the ``PeakCollection`` fields between ``FIT`` and ``PEAKS`` subgroups from ``NXstress`` was a bit confusing. This needs to be examined carefully to determine if it is correct. + +#. **Not yet in PyRS but required by the ``NXstress`` schema**: ``FIT/DIFFRACTOGRAM/fit``, ``fit_errors``: these datasets should contain the reconstructed spectrum from the fitted model. We don't seem to have methods to do this yet, so these are initialized to NaN. + + +``SAMPLE`` (NXsample) group +--------------------------- +This was complicated! Again the main issue is the *naming* of things in ``NXstress`` vs. the naming in the PyRS codebase + +Issues found: + +#. **Using ``PointList.(vx, vy, vz)`` as the sample positions**? Is this correct? + +#. **Possible mis-match between per-scan-point logs, and logs which have a single value for the entire experiment**. This still needs to be checked log-by-log! + +#. Where at all possible, *all* of the available logs have been included in an additional ``logs`` (``NXcollection``) subgroup. + + + +``INSTRUMENT`` (NXinstrument) group +----------------------------------- + +Issues found: +------------- + +#. Mask I/O should be fully implemented, including both detector and solid-angle masks. By necessity, this treatment assumes that a ```` mask will always exist, and this mask is *created* at output when necessary. Detector and solid-angle masks are distinguished by their array *shape*. **Note that under the current mask-naming scheme used by PyRS, masks must have *distinct* names, regardless of type.** + +#. **Calibrated** vs. **uncalibrated** instrument is only partially treated, and this will require some additional work in order to make sure that the treatment is correct. **In PyRS itself, several bugs were found relating to how calibration is applied:** specifically, there's nothing preventing it from being applied *multiple* times. + +#. **Monochromator information is only partially available**. + +#. **There is a whole lot of room for adjustments and *corrections* in this section!** + + +Possible extensions +------------------- + +#. A better and more complete treatment of instrument calibration, and the monochromator information. + + +#. Treatment of masks by *type*. This should almost certainly be changed so that it is *explicit*. At present, distinguishing between a *detector* or *solid-angle* mask depends only on array shape. diff --git a/docs/source/project_file.rst b/docs/developer/source/design/project_file.rst similarity index 100% rename from docs/source/project_file.rst rename to docs/developer/source/design/project_file.rst diff --git a/docs/developer/source/index.rst b/docs/developer/source/index.rst new file mode 100644 index 000000000..447761617 --- /dev/null +++ b/docs/developer/source/index.rst @@ -0,0 +1,34 @@ +PyRS Developer's Guide +====================== + +Python Residual Stress analysis +------------------------------- + +pyRS is a Python software framework for the reduction, analysis and visualization neutron diffraction data for residual stress analysis. +The pyRS software is tailored to meet the needs of the `High Intensity Diffractometer for Residual stress Analysis `_ instrument at the `High Flux Isotope Reactor `_. +pyRS relies on the scientific stack composed of [NumPy]_, [SciPy]_, [Matplotlib]_, and [mantid]_. +pyRS is part of the Oak Ridge `Neutrons Scattering Software suite `_. +Funded by the `Office of Energy Science `_, U.S. Department of Energy + +.. toctree:: + :maxdepth: 2 + + design/project_file + design/nexus/IO_prototype + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + + api/modules + bibliography + +:: + WARNING: `api/modules` is automatically generated, so it should not be edited by hand! + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/pole_figure.pdf b/docs/developer/source/reference/pole_figure.pdf similarity index 100% rename from docs/pole_figure.pdf rename to docs/developer/source/reference/pole_figure.pdf diff --git a/docs/pole_figure.tex b/docs/developer/source/reference/pole_figure.tex similarity index 100% rename from docs/pole_figure.tex rename to docs/developer/source/reference/pole_figure.tex diff --git a/docs/stress_strain.pdf b/docs/developer/source/reference/stress_strain.pdf similarity index 100% rename from docs/stress_strain.pdf rename to docs/developer/source/reference/stress_strain.pdf diff --git a/docs/stress_strain.tex b/docs/developer/source/reference/stress_strain.tex similarity index 100% rename from docs/stress_strain.tex rename to docs/developer/source/reference/stress_strain.tex diff --git a/docs/rtd/Makefile b/docs/user/Makefile similarity index 100% rename from docs/rtd/Makefile rename to docs/user/Makefile diff --git a/docs/rtd/environment.yml b/docs/user/environment.yml similarity index 100% rename from docs/rtd/environment.yml rename to docs/user/environment.yml diff --git a/docs/user/source/_static/.gitkeep b/docs/user/source/_static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/rtd/source/advanced/advanced.rst b/docs/user/source/advanced/advanced.rst similarity index 100% rename from docs/rtd/source/advanced/advanced.rst rename to docs/user/source/advanced/advanced.rst diff --git a/docs/user/source/api/.gitignore b/docs/user/source/api/.gitignore new file mode 100644 index 000000000..d181ece1b --- /dev/null +++ b/docs/user/source/api/.gitignore @@ -0,0 +1,7 @@ +# Ignore autogenerated API documentation +pyrs*.rst + +# ... but keep specific handwritten files: +!modules.rst +!manual_api.rst +!pyRS.rst diff --git a/docs/rtd/source/api/modules.rst b/docs/user/source/api/manual_api.rst similarity index 62% rename from docs/rtd/source/api/modules.rst rename to docs/user/source/api/manual_api.rst index fc0865642..9d1ed43ae 100644 --- a/docs/rtd/source/api/modules.rst +++ b/docs/user/source/api/manual_api.rst @@ -1,3 +1,10 @@ +.. + THIS DOCUMENT is partially duplicated at `docs/developer/source/api/modules.rst`, which is automatically generated. + Unfortunately, we can't easily link from this `docs/user/source` tree to the `docs/developer/source` tree, and so this + hand-edited version will be left where it is. + + + Python programming API ====================== @@ -10,7 +17,7 @@ raw neutron event data. The most important class is AzimuthalIntegrator which is an object containing both the geometry (it inherits from Geometry, another class) -and exposes important methods (functions) like `integrate1d` and `integrate2d. +and exposes important methods (functions) like `integrate1d` and `integrate2d`. .. toctree:: :maxdepth: 3 @@ -23,61 +30,83 @@ pyRS package ------------------------------------ .. autoclass:: pyrs.core.nexus_conversion.NeXusConvertingApp + :no-index: + :members: + :undoc-members: + :show-inheritance: + .. autofunction:: pyrs.core.nexus_conversion.NeXusConvertingApp.convert + :no-index: + .. autofunction:: pyrs.core.nexus_conversion.NeXusConvertingApp.save + :no-index: :mod:`Data Reduction Manager` Module ------------------------------------ .. autoclass:: pyrs.core.reduction_manager.HB2BReductionManager - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Data Reduction` Module ---------------------------- .. autoclass:: pyrs.core.reduce_hb2b_pyrs.PyHB2BReduction - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Detector Definition` Module ---------------------------------- .. autoclass:: pyrs.core.instrument_geometry.DENEXDetectorGeometry - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: .. autoclass:: pyrs.core.instrument_geometry.DENEXDetectorShift - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Instrument Definition` Module ----------------------------------- .. autoclass:: pyrs.core.instrument_geometry.HidraSetup - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: .. autoclass:: pyrs.core.reduce_hb2b_pyrs.ResidualStressInstrument - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Instrument Calibration` Module ------------------------------------ -.. autoclass:: pyrs.calibration.peakfit_calibration.PeakFitCalibration - :undoc-members: - :show-inheritance: +.. autoclass:: pyrs.calibration.mantid_peakfit_calibration.FitCalibration + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Peak Fitting Methods` Module ---------------------------------- .. autoclass:: pyrs.peaks.peak_fit_engine.PeakFitEngine + :no-index: + :members: + :undoc-members: + :show-inheritance: + .. autofunction:: pyrs.peaks.peak_fit_engine.PeakFitEngine.fit_multiple_peaks + :no-index: diff --git a/docs/rtd/source/api/pyRS.rst b/docs/user/source/api/pyRS.rst similarity index 60% rename from docs/rtd/source/api/pyRS.rst rename to docs/user/source/api/pyRS.rst index 85b9160a1..9a5f9be2d 100644 --- a/docs/rtd/source/api/pyRS.rst +++ b/docs/user/source/api/pyRS.rst @@ -1,3 +1,10 @@ +:orphan: + + +:: + It wasn't really clear where this document was intended to go. It seems a duplicate of `manual_api.rst`. + + pyRS package ============ @@ -7,44 +14,63 @@ pyRS package ------------------------------------ .. autoclass:: pyrs.core.nexus_conversion.NeXusConvertingApp + :no-index: + :members: + :undoc-members: + :show-inheritance: + .. autofunction:: pyrs.core.nexus_conversion.NeXusConvertingApp.convert + :no-index: + .. autofunction:: pyrs.core.nexus_conversion.NeXusConvertingApp.save + :no-index: + :mod:`Data Reduction Manager` Module ------------------------------------ .. autoclass:: pyrs.core.reduction_manager.HB2BReductionManager - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Data Reduction` Module ---------------------------- .. autoclass:: pyrs.core.reduce_hb2b_pyrs.PyHB2BReduction - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Instrument Definition` Module ----------------------------------- .. autoclass:: pyrs.core.reduce_hb2b_pyrs.ResidualStressInstrument - :members: - :undoc-members: - :show-inheritance: + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Instrument Calibration` Module ------------------------------------ -.. autoclass:: pyrs.calibration.peakfit_calibration.PeakFitCalibration - - :undoc-members: - :show-inheritance: +.. autoclass:: pyrs.calibration.mantid_peakfit_calibration.FitCalibration + :no-index: + :members: + :undoc-members: + :show-inheritance: :mod:`Peak Fitting Methods` Module ---------------------------------- .. autoclass:: pyrs.peaks.peak_fit_engine.PeakFitEngine + :no-index: + :members: + :undoc-members: + :show-inheritance: + .. autofunction:: pyrs.peaks.peak_fit_engine.PeakFitEngine.fit_multiple_peaks + :no-index: diff --git a/docs/rtd/source/basics/basics.rst b/docs/user/source/basics/basics.rst similarity index 100% rename from docs/rtd/source/basics/basics.rst rename to docs/user/source/basics/basics.rst diff --git a/docs/rtd/source/bibliography.rst b/docs/user/source/bibliography.rst similarity index 96% rename from docs/rtd/source/bibliography.rst rename to docs/user/source/bibliography.rst index 509cf6ef0..931ce776b 100644 --- a/docs/rtd/source/bibliography.rst +++ b/docs/user/source/bibliography.rst @@ -1,7 +1,8 @@ :Author: Chris Fancher -:Data 05/12/2021 +:Date: 05/12/2021 :Keywords: list of references + Bibliography ============ diff --git a/docs/rtd/source/conf.py b/docs/user/source/conf.py similarity index 94% rename from docs/rtd/source/conf.py rename to docs/user/source/conf.py index 3799f32bf..4dcf02cb6 100644 --- a/docs/rtd/source/conf.py +++ b/docs/user/source/conf.py @@ -22,6 +22,10 @@ # needs_sphinx = '1.0' import os import sys +import warnings + +from unittest import mock + project_dir = os.path.abspath(os.path.join(__file__, "..", "..", "..", "..")) sys.path.insert(0, project_dir) @@ -37,12 +41,31 @@ "sphinx.ext.mathjax", ] -import mock # noqa: E402 - -MOCK_MODULES = ["mantid", "mantid.kernel", "mantid.simpleapi", "mantid.api", "h5py", "qtpy", "qtpy.uic"] +MOCK_MODULES = [ + "mantid", + "mantid.kernel", + "mantid.simpleapi", + "mantid.api", + "h5py", + "qtpy", + "qtpy.QtCore", + "qtpy.QtGui", + "qtpy.QtWidgets", + "qtpy.uic", + # Prevent `sphinx` from trying to import modules which start any GUI operations: + # - not ideal, but for the moment we skip the entire `pyrs.interface` tree. + "pyrs.interface", + # additional `h5py` consuming modules, used by `NXstress` / `nexusformat`: + "hdf5plugin", + "nexusformat", + "nexusformat.nexus", +] for mod_name in MOCK_MODULES: sys.modules[mod_name] = mock.Mock() +# Filter specific Pydantic warnings about Mocks +warnings.filterwarnings("ignore", message=".*Mock name.*is not a Python type.*") + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -76,7 +99,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/docs/rtd/source/example/example.rst b/docs/user/source/example/example.rst similarity index 100% rename from docs/rtd/source/example/example.rst rename to docs/user/source/example/example.rst diff --git a/docs/rtd/source/figures/Example_Fit.png b/docs/user/source/figures/Example_Fit.png similarity index 100% rename from docs/rtd/source/figures/Example_Fit.png rename to docs/user/source/figures/Example_Fit.png diff --git a/docs/rtd/source/figures/Fit_2246.png b/docs/user/source/figures/Fit_2246.png similarity index 100% rename from docs/rtd/source/figures/Fit_2246.png rename to docs/user/source/figures/Fit_2246.png diff --git a/docs/rtd/source/figures/Fit_2247.png b/docs/user/source/figures/Fit_2247.png similarity index 100% rename from docs/rtd/source/figures/Fit_2247.png rename to docs/user/source/figures/Fit_2247.png diff --git a/docs/rtd/source/figures/Fit_2251.png b/docs/user/source/figures/Fit_2251.png similarity index 100% rename from docs/rtd/source/figures/Fit_2251.png rename to docs/user/source/figures/Fit_2251.png diff --git a/docs/rtd/source/figures/Stress_Define_Material.png b/docs/user/source/figures/Stress_Define_Material.png similarity index 100% rename from docs/rtd/source/figures/Stress_Define_Material.png rename to docs/user/source/figures/Stress_Define_Material.png diff --git a/docs/rtd/source/figures/Stress_Define_d0.png b/docs/user/source/figures/Stress_Define_d0.png similarity index 100% rename from docs/rtd/source/figures/Stress_Define_d0.png rename to docs/user/source/figures/Stress_Define_d0.png diff --git a/docs/rtd/source/figures/Stress_Final.png b/docs/user/source/figures/Stress_Final.png similarity index 100% rename from docs/rtd/source/figures/Stress_Final.png rename to docs/user/source/figures/Stress_Final.png diff --git a/docs/rtd/source/figures/Stress_Load.png b/docs/user/source/figures/Stress_Load.png similarity index 100% rename from docs/rtd/source/figures/Stress_Load.png rename to docs/user/source/figures/Stress_Load.png diff --git a/docs/rtd/source/figures/define_range.png b/docs/user/source/figures/define_range.png similarity index 100% rename from docs/rtd/source/figures/define_range.png rename to docs/user/source/figures/define_range.png diff --git a/docs/rtd/source/figures/fit_data.png b/docs/user/source/figures/fit_data.png similarity index 100% rename from docs/rtd/source/figures/fit_data.png rename to docs/user/source/figures/fit_data.png diff --git a/docs/rtd/source/figures/multi_fit.png b/docs/user/source/figures/multi_fit.png similarity index 100% rename from docs/rtd/source/figures/multi_fit.png rename to docs/user/source/figures/multi_fit.png diff --git a/docs/rtd/source/figures/peak_overview.png b/docs/user/source/figures/peak_overview.png similarity index 100% rename from docs/rtd/source/figures/peak_overview.png rename to docs/user/source/figures/peak_overview.png diff --git a/docs/rtd/source/figures/peak_overview.svg b/docs/user/source/figures/peak_overview.svg similarity index 100% rename from docs/rtd/source/figures/peak_overview.svg rename to docs/user/source/figures/peak_overview.svg diff --git a/docs/rtd/source/figures/single_fit.png b/docs/user/source/figures/single_fit.png similarity index 100% rename from docs/rtd/source/figures/single_fit.png rename to docs/user/source/figures/single_fit.png diff --git a/docs/rtd/source/figures/startup.png b/docs/user/source/figures/startup.png similarity index 100% rename from docs/rtd/source/figures/startup.png rename to docs/user/source/figures/startup.png diff --git a/docs/rtd/source/figures/stress_overview.ai b/docs/user/source/figures/stress_overview.ai similarity index 100% rename from docs/rtd/source/figures/stress_overview.ai rename to docs/user/source/figures/stress_overview.ai diff --git a/docs/rtd/source/figures/stress_overview.svg b/docs/user/source/figures/stress_overview.svg similarity index 100% rename from docs/rtd/source/figures/stress_overview.svg rename to docs/user/source/figures/stress_overview.svg diff --git a/docs/rtd/source/figures/visualize_res.png b/docs/user/source/figures/visualize_res.png similarity index 100% rename from docs/rtd/source/figures/visualize_res.png rename to docs/user/source/figures/visualize_res.png diff --git a/docs/rtd/source/index.rst b/docs/user/source/index.rst similarity index 87% rename from docs/rtd/source/index.rst rename to docs/user/source/index.rst index 0215accd2..65188ec63 100644 --- a/docs/rtd/source/index.rst +++ b/docs/user/source/index.rst @@ -1,4 +1,3 @@ -Ex PyRS User's Guide ================= @@ -12,6 +11,9 @@ pyRS relies on the scientific stack composed of [NumPy]_, [SciPy]_, [Matplotlib] pyRS is part of the Oak Ridge `Neutrons Scattering Software suite `_. Funded by the `Office of Energy Science `_, U.S. Department of Energy +.. + WARNING: `api/manual_api` is partially duplicated at `docs/developer/source/api/modules.api`, which is automatically generated. + .. toctree:: :maxdepth: 2 @@ -19,7 +21,7 @@ Funded by the `Office of Energy Science =2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - lz4-c >=1.10.0,<1.11.0a0 + - zlib-ng >=2.3.3,<2.4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 353899 + timestamp: 1772620395951 - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda sha256: 67cc7101b36421c5913a1687ef1b99f85b5d6868da3abbf6ec1a4181e79782fc md5: 4492fd26db29495f0ba23f146cd5638d @@ -2855,7 +2895,7 @@ packages: license: Apache-2.0 AND BSD-3-Clause AND PSF-2.0 AND MIT license_family: BSD purls: - - pkg:pypi/cryptography?source=compressed-mapping + - pkg:pypi/cryptography?source=hash-mapping size: 1714583 timestamp: 1770772534804 - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -3574,6 +3614,19 @@ packages: - pkg:pypi/h2?source=hash-mapping size: 95967 timestamp: 1756364871835 +- conda: https://conda.anaconda.org/conda-forge/noarch/h5glance-0.9-pyhd8ed1ab_0.conda + sha256: a2f9a20126c1ad4f128286b729f2f8a69063b943eb7ee6e8efcef4ce188527a0 + md5: c1f2aee61a183b8c991922fc6903db50 + depends: + - h5py >=2.10 + - htmlgen + - python >=3.7 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/h5glance?source=hash-mapping + size: 22140 + timestamp: 1732029278587 - conda: https://conda.anaconda.org/conda-forge/linux-64/h5py-3.15.1-nompi_py311h0b2f468_101.conda sha256: 6bf4f9a6ab5ccbfd8a2a6f130d5c14cb12f77ada367d3fa7724cd2f6515bddab md5: 1ce254e09ec4982ed0334e5e6f113e1c @@ -3641,24 +3694,24 @@ packages: - pkg:pypi/hatch?source=hash-mapping size: 208420 timestamp: 1772806610433 -- conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.28.0-pyhcf101f3_1.conda - sha256: c83a28bacd1918e84261ba8bc3fe51689b93f5a2b9b31447a73d2090723b8442 - md5: a920c64f09ba92514c9c288bc91b783d +- conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.29.0-pyhcf101f3_0.conda + sha256: bb86ff4ca54a2a0f63714766c7653a772c13d34c9e7cfb7be653db2fb806f961 + md5: 9d67ecd4cd5e6a9be36522be95951785 depends: - - python >=3.10 - - editables >=0.3 - packaging >=24.2 - pathspec >=0.10.1 - pluggy >=1.0.0 + - python >=3.10 - tomli >=1.2.2 - trove-classifiers + - editables >=0.3 - python license: MIT license_family: MIT purls: - - pkg:pypi/hatchling?source=hash-mapping - size: 60891 - timestamp: 1767609134323 + - pkg:pypi/hatchling?source=compressed-mapping + size: 61052 + timestamp: 1773194193187 - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda sha256: 0d09b6dc1ce5c4005ae1c6a19dc10767932ef9a5e9c755cfdbb5189ac8fb0684 md5: bd77f8da987968ec3927990495dc22e4 @@ -3690,6 +3743,29 @@ packages: purls: [] size: 3708864 timestamp: 1770390337946 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5plugin-6.0.0-py311h60fd0ea_3.conda + sha256: 866ecce28949e707de439a87b6c52e83ab7b2d87c9b01371bd8d925203e04007 + md5: ab4fbcf37a3335ddef185d36eaf8391c + depends: + - __glibc >=2.17,<3.0.a0 + - blosc >=1.21.6,<2.0a0 + - bzip2 >=1.0.8,<2.0a0 + - c-blosc2 >=2.23.0,<2.24.0a0 + - h5py >=3.0.0 + - hdf5 >=1.14.6,<1.14.7.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + - zstd >=1.5.7,<1.6.0a0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/hdf5plugin?source=hash-mapping + size: 3409408 + timestamp: 1770027671397 - conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_3.conda sha256: 6d7e6e1286cb521059fe69696705100a03b006efb914ffe82a2ae97ecbae66b7 md5: 129e404c5b001f3ef5581316971e3ea0 @@ -3709,6 +3785,17 @@ packages: - pkg:pypi/hpack?source=hash-mapping size: 30731 timestamp: 1737618390337 +- conda: https://conda.anaconda.org/conda-forge/noarch/htmlgen-2.0.0-pyhd8ed1ab_0.conda + sha256: cc007bac98c1f0b92b30956aefe6f955cce77fce9a783086f669721a454c0ed7 + md5: e195c631ede98ed735dcf7bb6fe37f6f + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/htmlgen?source=hash-mapping + size: 41001 + timestamp: 1682328147485 - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.7-pyh29332c3_1.conda sha256: c84d012a245171f3ed666a8bf9319580c269b7843ffa79f26468842da3abd5df md5: 2ca8e6dbc86525c8b95e3c0ffa26442e @@ -3895,7 +3982,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/iniconfig?source=compressed-mapping + - pkg:pypi/iniconfig?source=hash-mapping size: 13387 timestamp: 1760831448842 - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.2.0-pyha191276_1.conda @@ -4083,7 +4170,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/json5?source=compressed-mapping + - pkg:pypi/json5?source=hash-mapping size: 34017 timestamp: 1767325114901 - conda: https://conda.anaconda.org/conda-forge/linux-64/jsoncpp-1.9.6-hf42df4d_1.conda @@ -6035,7 +6122,7 @@ packages: license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/multidict?source=compressed-mapping + - pkg:pypi/multidict?source=hash-mapping size: 100649 timestamp: 1771610839808 - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda @@ -6137,6 +6224,21 @@ packages: purls: [] size: 1047686 timestamp: 1748012178395 +- conda: https://conda.anaconda.org/conda-forge/noarch/nexusformat-1.0.8-pyhd8ed1ab_0.conda + sha256: 923f69cacdd40754448f14b26ab0b4ecd9efd258edd8d3a6cfe1bf45af843995 + md5: fa47792de19cba1ebdc27258d7561172 + depends: + - h5py >=2.9 + - hdf5plugin + - numpy + - python >=3.9 + - scipy + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/nexusformat?source=hash-mapping + size: 69038 + timestamp: 1743367584974 - conda: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.3.3-py310h6de7dc8_0.conda noarch: python sha256: dcc0eee49226ef2f8f58de541a1b0ec492f4f0928ec43b1c26bf498511c363b1 @@ -6441,7 +6543,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/parso?source=compressed-mapping + - pkg:pypi/parso?source=hash-mapping size: 82287 timestamp: 1770676243987 - conda: https://conda.anaconda.org/conda-forge/linux-64/patch-2.8-hb03c661_1002.conda @@ -6613,9 +6715,9 @@ packages: - pkg:pypi/pkginfo?source=hash-mapping size: 30536 timestamp: 1739984682585 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.2-pyhcf101f3_0.conda - sha256: 7f263219cecf0ba6d74c751efa60c4676ce823157ca90aa43ebba5ac615ca0fa - md5: 4fefefb892ce9cc1539405bec2f1a6cd +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.4-pyhcf101f3_0.conda + sha256: 0289f0a38337ee201d984f8f31f11f6ef076cfbbfd0ab9181d12d9d1d099bf46 + md5: 82c1787f2a65c0155ef9652466ee98d6 depends: - python >=3.10 - python @@ -6623,8 +6725,8 @@ packages: license_family: MIT purls: - pkg:pypi/platformdirs?source=compressed-mapping - size: 25643 - timestamp: 1771233827084 + size: 25646 + timestamp: 1773199142345 - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e md5: d7585b6550ad04c8c5e21097ada2888e @@ -6703,7 +6805,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/pre-commit?source=compressed-mapping + - pkg:pypi/pre-commit?source=hash-mapping size: 200827 timestamp: 1765937577534 - conda: https://conda.anaconda.org/conda-forge/noarch/prettytable-3.17.0-pyhd8ed1ab_0.conda @@ -6999,7 +7101,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/pyjwt?source=compressed-mapping + - pkg:pypi/pyjwt?source=hash-mapping size: 30144 timestamp: 1769858771741 - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda @@ -7080,8 +7182,8 @@ packages: timestamp: 1695420780098 - pypi: ./ name: pyrs - version: 1.9.0.dev9 - sha256: d679a4da38a19ffdf533d3da1b3fce85b06a77095cfe20b66b10623cdbb85b1e + version: 1.9.0.dev15 + sha256: 0748c0660c776bc6e0878e7484f320b8b99db5d40c7af7ccce71029ee5f0d079 requires_python: '>=3.11' editable: true - conda: https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.2-py311h72d58bf_1.conda @@ -7254,6 +7356,7 @@ packages: - platformdirs <5,>=4.3.6 - python license: MIT + license_family: MIT purls: - pkg:pypi/python-discovery?source=compressed-mapping size: 33996 @@ -7314,7 +7417,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/librt?source=compressed-mapping + - pkg:pypi/librt?source=hash-mapping size: 77144 timestamp: 1771423012220 - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda @@ -8249,6 +8352,18 @@ packages: - pkg:pypi/sphinxcontrib-mermaid?source=compressed-mapping size: 19817 timestamp: 1772753857998 +- conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-programoutput-0.19-pyhd8ed1ab_0.conda + sha256: a0ffae2b63de1e48d8b3a59219fc57d486f58e7a470cc981ccc819319b546fdb + md5: 4e214c97d3722dc82f3556fb359df92e + depends: + - python >=3.8 + - sphinx >=5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-programoutput?source=hash-mapping + size: 20670 + timestamp: 1771622545945 - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda sha256: c664fefae4acdb5fae973bdde25836faf451f41d04342b64a358f9a7753c92ca md5: 00534ebcc0375929b45c3039b5ba7636 @@ -8313,7 +8428,7 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/superqt?source=compressed-mapping + - pkg:pypi/superqt?source=hash-mapping size: 81580 timestamp: 1772747657990 - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2022.3.0-h8d10470_1.conda @@ -8593,7 +8708,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/ukkonen?source=compressed-mapping + - pkg:pypi/ukkonen?source=hash-mapping size: 14898 timestamp: 1769438724694 - conda: https://conda.anaconda.org/conda-forge/noarch/uncertainties-3.2.4-pyhd8ed1ab_0.conda @@ -8799,7 +8914,7 @@ packages: license: MIT license_family: MIT purls: - - pkg:pypi/wcwidth?source=compressed-mapping + - pkg:pypi/wcwidth?source=hash-mapping size: 71550 timestamp: 1770634638503 - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda diff --git a/pyproject.toml b/pyproject.toml index 25d2bc29c..d14d12b1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,9 @@ matplotlib = "*" numpy = "*" pandas = "*" types-six = "*" +nexusformat = ">=1.0.8,<2" +pydantic = ">=2.7.3,<3" +h5glance = ">=0.9,<0.10" [tool.pixi.pypi-dependencies] # PyPI dependencies, including this package to allow local editable installs @@ -104,6 +107,8 @@ matplotlib = "*" numpy = "*" pandas = "*" types-six = "*" +nexusformat = ">=1.0.8,<2" +pydantic = ">=2.7.3,<3" # ------------------------------- # @@ -137,6 +142,7 @@ python-build = "*" sphinx = "*" sphinx_rtd_theme = "*" sphinxcontrib-mermaid = "*" +sphinxcontrib-programoutput = "*" types-pyyaml = "*" versioningit = "*" @@ -153,6 +159,8 @@ uncertainties = "*" libarchive = "*" pandas = "*" types-six = "*" +nexusformat = ">=1.0.8,<2" +pydantic = ">=2.7.3,<3" # ------------------------------- # # QA Environment Feature # @@ -167,6 +175,8 @@ uncertainties = "*" libarchive = "*" pandas = "*" types-six = "*" +nexusformat = ">=1.0.8,<2" +pydantic = ">=2.7.3,<3" # ------------------------------- # # Production Environment Feature # @@ -181,6 +191,8 @@ uncertainties = "*" libarchive = "*" pandas = "*" types-six = "*" +nexusformat = ">=1.0.8,<2" +pydantic = ">=2.7.3,<3" [tool.pixi.package] name = "pyrs" @@ -256,10 +268,11 @@ publish-conda = { cmd = "anaconda upload *.conda", description = "Publish the .c clean-conda = { cmd = "rm -f *.conda", description = "Clean the local .conda build artifacts" } # Documentation tasks -build-docs = { cmd = "sphinx-build -b html docs/source docs/_build", description = "Build the documentation" } +build-docs = { cmd = "sphinx-build -b html docs/user/source docs/_build/user", description = "Build the User documentation" } +build-dev-docs = { cmd = "sphinx-build -b html docs/developer/source docs/_build/developer", description = "Build the Developer documentation" } clean-docs = { cmd = "rm -rf docs/_build", description = "Clean the documentation build artifacts" } -docs-serve = { cmd = "python -m http.server 8000 -d docs/_build", description = "Serve documentation locally on port 8000" } -docs-autobuild = { cmd = "sphinx-autobuild docs/source docs/_build --host 0.0.0.0 --port 8000", description = "Auto-rebuild and serve docs on changes" } +docs-serve = { cmd = "python -m http.server 8000 -d docs/_build/user", description = "Serve User documentation locally on port 8000" } +docs-autobuild = { cmd = "sphinx-autobuild docs/user/source docs/_build/user --host 0.0.0.0 --port 8000", description = "Auto-rebuild and serve User docs on changes" } # Testing tasks test = { cmd = "pytest --cov=pyrs --cov-report=xml --cov-report=term ./tests", description = "Run the tests with coverage" } @@ -282,6 +295,9 @@ pyrs = { cmd = "pyrsplot", description = "Start the pyrs application" } ignore_missing_imports = true namespace_packages = true +[tool.pytest.ini_options] +norecursedirs = ["tests/scripts/cis_tests"] + [tool.ruff] line-length = 119 target-version = "py311" @@ -290,5 +306,9 @@ exclude = ["pyrs/icons", "scripts"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] +[tool.ruff.lint.per-file-ignores] +# WARNING: there are multiple `conftest.py` files! +"**/conftest.py" = ["F401"] + [tool.ruff.format] line-ending = "lf" diff --git a/pyrs/calibration/mantid_peakfit_calibration.py b/pyrs/calibration/mantid_peakfit_calibration.py index dfa2a2a8a..5b9cc31d9 100644 --- a/pyrs/calibration/mantid_peakfit_calibration.py +++ b/pyrs/calibration/mantid_peakfit_calibration.py @@ -65,7 +65,7 @@ def __init__( else: self._hidra_ws = reduction_engine - self.initalize_calib_arrays() + self.initialize_calib_arrays() # Initalize calibration status to -1 self._calibstatus = -1 @@ -217,7 +217,7 @@ def get_powder_lines(self): self._diff_peaks["BCC"] = np.sqrt(np.array([2, 4, 6, 8, 10, 12])) self._diff_peaks["FCC"] = np.sqrt(np.array([3, 4, 8, 11, 12, 19])) - def initalize_calib_arrays(self): + def initialize_calib_arrays(self): # calibration: numpy array. size as 7 for ... [6] for wave length self._calib = np.array(8 * [0], dtype=np.float64) # calibration error: numpy array. size as 7 for ... @@ -817,9 +817,11 @@ def write_calibration(self, file_name=None, write_latest=False): Parameters ---------- file_name: str or None - output Json file name. If None, write to /HFIR/HB2B/shared/CAL/ + output Json file name. If None, write to /HFIR/HB2B/shared/CAL/ + write_latest: bool - bool saying that the calibration should write HB2B_Cal_Latest + bool saying that the calibration should write HB2B_Cal_Latest + Returns ------- None diff --git a/pyrs/core/instrument_geometry.py b/pyrs/core/instrument_geometry.py index 0e55dc190..b2f1574df 100644 --- a/pyrs/core/instrument_geometry.py +++ b/pyrs/core/instrument_geometry.py @@ -41,6 +41,11 @@ def get_instrument_geometry(self, calibrated): :param calibrated: Bool :return GeometrySetup: Geometry setup parameters """ + # TODO: this next `if calibrated...` clause has some serious issues: + # (1) `apply_shift` returns `None`. + # (2) Potentially, shift will be applied multiple times. + # (3) Non-calibrated _geometry_setup is modified: + # what happens if the next call has `calibrated = False`? if calibrated and self._geometry_shift is not None: return self._geometry_setup.apply_shift(self._geometry_shift) @@ -49,8 +54,10 @@ def get_instrument_geometry(self, calibrated): def get_wavelength(self, wave_length_tag): """Get wave length Get wave length for only calibrated + :param wave_length_tag: str - user tag (as 111, 222) for wave length. None for single wave length + user tag (as 111, 222) for wave length. None for single wave length + :return float: wave length in A """ if wave_length_tag is not None: diff --git a/pyrs/core/peak_profile_utility.py b/pyrs/core/peak_profile_utility.py index 15ff1918a..aebd3f16b 100644 --- a/pyrs/core/peak_profile_utility.py +++ b/pyrs/core/peak_profile_utility.py @@ -43,8 +43,8 @@ def native_parameters(self): class BackgroundFunction(Enum): - LINEAR = "Linear" # so far, one and only supported - QUADRATIC = "Quadratic" # so far, one and only supported + LINEAR = "Linear" + QUADRATIC = "Quadratic" def __str__(self): return self.value @@ -72,7 +72,7 @@ def native_parameters(self): def get_parameter_dtype(peak_shape=None, background_function=None, effective=False): - """Convert the peak parameters into a dtype to ge used in numpy constructors + """Convert the peak parameters into a dtype to be used in numpy constructors ``np.zeros(NUM_SUBRUN, dtype=get_parameter_dtype('Gaussian'))`` """ @@ -115,7 +115,7 @@ def get_effective_parameters_converter(peak_profile): class PeakParametersConverter: - """Virtual base class to convert peak parameters from native to effective""" + """Virtual base class to convert peak parameters from native to effective or vice versa""" def __init__(self, peak_shape): """Initialization""" @@ -147,6 +147,25 @@ def calculate_effective_parameters(self, param_value_array, param_error_array): """ raise NotImplementedError("Virtual") + def calculate_native_parameters(self, eff_value_array, eff_error_array): + """Calculate native peak parameter values from effective parameters. + + This is the inverse of calculate_effective_parameters. + + Parameters + ---------- + eff_value_array : numpy.ndarray + structured array with effective parameter values + eff_error_array : numpy.ndarray + structured array with effective parameter errors + + Returns + ------- + np.ndarray, np.ndarray + structured arrays for native parameter values and errors + """ + raise NotImplementedError("Virtual") + class Gaussian(PeakParametersConverter): """ @@ -238,6 +257,62 @@ def calculate_effective_parameters(self, param_value_array, param_error_array): return eff_value_array, eff_error_array + def calculate_native_parameters(self, eff_value_array, eff_error_array): + """Inverse of calculate_effective_parameters for Gaussian. + + Effective → Native mapping: + PeakCentre = Center + Height = Height + Sigma = FWHM / (2 * sqrt(2 * ln(2))) + + Intensity is discarded (it was derived from Height and Sigma). + + Error propagation: + σ(PeakCentre) = σ(Center) + σ(Height) = σ(Height) + σ(Sigma) = σ(FWHM) / (2 * sqrt(2 * ln(2))) + """ + # Input validation + if eff_value_array.dtype != eff_error_array.dtype: + raise RuntimeError( + "dtype of values and errors do not match: {} and {}".format( + eff_value_array.dtype, eff_error_array.dtype + ) + ) + if eff_value_array.size != eff_error_array.size: + raise RuntimeError( + "size of values and errors do not match: {} and {}".format(eff_value_array.size, eff_error_array.size) + ) + + # Determine background type + has_quadratic = np.any(np.abs(eff_value_array["A2"]) > 1e-20) + bg_func = BackgroundFunction.QUADRATIC if has_quadratic else BackgroundFunction.LINEAR + + native_dtype = get_parameter_dtype(peak_shape=PeakShape.GAUSSIAN, background_function=bg_func) + native_values = np.zeros(eff_value_array.size, dtype=native_dtype) + native_errors = np.zeros(eff_value_array.size, dtype=native_dtype) + + # Invert FWHM → Sigma + native_values["Sigma"] = self.cal_sigma(eff_value_array["FWHM"]) + native_errors["Sigma"] = self.cal_sigma(eff_error_array["FWHM"]) # linear, same scale factor + + # Direct mappings + native_values["Height"] = eff_value_array["Height"] + native_values["PeakCentre"] = eff_value_array["Center"] + native_errors["Height"] = eff_error_array["Height"] + native_errors["PeakCentre"] = eff_error_array["Center"] + + # Background + native_values["A0"] = eff_value_array["A0"] + native_values["A1"] = eff_value_array["A1"] + native_errors["A0"] = eff_error_array["A0"] + native_errors["A1"] = eff_error_array["A1"] + if has_quadratic: + native_values["A2"] = eff_value_array["A2"] + native_errors["A2"] = eff_error_array["A2"] + + return native_values, native_errors + @staticmethod def cal_intensity(height, sigma): """Calculate peak intensity (intensities) @@ -441,6 +516,61 @@ def calculate_effective_parameters(self, param_value_array, param_error_array): return eff_value_array, eff_error_array + def calculate_native_parameters(self, eff_value_array, eff_error_array): + """Inverse of calculate_effective_parameters for PseudoVoigt. + + Effective → Native mapping: + PeakCentre = Center + FWHM = FWHM + Mixing = Mixing + Intensity = Intensity + + Height is discarded (it was derived from Intensity, FWHM, Mixing). + + Error propagation: all identity (direct copy). + """ + # Input validation + if eff_value_array.dtype != eff_error_array.dtype: + raise RuntimeError( + "dtype of values and errors do not match: {} and {}".format( + eff_value_array.dtype, eff_error_array.dtype + ) + ) + if eff_value_array.size != eff_error_array.size: + raise RuntimeError( + "size of values and errors do not match: {} and {}".format(eff_value_array.size, eff_error_array.size) + ) + + # Determine background type + has_quadratic = np.any(np.abs(eff_value_array["A2"]) > 1e-20) + bg_func = BackgroundFunction.QUADRATIC if has_quadratic else BackgroundFunction.LINEAR + + native_dtype = get_parameter_dtype(peak_shape=PeakShape.PSEUDOVOIGT, background_function=bg_func) + native_values = np.zeros(eff_value_array.size, dtype=native_dtype) + native_errors = np.zeros(eff_value_array.size, dtype=native_dtype) + + # All native PseudoVoigt params are direct from effective + native_values["Mixing"] = eff_value_array["Mixing"] + native_values["Intensity"] = eff_value_array["Intensity"] + native_values["PeakCentre"] = eff_value_array["Center"] + native_values["FWHM"] = eff_value_array["FWHM"] + + native_errors["Mixing"] = eff_error_array["Mixing"] + native_errors["Intensity"] = eff_error_array["Intensity"] + native_errors["PeakCentre"] = eff_error_array["Center"] + native_errors["FWHM"] = eff_error_array["FWHM"] + + # Background + native_values["A0"] = eff_value_array["A0"] + native_values["A1"] = eff_value_array["A1"] + native_errors["A0"] = eff_error_array["A0"] + native_errors["A1"] = eff_error_array["A1"] + if has_quadratic: + native_values["A2"] = eff_value_array["A2"] + native_errors["A2"] = eff_error_array["A2"] + + return native_values, native_errors + @staticmethod def cal_height(intensity, fwhm, mixing): """Calculate peak height from I(intensity), Gamma (fwhm) and eta (mixing) @@ -494,7 +624,6 @@ def cal_height_error(intensity, intensity_error, fwhm, fwhm_error, mixing, mixin mixing_factor = np.sqrt(np.pi * np.log(2)) - 1 two_inv_pi = 2.0 / np.pi - # FIXME - all the terms shall get SQUARED! # Partial derivative to intensity # partial h()/partial I = 2. * (1 + (np.sqrt(np.pi * np.log(2)) - 1) * mixing) / (np.pi * fwhm) # = (2 / np.pi) * (1 + F1 * mixing) / fwhm @@ -640,7 +769,9 @@ def gaussian(x, a, sigma, x0): :param x0: :return: """ - return a * np.exp(-(((x - x0) / sigma) ** 2)) + # THIS WAS corrected (11.02.2026) so that it matches the convention used in the rest of this module, + # and throughout the Mantid codebase. + return a * np.exp(-0.5 * ((x - x0) / sigma) ** 2) def pseudo_voigt(x, intensity, fwhm, mixing, x0): diff --git a/pyrs/core/polefigurecalculator.py b/pyrs/core/polefigurecalculator.py index 8fc4e55ae..51f645d7d 100644 --- a/pyrs/core/polefigurecalculator.py +++ b/pyrs/core/polefigurecalculator.py @@ -188,7 +188,6 @@ def rotate_project_q(self, theta: float, omega: float, chi: float, phi: float, e Projection of angular dependent data onto pole sphere. Analytical solution taken from Chapter 8.3 in Bob He Two-Dimensional X-ray Diffraction - _______________________ :param two_theta: :param omega: :param chi: diff --git a/pyrs/core/workspaces.py b/pyrs/core/workspaces.py index e075d61e5..729485fd5 100644 --- a/pyrs/core/workspaces.py +++ b/pyrs/core/workspaces.py @@ -420,9 +420,9 @@ def get_wavelength(self, calibrated, throw_if_not_set, sub_run=None): calibrated : bool whether the wave length is calibrated or raw throw_if_not_set : bool - throw an exception if wave length is not set to workspace + throw an exception if wave length is not set on the workspace sub_run : None or int - sub run number for the wave length associated with + sub run number for the wave length Returns ------- @@ -1190,6 +1190,73 @@ def set_wavelength(self, wave_length, calibrated): else: self._wave_length_dict = wl_dict + def set_sample_logs_from_object(self, sample_logs): + """Replace the current sample logs with the provided SampleLogs object. + + Parameters + ---------- + sample_logs : SampleLogs + Complete SampleLogs object to set + + Returns + ------- + None + """ + self._sample_logs = sample_logs + + def set_wavelength_from_value(self, wavelength): + """Set the universal wavelength (not per-subrun). + + Parameters + ---------- + wavelength : float + Wavelength value to set + + Returns + ------- + None + """ + self._wave_length = wavelength + + def set_reduced_diffraction_data_set(self, two_theta_matrix, diff_data_set, var_data_set): + """Set the full reduced diffraction data matrices directly. + + Parameters + ---------- + two_theta_matrix : np.ndarray + 2D array, shape (n_subruns, n_2theta) + diff_data_set : dict + {mask_id: np.ndarray} intensity matrices + var_data_set : dict + {mask_id: np.ndarray} variance matrices + + Returns + ------- + None + """ + self._2theta_matrix = np.copy(two_theta_matrix) + self._diff_data_set = {k: np.copy(v) for k, v in diff_data_set.items()} + self._var_data_set = {k: np.copy(v) for k, v in var_data_set.items()} + + def set_masks_from_dict(self, default_mask, mask_dict): + """Set detector masks from explicit arrays. + + Parameters + ---------- + default_mask : np.ndarray or None + Default mask array, or None if no default + mask_dict : dict + {mask_name: mask_array} + + Returns + ------- + None + """ + if default_mask is not None: + self.set_detector_mask(default_mask, True) + for name, arr in mask_dict.items(): + self.set_detector_mask(arr, False, name) + def reset_diffraction_data(self): """Reset the data structures to store the diffraction data set diff --git a/pyrs/dataobjects/constants.py b/pyrs/dataobjects/constants.py index 1e496016d..b262fd405 100644 --- a/pyrs/dataobjects/constants.py +++ b/pyrs/dataobjects/constants.py @@ -57,3 +57,9 @@ class HidraConstants: # Special sample logs SUB_RUN_DURATION = "sub-run duration" # units in seconds SAMPLE_COORDINATE_NAMES = ("vx", "vy", "vz") + SAMPLE_NAME = "SampleName" + SAMPLE_DESCRIPTION = "SampleDescription" + CHEMICAL_FORMULA = "chemical formula" + TEMPERATURE = "temperature" + STRESS_FIELD = "stress field" + STRESS_FIELD_DIRECTION = "stress field direction" diff --git a/pyrs/dataobjects/fields.py b/pyrs/dataobjects/fields.py index 5d70428d4..462a3b67f 100644 --- a/pyrs/dataobjects/fields.py +++ b/pyrs/dataobjects/fields.py @@ -6,69 +6,72 @@ class ScalarFieldObject - Definition: Scalar field (values and errors) defined over a set of sample points (a triad of - x, y, and z values). - - Functionality: - - Scalar fields can be combined in two fundamental ways, "along the same direction" - and "accross different directions". - - Combining ("fusing") two (or more) fields along the same direction results in one scalar - field. The sample points of each field are "fused" together, resulting in a single set - of sample points. The resulting field is defined over this new set of sample points. Any - point common to two input fields is "fused" into a single point in the combined - field. Operator '+' can be used to fuse scalar fields. - - Combining ("stacking") two (or more) fields across different directions results in as many - output fields as input fields. The sample points of each field are "fused" together, - resulting in a single set of sample points. All the output fields are defined over - this new set of sample points. Operator '*' can be used to stack scalar fields + Definition: + Scalar field (values and errors) defined over a set of sample points (a triad of + x, y, and z values). + + Functionality: + - Scalar fields can be combined in two fundamental ways, "along the same direction" + and "accross different directions". + - Combining ("fusing") two (or more) fields along the same direction results in one scalar + field. The sample points of each field are "fused" together, resulting in a single set + of sample points. The resulting field is defined over this new set of sample points. Any + point common to two input fields is "fused" into a single point in the combined + field. Operator '+' can be used to fuse scalar fields. + - Combining ("stacking") two (or more) fields across different directions results in as many + output fields as input fields. The sample points of each field are "fused" together, + resulting in a single set of sample points. All the output fields are defined over + this new set of sample points. Operator '*' can be used to stack scalar fields. class StrainField - Definition: a scalar field of strain values and errors defined over a set of sample points (a triad of - x, y, and z values). - - The set of sample points may originate from one or more experimental runs. Each experimental run has - associated one PeakCollection instance, so we'll say that a strain field is associated to one or - more PeakCollection instances. - - Functionality: - - Strain fields can be fused (combine strains along the same direction) or stacked (combine - strains across directions) - - Strain fields resulting from the fusion of two or more strains are associated to a list of - PeakCollection instances, which can be retrieved from property StrainField.peak_collections - - Strain fields store the set of sample points over which they are defined. If a strain is - associated to only one PeakCollection, then the sample points are those of the - PeakCollection. If a strain is associated to, say, two PeakCollection objects, then some of the - sample points will originate in the first PeakCollection object, and the remaining points will - originate in the second PeakCollection object. The StrainField object holds a cross-reference - index table resolving the provenance of each sample point to one PeakCollection and one sample - point within the PeakCollection. - - StrainField objects don't store strain values and errors, rather they are calculated every time - the are requested using the cross-reference index table and function PeakCollection.get_strain() + Definition: + a scalar field of strain values and errors defined over a set of sample points (a triad of + x, y, and z values). + + The set of sample points may originate from one or more experimental runs. Each experimental run has + associated one PeakCollection instance, so we'll say that a strain field is associated to one or + more PeakCollection instances. + + Functionality: + - Strain fields can be fused (combine strains along the same direction) or stacked (combine + strains across directions) + - Strain fields resulting from the fusion of two or more strains are associated to a list of + PeakCollection instances, which can be retrieved from property StrainField.peak_collections + - Strain fields store the set of sample points over which they are defined. If a strain is + associated to only one PeakCollection, then the sample points are those of the + PeakCollection. If a strain is associated to, say, two PeakCollection objects, then some of the + sample points will originate in the first PeakCollection object, and the remaining points will + originate in the second PeakCollection object. The StrainField object holds a cross-reference + index table resolving the provenance of each sample point to one PeakCollection and one sample + point within the PeakCollection. + - StrainField objects don't store strain values and errors, rather they are calculated every time + the are requested using the cross-reference index table and function PeakCollection.get_strain() class StressField - Definition: a container of three stress and three strains scalar fields, a pair for each of the - three mutually perperdicular directions. - - StressField objects are generated using three StrainField objects, one for each mutually perperdicular - direction. Usually, these strains are defined over slightly different sets of sample points, so - it is necessary to stack them. After the stacking operation, the three output StrainField objects are - defined over the same set of sample points and calculation of the stress components can proceed. The - StressField object stores the three stacked StrainField objects, it does not store the three original - StrainField objects. - - The three stress components are stored as ScalarFieldSample objects. - - Selected Functionality: - - Stacked strains are accessible with properties StressField.strain11 (.strain22, .strain33) - - Stress components can be accessed with the bracket operator (stress['11'], stress['22'], stress['33']) - - Iterating over a StressField objects returns an iterator over the stress - components (for component in stress: ...) - - StressField objects hold a "currently accessible direction" which can be updated with the - StressField.select() method. - - Properties StressField.values and StressField.errors returns the values and errors of the stress - component along the currectly accessble direction + Definition: + a container of three stress and three strains scalar fields, a pair for each of the + three mutually perperdicular directions. + + StressField objects are generated using three StrainField objects, one for each mutually perperdicular + direction. Usually, these strains are defined over slightly different sets of sample points, so + it is necessary to stack them. After the stacking operation, the three output StrainField objects are + defined over the same set of sample points and calculation of the stress components can proceed. The + StressField object stores the three stacked StrainField objects, it does not store the three original + StrainField objects. + + The three stress components are stored as ScalarFieldSample objects. + + Selected Functionality: + - Stacked strains are accessible with properties StressField.strain11 (.strain22, .strain33) + - Stress components can be accessed with the bracket operator (stress['11'], stress['22'], stress['33']) + - Iterating over a StressField objects returns an iterator over the stress + components (for component in stress: ...) + - StressField objects hold a "currently accessible direction" which can be updated with the + StressField.select() method. + - Properties StressField.values and StressField.errors returns the values and errors of the stress + component along the currectly accessble direction. """ from collections import namedtuple @@ -117,17 +120,23 @@ class ScalarFieldSample: Parameters ---------- + name: str Name of the field. Standard field names are defined in SCALAR_FIELD_NAMES + values: list List of real values corresponding to the evaluation of the scalar field at the sample points + errors: list List of real values corresponding to the undeterminacies in the evaluation of the scalar field at the sample points + x: list List of coordinates along some X-axis for the set of sample points. + y: list List of coordinates along some Y-axis for the set of sample points. + z: list List of coordinates along some Z-axis for the set of sample points. """ @@ -606,16 +615,16 @@ def export(self, *args: Any, form: str = "MDHistoWokspace", **kwargs: Any) -> An Export the scalar field to a particular format. Each format has additional arguments Allowed formats, along with additional arguments and return object: - - 'MDHistoWorkspace' calls function `to_md_histo_workspace` - name: str, name of the workspace - interpolate (`True`): bool, interpolate values to a regular coordinate grid - method: ('linear'): str, method of interpolation. Allowed values are 'nearest' and 'linear' - fill_value: (float('nan'): float, value used to fill in for requested points outside the input points. - keep_nan (`True`): bool, transfer `nan` values to the interpolated sample - Returns: MDHistoWorkspace, handle to the workspace - - 'CSV' calls function `to_csv` - file: str, name of the output file - Returns: str, the file as a string + - 'MDHistoWorkspace' calls function `to_md_histo_workspace` + name: str, name of the workspace + interpolate (`True`): bool, interpolate values to a regular coordinate grid + method: ('linear'): str, method of interpolation. Allowed values are 'nearest' and 'linear' + fill_value: (float('nan'): float, value used to fill in for requested points outside the input points. + keep_nan (`True`): bool, transfer `nan` values to the interpolated sample + Returns: MDHistoWorkspace, handle to the workspace + - 'CSV' calls function `to_csv` + file: str, name of the output file + Returns: str, the file as a string Parameters ---------- diff --git a/pyrs/dataobjects/sample_logs.py b/pyrs/dataobjects/sample_logs.py index d57559884..42ccb572f 100644 --- a/pyrs/dataobjects/sample_logs.py +++ b/pyrs/dataobjects/sample_logs.py @@ -348,24 +348,25 @@ def __setitem__( r""" Initialize/update the subruns instance, or insert/update the value of a log entry - `value` is coerced into a numpy array, which could be a one-item array if passing an int of float. + `value` is coerced into a numpy array, which could be a one-item array if passing an int or float. Parameters ---------- key: str, tuple - If `str`, then name of the log value, or dedicated string 'sub-runs'. It `tuple`, then the - first item is the same as previously, and the second item is a string representing the log units. - value: int, flat, list, np.ndarray, ~pyrs.dataobjects.sample_logs.Subruns - A list of subrun numbers of the values of a log entry + If `str`, then name of the log entry, or dedicated string 'sub-runs'. If `tuple`, then the + first item is the same as previously, and the second item is a string representing the units + of the log entry's value. + value: int, float, list, np.ndarray, ~pyrs.dataobjects.sample_logs.Subruns + A list, in subrun order, of the values of a log entry Raises ------ ValueError Attempt to insert/update the value of a log entry prior to initialization of the - selected subruns list + number of subruns ValueError - Attempt to insert/update the value of a log entry with a list of different size - then the selected subruns list + Attempt to insert/update the value of a log entry with a list of different length + then the number of subruns """ if isinstance(key, str): log_name = key @@ -379,10 +380,13 @@ def __setitem__( if self._subruns.size == 0: raise RuntimeError("Must set subruns first") elif isinstance(value, np.ndarray): - if value.size != self.subruns.size: + # 17.02.2026: Modified this to allow log entries to be non-scalar: + # this seemed to be consistent with what was intended by the original design. + # For a `numpy.ndarray` `len` returns the number of rows (i.e. the length of the first axis). + if len(value) != self.subruns.size: raise ValueError( - "Number of values[{}] isn't the same as number of subruns[{}]".format( - value.size, self.subruns.size + "Number of values (or value rows)[{}] isn't the same as number of subruns[{}]".format( + len(value), self.subruns.size ) ) else: diff --git a/pyrs/peaks/peak_collection.py b/pyrs/peaks/peak_collection.py index 843a66c55..24c0be6f9 100644 --- a/pyrs/peaks/peak_collection.py +++ b/pyrs/peaks/peak_collection.py @@ -8,6 +8,7 @@ BackgroundFunction, ) from pyrs.dataobjects import SubRuns # type: ignore +from pyrs.dataobjects.constants import HidraConstants from typing import Tuple, Union from uncertainties import unumpy from uncertainties import ufloat @@ -242,6 +243,8 @@ def __init__( d_reference_error: Union[float, np.ndarray] = 0.0, projectfilename: str = "", runnumber: int = -1, + *, + mask: str = HidraConstants.DEFAULT_MASK, ) -> None: """Initialization @@ -257,6 +260,7 @@ def __init__( """ # Init variables from input self._tag = peak_tag + self._mask = mask self._filename: str = "" self.projectfilename = projectfilename # use the setter self._runnumber: int = runnumber @@ -299,6 +303,18 @@ def peak_tag(self) -> str: """ return self._tag + @property + def mask(self) -> str: + """Mask name + + Returns + ------- + str + Mask name + + """ + return self._mask + @property def peak_profile(self) -> str: """Get peak profile name @@ -373,7 +389,10 @@ def __convertParameters(self, parameters): raise RuntimeError(msg) converted = np.zeros(parameters.size, get_parameter_dtype(self._peak_profile, self._background_type)) for name in converted.dtype.names: - converted[name] = parameters[name] + # Only copy fields that exist in the input parameters + if name in supplied_names: + converted[name] = parameters[name] + # Fields not in input (e.g., A2 for Linear background) remain zero return converted @@ -468,7 +487,7 @@ def get_strain(self, units: str = "strain") -> Tuple[np.ndarray, np.ndarray]: d_reference_errors, ) - # multiplying by 1e6 converts to micro + # multiplying by 1.0e6 converts to micro strain = conversion_factor * (d_fitted - safe_d_reference) / safe_d_reference # unpack the values to return diff --git a/pyrs/projectfile/file_object.py b/pyrs/projectfile/file_object.py index d8b10302d..29211d9ab 100644 --- a/pyrs/projectfile/file_object.py +++ b/pyrs/projectfile/file_object.py @@ -77,6 +77,18 @@ def __init__( if self._io_mode == HidraProjectFileMode.OVERWRITE: self._init_project() + ###################################### + ## Context-manager support methods: ## + ###################################### + def __enter__(self) -> "HidraProjectFile": + return self + + def __exit__(self, exc_type, exc, tb): + self.close() + return False # do not suppress exceptions + + ###################################### + def _checkFileAccess(self): """Verify the file has the correct acces permissions and set the value of ``self._is_writable``""" # prepare the call to check the file permissions @@ -487,6 +499,7 @@ def read_instrument_geometry(self): """ Get instrument geometry parameters :return: an instance of instrument_geometry.InstrumentSetup + *** TODO: actually this returns an instance of `DENEXDetectorGeometry`! *** """ # Get group geometry_group = self._project_h5[HidraConstants.INSTRUMENT][HidraConstants.GEOMETRY_SETUP] diff --git a/pyrs/utilities/NXstress/NXstress.py b/pyrs/utilities/NXstress/NXstress.py new file mode 100644 index 000000000..38267a11b --- /dev/null +++ b/pyrs/utilities/NXstress/NXstress.py @@ -0,0 +1,336 @@ +""" +pyrs/utilities/NXstress/NXstress.py + +Primary service class for NeXus NXstress-compatible I/O. +""" + +from datetime import datetime +from nexusformat.nexus import NXdata, NXentry, NXfield, NXFile, nxopen +from pathlib import Path + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.peaks.peak_collection import PeakCollection +from pyrs.utilities.pydantic_transition import validate_call_ + +from ._definitions import ( + DEFAULT_TAG, + GROUP_NAME, + group_naming_scheme, + NO_LOG, + suffix_from_group_name, + logger, + REQUIRED_LOGS, +) +from ._input_data import _InputData +from ._instrument import _Instrument, _Masks +from ._sample import _Sample +from ._fit import _Fit, _Diffractogram +from ._peaks import _Peaks + + +""" +REQUIRED PARAMETERS FOR NXstress: +--------------------------------- + +/ (NXentry, group) +│ +├─ definition (dataset: "NXstress") +├─ start_time (dataset: ISO8601 string) +├─ end_time (dataset: ISO8601 string) +├─ processingtype (dataset: string) +│ +├─ instrument (NXinstrument, group) +│ ├─ name (dataset: string) +│ ├─ source (NXsource, group) +│ ├─ detector (NXdetector, group) +│ └─ mask (optional) (NXcollection, group) +│ +├─ sample (NXsample, group) +│ ├─ name (dataset: string) +│ ├─ chemical_formula (optional) (dataset: string) +│ ├─ temperature (optional) (dataset: string) +│ ├─ stress_field (optional) (dataset: string) +│ └─ gauge_volume (optional) (NXparameters, group) +│ +├─ fit (NXprocess, group) +│ ├─ @date (attribute: ISO8601 string) +│ ├─ @program (attribute: string) +│ ├─ description (NXnote, group) +│ ├─ peakparameters (NXparameters, group) +│ └─ diffractogram (NXdata, group) +│ ├─ diffractogram (dataset) +│ ├─ diffractogram_errors (dataset) +│ ├─ daxis/xaxis (dataset) +│ ├─ @axes (attribute: string) +│ └─ @signal (attribute: string) +│ +├─ peaks (NXreflections, group) +│ ├─ h (dataset) +│ ├─ k (dataset) +│ ├─ l (dataset) +│ └─ phase_name (dataset) +""" + + +class NXstress: + ################################################################## + ## Service class to write NXstress-compliant NXentries: ## + ## the `write` method writes the next `NXentry` to the file. ## + ################################################################## + + ## Context-manager related methods: + def __init__(self, file_path: Path, mode: str = "r"): + self._path = str(file_path) + self._mode = mode + self._nx = NXFile(self._path, self._mode) # low-level handle + self._root = None # will *ONLY* be set in __enter__ + + def __enter__(self) -> "NXstress": + self._root = nxopen(self._path, self._mode) + if self._root is None: + raise RuntimeError( + f"Unexpected `nexusformat` error opening '{self._path}' " + f"for {'read' if 'r' in self._mode else 'write'}." + ) + self._root.__enter__() + + return self + + def __exit__(self, exc_type, exc, tb): + if self._root: + self._root.__exit__(exc_type, exc, tb) + self._root = None + + # Do not suppress exceptions + return False + + def write(self, ws: HidraWorkspace, peakss: list[PeakCollection]): + # Write the _next_ NXentry to the file: + # + # -- multiple NXentry are allowed by the NXstress schema. + # -- each NXentry includes: + # + # -- [optional] input_data: raw detector counts, indexed by 'scan_point' (aka: 'subrun'); + # -- the `NXinstrument`, including its `NXdetector`, applicable `NXtransformations` + # and detector and solid-angle masks; + # -- a canonical PEAKS instance: + # + # -- peaks are indexed by: phase, (h, k, l), mask, + # (no duplicate entries are allowed) + # + # -- reduced 'diffraction_data' sections corresponding to the PEAKS entries: + # + # -- peak-fit details, indexed as for the PEAKS indices; + # -- normalized and reduced data for each mask, indexed by 'scan_point'; + # -- a calculated model spectrum: this section is still in progress. + # + + ###################################################### + ## Recommended usage: ## + ## -------------------------------------------------## + ## from pyrs/utilities/NXstress import NXstress ## + ## ... ## + ## ws: HidraWorkspace ## + ## peakss: list[PeakCollection] ## + ## ... ## + ## # To write the first (, or only) entry: ## + ## with NXstress(.nxs, 'w') as nxS: ## + ## nxS.write(f, ws, peaks) ## + ## -------------------------------------------------## + ## # To write an additional entry: ## + ## # alternatively, this could have been done ## + ## # in the first `with` clause above. ## + ## with NXfile(.nxs, 'a') as nxS: ## + ## nxS.write(f, ws, peaks) ## + ###################################################### + + if self._root is None: + raise RuntimeError("Usage error: only usage as context manager is supported!") + entry_number = len(self._root.NXentry) + 1 + entry_name = group_naming_scheme(GROUP_NAME.ENTRY, entry_number) + if entry_name in self._root: + raise RuntimeError(f"Not implemented: overwriting existing `NXentry` '/{entry_name}'.") + + entry = self.init_group(ws, peakss) + self._root[entry_name] = entry + + def read(self, entry_number: int = 1): + """Read back a (HidraWorkspace, list[PeakCollection]) from the NXstress file. + + Parameters + ---------- + entry_number : int + Which NXentry to read (1-based). Default is 1. + + Returns + ------- + tuple + (HidraWorkspace, list[PeakCollection]) + """ + # Verify context manager is active + if self._root is None: + raise RuntimeError("Usage error: only usage as context manager is supported!") + + # Resolve entry name + entry_name = group_naming_scheme(GROUP_NAME.ENTRY, entry_number) + + # Access entry + entry = self._root[entry_name] + + # Read sample logs + sample_logs = _Sample.sampleLogsFromNexus(entry[GROUP_NAME.SAMPLE_DESCRIPTION]) + + # Read instrument + geometry, shift, wavelength = _Instrument.instrumentFromNexus(entry[GROUP_NAME.INSTRUMENT]) + is_calibrated = shift is not None + + # Read masks + default_mask, mask_dict = _Masks.masksFromNexus(entry[GROUP_NAME.INSTRUMENT][GROUP_NAME.MASKS]) + + # Build workspace + ws = HidraWorkspace() + ws.set_sample_logs_from_object(sample_logs) + # `set_wavelength` expects `dict[int, float]` + ws.set_wavelength( + {subrun_index: wavelength[n] for n, subrun_index in enumerate(ws.get_sub_runs())}, is_calibrated + ) + ws.set_instrument_geometry(geometry) + if shift is not None: + ws.set_detector_shift(shift) + ws.set_masks_from_dict(default_mask, mask_dict) + + # Read raw counts if present + if GROUP_NAME.INPUT_DATA in entry: + _InputData.readSubruns(ws, entry[GROUP_NAME.INPUT_DATA]) + + # Read reduced diffraction data from FIT group's DIFFRACTOGRAM subgroups + if GROUP_NAME.FIT in entry: + fit_group = entry[GROUP_NAME.FIT] + diff_data = {} + var_data = {} + two_theta_matrix = None + + for child_name in fit_group: + child = fit_group[child_name] + if not isinstance(child, NXdata): + continue + mask_name = suffix_from_group_name(child_name, GROUP_NAME.DIFFRACTOGRAM) + scan_pts, two_theta, data, errors = _Diffractogram.diffractogramFromNexus(child) + + # Map DEFAULT_TAG to None for workspace dict keys + ws_mask_key = None if mask_name == DEFAULT_TAG else mask_name + diff_data[ws_mask_key] = data + + # NOTE: Despite the field name 'diffractogram_errors', + # variance values (not standard errors) are stored in this field. + var_data[ws_mask_key] = errors + + if two_theta_matrix is None: + two_theta_matrix = two_theta + + if two_theta_matrix is not None: + ws.set_reduced_diffraction_data_set(two_theta_matrix, diff_data, var_data) + + # Read peak collections + peak_collections = [] + if GROUP_NAME.PEAKS in entry: + peaks_group = entry[GROUP_NAME.PEAKS] + if GROUP_NAME.FIT in entry: + fit_group = entry[GROUP_NAME.FIT] + peak_collections = _Peaks.peakCollectionsFromNexus(peaks_group, fit_group) + + return ws, peak_collections + + ############################################ + # ALL non-context-manager related methods ## + # must be `classmethod`. ## + ############################################ + + @classmethod + @validate_call_ + def _validateWorkspaceAndPeaksData(cls, ws: HidraWorkspace, peakss: list[PeakCollection]): + # VERIFY that all required logs are present. + logs = ws.sample_log_names + for k in REQUIRED_LOGS: + if k not in logs: + raise ValueError(f"NXstress requires log '{k}', which is not present") + + # VERIFY that no duplicate PeakCollections exist + _Peaks.validateNoDuplicatePeaks(peakss) + + # VERIFY that any or referenced by any `PeakCollection` is included in the workspace. + _Fit.validateWorkspaceAndPeaksData(ws, peakss) + + @classmethod + @validate_call_ + def _init(cls, ws: HidraWorkspace) -> NXentry: + # Create the NXentry and initialize any required attributes. + + """ + ├─ definition (dataset: "NXstress") + ├─ start_time (dataset: ISO8601 string) + ├─ end_time (dataset: ISO8601 string) + ├─ processing_type (dataset: string) + :: apart from 'definition', these fields may also be + lists by subrun. + """ + entry = NXentry() + entry["definition"] = "NXstress" + + # lists of 'start_time', 'end_time' for all subruns + try: + start_times: list[str] = [ + datetime.fromisoformat(t.decode("utf-8")).astimezone().isoformat() + for t in ws.get_sample_log_values("start_time") + ] + end_times: list[str] = [ + datetime.fromisoformat(t.decode("utf-8")).astimezone().isoformat() + for t in ws.get_sample_log_values("end_time") + ] + except ValueError as e: + if "Invalid isoformat string" not in str(e): + raise + logger.warning( + f"Log entries for sub-run start and end times are not in ISO-8601 format:\n" + f" in order to continue writing, a value of '{NO_LOG}' will be used for all time entries!" + ) + start_times = end_times = [NO_LOG for n in ws._sample_logs.subruns] + entry["start_time"] = NXfield(start_times) + entry["end_time"] = NXfield(end_times) + + # the type of the primary strain calculation: + # this might also be 'two-theta', but 'd-spacing' seems more likely + entry["processing_type"] = "d-spacing" + + return entry + + @classmethod + @validate_call_ + def init_group(cls, ws: HidraWorkspace, peakss: list[PeakCollection]) -> NXentry: + # Create and initialize a single NXstress-compatible NXentry tree: + # _multiple_ NXentry can exist within an NXstress-compatible HDF5 file. + # For example, distinct entries might be added for each set of + # data-reduction or sample conditions. + + # Verify that all data required by NXstress are present. + cls._validateWorkspaceAndPeaksData(ws, peakss) + + # Initialize this NXentry, and add required attributes. + entry = cls._init(ws) + + # 'input_data' group + entry[GROUP_NAME.INPUT_DATA] = _InputData.init_group(ws) + + # 'instrument' group + entry[GROUP_NAME.INSTRUMENT] = _Instrument.init_group(ws) + + # 'SAMPLE_DESCRIPTION' group + entry[GROUP_NAME.SAMPLE_DESCRIPTION] = _Sample.init_group(ws._sample_logs) + + # 'FIT' group + entry[GROUP_NAME.FIT] = _Fit.init_group(ws, peakss, ws._sample_logs) + + # 'PEAKS' group + entry[GROUP_NAME.PEAKS] = _Peaks.init_group(peakss, ws._sample_logs) + + return entry diff --git a/pyrs/utilities/NXstress/__init__.py b/pyrs/utilities/NXstress/__init__.py new file mode 100644 index 000000000..98f367c85 --- /dev/null +++ b/pyrs/utilities/NXstress/__init__.py @@ -0,0 +1,10 @@ +""" +pyrs/utilities/NXstress/__init__.py + +This package implements NeXus NXstress-schema compatible I/O for PyRS. +""" +# pyrs/utilities/NXstress/__init__.py + +from .NXstress import NXstress + +__all__ = ["NXstress"] diff --git a/pyrs/utilities/NXstress/_definitions.py b/pyrs/utilities/NXstress/_definitions.py new file mode 100644 index 000000000..4c2f6e265 --- /dev/null +++ b/pyrs/utilities/NXstress/_definitions.py @@ -0,0 +1,242 @@ +""" +pyrs/utilities/NXstress/_definitions.py + +Constants and definitions used by NeXus NXstress-compatible I/O. +""" + +from enum import Enum, StrEnum +from datetime import datetime +import h5py +import logging +from nexusformat.nexus import ( + NXbeam, + NXcollection, + NXdata, + NXdetector, + NXentry, + NXfield, + NXgroup, + NXinstrument, + NXmonochromator, + NXnote, + NXparameters, + NXprocess, + NXreflections, + NXsample, + NXsource, + NXtransformations, +) +import numpy as np +from typing import List, Tuple + +from pyrs.dataobjects.constants import HidraConstants + + +logger = logging.getLogger("pyrs.utilities.NXstress") +REQUIRED_LOGS: List[str] = [] + + +class _TypeBehavior: + # Avoid metaclass conflict if mixin were derived from `type` directly. + + def __call__(self, *args, **kwargs): + # Allow calling the enum member to construct via the underlying type + return self.value(*args, **kwargs) + + def is_instance(self, obj): + return isinstance(obj, self.value) + + def is_subclass(self, cls): + return issubclass(cls, self.value) + + def __str__(self): + return self.value.__name__ + + +class FIELD_DTYPE(_TypeBehavior, Enum): + # HDF5 dataset types for various fields + FLOAT_CONSTANT = np.float64 + FLOAT_DATA = np.float32 + INT_DATA = np.int32 + STRING = h5py.string_dtype(encoding="utf-8") + + +def CHUNK_SHAPE(rank: int) -> Tuple[int, ...]: + # chunk fast-axis only + return (1,) * (rank - 1) + (100,) + + +class REQUIRED_NAME(StrEnum): + # These are *required* group or dataset names, as specified in the `NXstress` schema. + + # FIT/DIFFRACTOGRAM sub-fields: + PEAK_PARAMETERS = "peak_parameters" + BACKGROUND_PARAMETERS = "background_parameters" + DGRAM_DIFFRACTOGRAM = "diffractogram" + DGRAM_DIFFRACTOGRAM_ERRORS = "diffractogram_errors" + DGRAM_FIT = "fit" + DGRAM_FIT_ERRORS = "fit_errors" + + INSTRUMENT = "instrument" + BEAM = "beam_intensity_profile" + PEAKS = "peaks" + + +class GROUP_NAME(StrEnum): + # Group names: ordered by their appearance in the NXstress schema: + # + # -- Unless initialized from a `REQUIRED_NAME`, these may be modified as necessary. + # -- In case of multiple group instances, the enum value here becomes the , + # with the or becoming a name suffix (see `group_naming_scheme` below). + # + + # --- mypy: --- + allowMultiple: bool + nxClass: type[NXgroup] + # ------------- + + # Multiple NXentry are allowed in case there are multiple reduced data sets: + # e.g. from the same input data set, using different optimal peak-fit combinations. + ENTRY = ("entry", True, NXentry) + + INSTRUMENT = (REQUIRED_NAME.INSTRUMENT, False, NXinstrument) + CALIBRATION = ("calibration", False, NXnote) + + # SOURCE = ('source', False, NXsource) + SOURCE = ("SOURCE", False, NXsource) # *** DEBUG *** validator bug + + # DETECTOR = ('detector', True, NXdetector) + DETECTOR = ("DETECTOR", True, NXdetector) # *** DEBUG *** validator bug + + TRANSFORMATIONS = ("transformations", False, NXtransformations) + BEAM = (REQUIRED_NAME.BEAM, False, NXbeam) + MONOCHROMATOR = ("monochromator", False, NXmonochromator) + + # SAMPLE_DESCRIPTION = ('sample', False, NXsample) + SAMPLE_DESCRIPTION = ("SAMPLE_DESCRIPTION", False, NXsample) # *** DEBUG *** validator bug + + # FIT (NXprocess) groups contain the reduced data (and associated metadata): + # there should be one FIT group corresponding to each detector mask. + + # FIT = ('fit', True, NXprocess) + FIT = ("FIT", True, NXprocess) # *** DEBUG *** validator bug + + # input NXparameters subgroup in 'NXprocess' + INPUT = ("input", False, NXparameters) + + # DESCRIPTION = ('description', False, NXnote) # *** DEBUG *** validator bug + DESCRIPTION = ("DESCRIPTION", False, NXnote) + + PEAK_PARAMETERS = (REQUIRED_NAME.PEAK_PARAMETERS, False, NXparameters) + BACKGROUND_PARAMETERS = (REQUIRED_NAME.BACKGROUND_PARAMETERS, False, NXparameters) + + # DIFFRACTOGRAM = ('diffractogram', False, NXdata) # *** DEBUG *** validator bug + DIFFRACTOGRAM = ("DIFFRACTOGRAM", False, NXdata) + DGRAM_TWO_THETA_NAME = ("XAXIS", False, NXfield) # *** DEBUG *** validator bug: normally would be just 'two_theta' + + # DIFFRACTOGRAM sub-fields: + DGRAM_DIFFRACTOGRAM = (REQUIRED_NAME.DGRAM_DIFFRACTOGRAM, False, NXfield) + DGRAM_DIFFRACTOGRAM_ERRORS = (REQUIRED_NAME.DGRAM_DIFFRACTOGRAM_ERRORS, False, NXfield) + DGRAM_FIT = (REQUIRED_NAME.DGRAM_FIT, False, NXfield) + DGRAM_FIT_ERRORS = (REQUIRED_NAME.DGRAM_FIT_ERRORS, False, NXfield) + + # PEAKS (NXreflections) presents the canonical reduction result: there is only one per NXentry. + PEAKS = (REQUIRED_NAME.PEAKS, False, NXreflections) + + ## OPTIONAL GROUPS, allowed by but not specified by the schema: ## + + # Including the input data allows all of the information for a reduction to be contained in one file. + INPUT_DATA = ("input_data", False, NXdata) + + # Masks are added as a subgroup under the `INSTRUMENT` group: + # both and are currently recognized, + # however the mask names must be distinct, because they're used as suffix tags + # when creating other group names. + MASKS = ("masks", False, NXcollection) + + def __new__(cls, value, allowMultiple: bool, nxClass: type[NXgroup]): + obj = str.__new__(cls, value) + obj._value_ = value + obj.allowMultiple = allowMultiple + obj.nxClass = nxClass + return obj + + +# `NXstress` records `peak_parameters` and `background_parameters` in distinct groups. +EFFECTIVE_BACKGROUND_PARAMETERS = ["A0", "A1", "A2"] + +# Name or suffix corresponding to the default dataset: +# -- when a group name uses this as a suffix tag (e.g. multiple FIT (NXprocess) groups, one for each mask) +# this default tag should be _omitted_ from the group name. +# -- presently this is only used for masks, to allow multiple FIT.diffractogram groups. +DEFAULT_TAG = HidraConstants.DEFAULT_MASK + +UNDEFINED_PEAK_TAG = "_undefined_" + +NO_LOG = "_no_log_" + + +def group_naming_scheme(base_name: str, suffix: int | str) -> str: + # Generate the name for an HDF5 group, allowing for multiple group instances: + # instance: + # int: enumerated group names: '_1' is omitted; + # str: group names (e.g. 'DIFFRACTOGRAM' (NXdata)), delineated using a tag suffix: '__DEFAULT_' is omitted. + if not isinstance(suffix, (int, str)): + raise RuntimeError(f"`group_naming_scheme`: not implemented for suffix '{suffix}'") + + tag = "" + if isinstance(suffix, int) and suffix > 1 or isinstance(suffix, str) and suffix != DEFAULT_TAG: + tag = f"_{suffix}" + + return f"{base_name}{tag}" + + +def suffix_from_group_name(group_name: str, base_name: str) -> str: + """Reverse of `group_naming_scheme`: extract the suffix from a group name. + + 'DIFFRACTOGRAM' -> DEFAULT_TAG + 'DIFFRACTOGRAM_mask_A' -> 'mask_A' + + Parameters + ---------- + group_name : str + The group name to parse + base_name : str + The base name used to form the group name + + Returns + ------- + str + The appended suffix (or DEFAULT_TAG for the default case) + """ + prefix = str(base_name) + if group_name == prefix: + return DEFAULT_TAG + elif group_name.startswith(prefix + "_"): + return group_name[len(prefix) + 1 :] + else: + raise RuntimeError(f"Cannot extract suffix (e.g. mask name) from '{group_name}'") + + +def allowed_identifier(s: str) -> str: + # Convert PV-log name to NeXus-compliant identifier + + # This function is simplified, for the moment (making several assumptions about the input string): + # + # -- ':' characters are not allowed, and are replaced by '_'; + # -- '.' are allowed, and are assumed to be in the interior + # of string; + # -- TODO: check for other disallowed chars, such as "$"? + + return s.replace(":", "_") + + +def is_ISO_8601(s: str) -> bool: + scannable = True + try: + datetime.fromisoformat(s) + except ValueError as e: + if "Invalid isoformat string" not in str(e): + raise + scannable = False + return scannable diff --git a/pyrs/utilities/NXstress/_fit.py b/pyrs/utilities/NXstress/_fit.py new file mode 100644 index 000000000..f7eaab983 --- /dev/null +++ b/pyrs/utilities/NXstress/_fit.py @@ -0,0 +1,551 @@ +""" +pyrs/utilities/NXstress/_fit.py + +Private service class for NeXus NXstress-compatible I/O. +This class provides I/O for the `fit` `NXprocess` subgroup: + this subgroup includes the reduced output data as a 'diffraction_data' `NXdata` group. +""" + +from datetime import datetime +from nexusformat.nexus import NXdata, NXfield, NXnote, NXparameters, NXprocess +import numpy as np +from typing import Tuple + +from pyrs.peaks.peak_collection import PeakCollection +from pyrs.core.peak_profile_utility import BackgroundFunction +from pyrs.core.workspaces import HidraWorkspace +from pyrs.dataobjects.sample_logs import SampleLogs +from pyrs.utilities.pydantic_transition import validate_call_ + +from ._definitions import FIELD_DTYPE, CHUNK_SHAPE, DEFAULT_TAG, GROUP_NAME, group_naming_scheme, UNDEFINED_PEAK_TAG +from ._peaks import _Peaks + +""" +REQUIRED PARAMETERS FOR NXstress: +--------------------------------- + +├─ fit (NXprocess, group) +│ ├─ date (dataset: ISO8601 string) +│ ├─ program (dataset: string) +│ ├─ description (NXnote, group) +│ ├─ peakparameters (NXparameters, group) +│ └─ diffractogram (NXdata, group) +│ ├─ diffractogram (dataset) +│ ├─ diffractogram_errors (dataset) +│ ├─ daxis/xaxis (dataset) +│ ├─ @axes (attribute: string) +│ └─ @signal (attribute: string) +""" + + +class _PeakParameters: + @classmethod + def _init(cls, peakss: list[PeakCollection]) -> NXparameters: + # required 'peak_parameters' subgroup + pp = NXparameters() + peak_profile = str(peakss[0].peak_profile).lower() if peakss else UNDEFINED_PEAK_TAG + + # To be compliant with `NXstress` schema: + # this cannot be tiled: all `PeakCollection` must share the same `peak_profile`. + pp["title"] = NXfield(peak_profile, dtype=FIELD_DTYPE.STRING.value) + + pp["center"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="degree" + ) + pp["center_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="degree" + ) + pp["height"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts" + ) + pp["height_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts" + ) + pp["fwhm"] = NXfield(np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="degree") + pp["fwhm_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="degree" + ) + + # Voigt or Pseudo-Voigt: Lorentzian fraction + pp["form_factor"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="1" + ) + pp["form_factor_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="1" + ) + + return pp + + @classmethod + @validate_call_ + def init_group(cls, peakss: list[PeakCollection]) -> NXparameters: + # required 'peak_parameters' subgroup + pp = cls._init(peakss) + + for peak_collection in sorted(peakss, key=_Peaks.PeakIndex.sort_key): + cls._append_peak(pp, peak_collection) + + return pp + + @classmethod + @validate_call_ + def _append_peak(cls, pp: NXparameters, peaks: PeakCollection) -> NXparameters: + # Append the peak parameters from a single `PeakCollection` instance. + + # Verify the `PeakCollection` peak-profile type. + peak_profile = str(peaks.peak_profile).lower() + if pp["title"] == UNDEFINED_PEAK_TAG: + pp["title"].replace(peak_profile) + elif peak_profile != pp["title"]: + raise ValueError( + f"All `PeakCollection` must share the same peak profile ''{pp['title']}'', not ''{peak_profile}''." + ) + + # Use _effective_ peak parameters here: all peaks will then have the same number of parameter, + # and all parameter values will be in the expected column. + # We have one new parameter value for each of 'N_scan' subruns. + + N_scan = len(peaks.sub_runs) + cur_rows = pp["center"].shape[0] + new_rows = cur_rows + N_scan + + ## In the following, make sure to include _only_ the peak-function parameters. + params_value, params_error = peaks.get_effective_params() + + pp["center"].resize((new_rows,)) + pp["center_errors"].resize((new_rows,)) + pp["height"].resize((new_rows,)) + pp["height_errors"].resize((new_rows,)) + pp["fwhm"].resize((new_rows,)) + pp["fwhm_errors"].resize((new_rows,)) + pp["form_factor"].resize((new_rows,)) + pp["form_factor_errors"].resize((new_rows,)) + + pp["center"][cur_rows:] = params_value["Center"].astype(np.float64) + pp["center_errors"][cur_rows:] = params_error["Center"].astype(np.float64) + pp["height"][cur_rows:] = params_value["Height"].astype(np.float64) + pp["height_errors"][cur_rows:] = params_error["Height"].astype(np.float64) + pp["fwhm"][cur_rows:] = params_value["FWHM"].astype(np.float64) + pp["fwhm_errors"][cur_rows:] = params_error["FWHM"].astype(np.float64) + + # Voigt or Pseudo-Voigt: Lorentzian fraction + pp["form_factor"][cur_rows:] = (1.0 - params_value["Mixing"]).astype(np.float64) + pp["form_factor_errors"][cur_rows:] = params_error["Mixing"].astype(np.float64) + + return pp + + @classmethod + def peakParametersForRange(cls, pp, start: int, end: int) -> tuple: + """Extract peak parameters for a specific range and convert to native parameters. + + Reads effective parameters from the NXparameters group, slices to the specified range, + and converts to native parameters using the appropriate converter. + + CRITICAL: form_factor is stored as (1 - Mixing), so we invert: Mixing = 1 - form_factor + + Parameters + ---------- + pp : NXparameters + Peak parameters group + start : int + Starting index (inclusive) + end : int + Ending index (exclusive) + + Returns + ------- + tuple[np.ndarray, np.ndarray] + (native_values, native_errors) structured arrays + """ + from pyrs.core.peak_profile_utility import PeakShape, get_parameter_dtype, get_effective_parameters_converter + + # Get peak profile type + peak_shape = PeakShape.getShape(pp["title"].nxdata) + + # Build effective parameter structured arrays + N = end - start + eff_values = np.zeros(N, dtype=get_parameter_dtype(effective=True)) + eff_errors = np.zeros(N, dtype=get_parameter_dtype(effective=True)) + + # Slice datasets and populate effective arrays + eff_values["Center"] = pp["center"].nxdata[start:end] + eff_values["Height"] = pp["height"].nxdata[start:end] + eff_values["FWHM"] = pp["fwhm"].nxdata[start:end] + + # CRITICAL: Invert form_factor to Mixing + eff_values["Mixing"] = 1.0 - pp["form_factor"].nxdata[start:end] + + eff_errors["Center"] = pp["center_errors"].nxdata[start:end] + eff_errors["Height"] = pp["height_errors"].nxdata[start:end] + eff_errors["FWHM"] = pp["fwhm_errors"].nxdata[start:end] + eff_errors["Mixing"] = pp["form_factor_errors"].nxdata[start:end] + + # Intensity is not stored separately (derived from Height/FWHM/Mixing) + # Set to NaN - will be recalculated if needed + eff_values["Intensity"] = np.nan + eff_errors["Intensity"] = np.nan + + # A0, A1, A2 will be populated from backgroundParametersForRange + # Initialize to 0.0 as they are part of the effective parameter dtype + eff_values["A0"] = 0.0 + eff_values["A1"] = 0.0 + eff_values["A2"] = 0.0 + eff_errors["A0"] = 0.0 + eff_errors["A1"] = 0.0 + eff_errors["A2"] = 0.0 + + # Convert to native parameters + converter = get_effective_parameters_converter(peak_shape) + native_values, native_errors = converter.calculate_native_parameters(eff_values, eff_errors) + + return native_values, native_errors + + +class _BackgroundParameters: + @classmethod + def _init(cls, peakss: list[PeakCollection]) -> NXparameters: + # required 'background_parameters' subgroup + bp = NXparameters() + + # To be compliant with `NXstress` schema: + # this cannot be tiled: all `PeakCollection` must share the same `background_type`. + background_function = ( + str(BackgroundFunction.getFunction(peakss[0].background_type)).lower() if peakss else UNDEFINED_PEAK_TAG + ) + bp["title"] = NXfield(background_function, dtype=FIELD_DTYPE.STRING.value) + + bp["A0"] = NXfield(np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts") + bp["A0_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts" + ) + + bp["A1"] = NXfield(np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts") + bp["A1_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts" + ) + + bp["A2"] = NXfield(np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts") + bp["A2_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="counts" + ) + + return bp + + @classmethod + @validate_call_ + def init_group(cls, peakss: list[PeakCollection]) -> NXparameters: + # required 'background_parameters' subgroup + bp = cls._init(peakss) + + for peak_collection in sorted(peakss, key=_Peaks.PeakIndex.sort_key): + cls._append_peak(bp, peak_collection) + + return bp + + @classmethod + @validate_call_ + def _append_peak(cls, bp: NXparameters, peaks: PeakCollection) -> NXparameters: + # Append the background parameters from a single `PeakCollection` instance. + + # Verify the `PeakCollection` background type. + background_title = str(BackgroundFunction.getFunction(peaks.background_type)).lower() + if bp["title"] == UNDEFINED_PEAK_TAG: + bp["title"].replace(background_title) + elif background_title != bp["title"]: + raise ValueError( + f"All `PeakCollection` must share the same background type ''{bp['title']}'', not ''{background_title}''." + ) + + ## In the following, make sure to include _only_ the background parameters. + params_value, params_error = peaks.get_effective_params() + + N_scan = len(peaks.sub_runs) + cur_rows = bp["A0"].shape[0] + new_rows = cur_rows + N_scan + + bp["A0"].resize((new_rows,)) + bp["A0_errors"].resize((new_rows,)) + bp["A1"].resize((new_rows,)) + bp["A1_errors"].resize((new_rows,)) + bp["A2"].resize((new_rows,)) + bp["A2_errors"].resize((new_rows,)) + + bp["A0"][cur_rows:,] = params_value["A0"].astype(np.float64) + bp["A0_errors"][cur_rows:,] = params_error["A0"].astype(np.float64) + bp["A1"][cur_rows:,] = params_value["A1"].astype(np.float64) + bp["A1_errors"][cur_rows:,] = params_error["A1"].astype(np.float64) + bp["A2"][cur_rows:,] = params_value["A2"].astype(np.float64) + bp["A2_errors"][cur_rows:,] = params_error["A2"].astype(np.float64) + + return bp + + @classmethod + def backgroundParametersForRange(cls, bp, start: int, end: int) -> tuple: + """Extract background parameters for a specific range. + + Reads background coefficients from the NXparameters group and slices to the specified range. + + Parameters + ---------- + bp : NXparameters + Background parameters group + start : int + Starting index (inclusive) + end : int + Ending index (exclusive) + + Returns + ------- + tuple[np.ndarray, np.ndarray] + (eff_bg_values, eff_bg_errors) structured arrays with A0, A1, A2 fields + """ + from pyrs.core.peak_profile_utility import get_parameter_dtype + + # Build effective background parameter arrays + N = end - start + eff_bg_values = np.zeros(N, dtype=get_parameter_dtype(effective=True)) + eff_bg_errors = np.zeros(N, dtype=get_parameter_dtype(effective=True)) + + # Slice datasets and populate arrays + eff_bg_values["A0"] = bp["A0"].nxdata[start:end] + eff_bg_values["A1"] = bp["A1"].nxdata[start:end] + eff_bg_values["A2"] = bp["A2"].nxdata[start:end] + + eff_bg_errors["A0"] = bp["A0_errors"].nxdata[start:end] + eff_bg_errors["A1"] = bp["A1_errors"].nxdata[start:end] + eff_bg_errors["A2"] = bp["A2_errors"].nxdata[start:end] + + return eff_bg_values, eff_bg_errors + + +class _Diffractogram: + @classmethod + def _get_diffraction_data(cls, ws: HidraWorkspace, mask_name: str) -> Tuple[np.ndarray, np.ndarray]: + # Workaround for PyRS codebase use of `None` as the default key. + data_key = cls._diffraction_data_key(mask_name) + if data_key not in ws._diff_data_set: + raise RuntimeError( + f"NXstress._fit._Diffractogram: usage error: diffraction data '{data_key}' is not present in the workspace" + ) + if data_key not in ws._var_data_set: + raise RuntimeError( + f"NXstress._fit._Diffractogram: variance for diffraction data '{mask_name}' is not present in the workspace:\n" + " how was this workspace initialized?" + ) + return ws._diff_data_set[data_key], ws._var_data_set[data_key] + + @classmethod + def _diffraction_data_key(cls, mask_name: str) -> str | None: + # Workaround for PyRS codebase use of `None` as the default key. + return mask_name if mask_name != DEFAULT_TAG else None + + @classmethod + def _init(cls, ws: HidraWorkspace) -> NXdata: + if ws._2theta_matrix is None: + raise RuntimeError("Usage error: cannot write NXstress file: workspace doesn't include any reduced data.") + dg = NXdata() + return dg + + @classmethod + @validate_call_ + def init_group(cls, ws: HidraWorkspace, maskName: str, peakss: list[PeakCollection]) -> NXdata: + # required DIFFRACTOGRAM (NXdata) subgroup: + + dg = cls._init(ws) + dg.attrs["signal"] = GROUP_NAME.DGRAM_DIFFRACTOGRAM + dg.attrs["auxiliary_signals"] = [ + GROUP_NAME.DGRAM_DIFFRACTOGRAM_ERRORS, + GROUP_NAME.DGRAM_FIT, + GROUP_NAME.DGRAM_FIT_ERRORS, + ] + dg.attrs["axes"] = ["scan_point", "."] # do _not_ specify a 2-D theta in 'axes' + dg.attrs["two_theta_indices"] = [0, 1] # two-theta has shape (, ) + dg["scan_point"] = NXfield(ws.get_sub_runs()) + dg["scan_point"].attrs["units"] = "" + + two_theta = ws._2theta_matrix + dg[GROUP_NAME.DGRAM_TWO_THETA_NAME] = NXfield( # *** DEBUG *** validator bug + two_theta, units="degree" + ) + + data, errors = cls._get_diffraction_data(ws, maskName) + dg[GROUP_NAME.DGRAM_DIFFRACTOGRAM] = NXfield( + data, dtype=FIELD_DTYPE.FLOAT_DATA.value, interpretation="spectrum", units="counts" + ) + + dg[GROUP_NAME.DGRAM_DIFFRACTOGRAM_ERRORS] = NXfield(errors, dtype=FIELD_DTYPE.FLOAT_DATA.value, units="counts") + + ## + ## ENTRY/FIT/DIFFRACTOGRAM/fit, fit_errors: required datasets under `NXstress`: + ## these should contain the spectrum reconstructed from the fitted model. + ## For the moment, these will be initialized to NaN. + ## + dg[GROUP_NAME.DGRAM_FIT] = NXfield( + np.empty((0, 0), dtype=np.float64), maxshape=(None, None), chunks=CHUNK_SHAPE(2), fillvalue=np.nan + ) + dg[GROUP_NAME.DGRAM_FIT].attrs["interpretation"] = "spectrum" + dg[GROUP_NAME.DGRAM_FIT].attrs["units"] = "counts" + dg[GROUP_NAME.DGRAM_FIT_ERRORS] = NXfield( + np.empty((0, 0), dtype=np.float64), maxshape=(None, None), chunks=CHUNK_SHAPE(2), fillvalue=np.nan + ) + dg[GROUP_NAME.DGRAM_FIT_ERRORS].attrs["units"] = "counts" + + return dg + + @classmethod + @validate_call_ + def diffractogramFromNexus(cls, dg): + """Read diffractogram data from NXdata group. + + Parameters + ---------- + dg : NXdata + The DIFFRACTOGRAM NXdata group from the HDF5 file + + Returns + ------- + tuple + (scan_points, two_theta, diffractogram, diffractogram_errors) + + Note + ---- + The write side stores variance in 'diffractogram_errors', so this is + returned directly without conversion. + """ + # Read scan_point array + scan_points = dg["scan_point"].nxdata + + # Read two_theta array (using the correct field name from GROUP_NAME) + two_theta = dg[GROUP_NAME.DGRAM_TWO_THETA_NAME].nxdata + + # Read diffractogram and diffractogram_errors (which stores variance) + diffractogram = dg[GROUP_NAME.DGRAM_DIFFRACTOGRAM].nxdata + diffractogram_errors = dg[GROUP_NAME.DGRAM_DIFFRACTOGRAM_ERRORS].nxdata + + return scan_points, two_theta, diffractogram, diffractogram_errors + + +class _Fit: + ######################################## + # ALL methods must be `classmethod`. ## + ######################################## + + ## + ## Notes: + ## -- Under 'NXstress', there can be multiple FIT (NXprocess) groups in the NXentry, but the results from only + ## one of these should be promoted to the canonical fit results in the PEAKS (NXreflections) group. + ## -- FIT (NXprocess) contains the as-fit peak and background parameters, including any information associated + ## with the fitting process. In this section, any appropriate coordinate system may be used. + ## -- Not yet in PyRS: FIT/DIFFRACTOGRAM/fit, fit_errors: these datasets should contain the reconstructed spectrum + # from the fitted model. We don't seem to have methods to do this yet, so these are initialized to NaN. + ## -- The canonical fit results in PEAKS (NXreflections) should contain the final results, converted to the final + ## coordinate system (e.g. usually `d-spacing`). + ## + @classmethod + @validate_call_ + def _init(cls, logs: SampleLogs, *, processing_description: str, processing_time) -> NXprocess: + # Initialize the 'FIT' (NXprocess) group: + + fit = NXprocess() + + input_ = NXparameters() + input_["description"] = "Peak fits and reduced diffractogram data" + fit[GROUP_NAME.INPUT] = input_ + + # Required information fields: + fit["date"] = NXfield(processing_time) + fit["program"] = NXfield("PyRS") + fit["raw_data_file"] = NXfield(logs["Filename"][0].decode("utf-8")) + + note = NXnote( + type="text/plain", + description="Processing description", + # author='', + # date='', + data=processing_description, + ) + fit[GROUP_NAME.DESCRIPTION] = note + + return fit + + @classmethod + @validate_call_ + def init_group( + cls, + ws: HidraWorkspace, + peakss: list[PeakCollection], + logs: SampleLogs, + processing_description: str = "", + processing_time: str | None = None, + ): + # Initialize a new 'FIT' (NXprocess) group: + # (see `_definitions.group_naming_scheme`). + + ## Under `NXstress`: `FIT` (NXprocess) groups contain peak and background-fit results, including any + ## information relevant to the fitting process used. + fit = cls._init( + logs, + processing_description=processing_description, + processing_time=processing_time if bool(processing_time) else datetime.now().astimezone().isoformat(), + ) + fit[GROUP_NAME.PEAK_PARAMETERS] = _PeakParameters.init_group(peakss) + fit[GROUP_NAME.BACKGROUND_PARAMETERS] = _BackgroundParameters.init_group(peakss) + + # Add one DIFFRACTOGRAM group for each reduced diffraction dataset present in the workspace. + mask_keys = set(ws._diff_data_set.keys()) + mask_keys.discard(None) + mask_keys.add(DEFAULT_TAG) + for mask in mask_keys: + ## TODO: mask naming (and storage) is messed up. They all need to be accessed the same way, + ## regardless of whether or not the "default" mask is being accessed. + ## Here we assume that this loop also accesses data for the _DEFAULT_ mask, and that the default + ## mask has the '_DEFAULT_' name, and not some other name, such as 'main' or `None`?! + dgram_name = group_naming_scheme(GROUP_NAME.DIFFRACTOGRAM, mask) + if dgram_name in fit.NXdata: + raise RuntimeError( + f"Usage error: DIFFRACTOGRAM (NXdata) group '{dgram_name}' already exists in the current (NXprocess) group." + ) + fit[dgram_name] = _Diffractogram.init_group(ws, mask, peakss) + + return fit + + @classmethod + def validateWorkspaceAndPeaksData(cls, ws: HidraWorkspace, peakss: list[PeakCollection]): + # VERIFY that scan_point[s] and mask[s] reference by any `PeakCollection` are present in the workspace. + scan_point = set(ws.get_sub_runs().raw_copy()) + + diff_data_keys = set(ws._diff_data_set.keys()) + var_data_keys = set(ws._var_data_set.keys()) + if diff_data_keys != var_data_keys: + raise ValueError( + f"Diffraction-data keys '{diff_data_keys}' and variance keys '{var_data_keys}' are not the same." + ) + + mask_keys = set(ws._mask_dict.keys()) + mask_keys.discard(None) + mask_keys.add(DEFAULT_TAG) + + for peaks in peakss: + # VERIFY that any referenced by any `PeakCollection` is included in the workspace. + + # Note: `PeakCollection.get_sub_runs()` is *broken*: + # it does not actually return a `SubRuns` instance! + peaks_scan_point = set(peaks._sub_run_array.raw_copy()) + if not peaks_scan_point.issubset(scan_point): + raise ValueError( + f"Scan points {peaks_scan_point}, required by `PeakCollection`,\n" + f" are not present in workspace scan points {scan_point}." + ) + + # VERIFY that any referenced by any `PeakCollection` is included in the workspace. + peaks_mask = peaks.mask + if peaks_mask not in mask_keys: + raise ValueError( + f"Mask '{peaks_mask}' required by `PeakCollection`,\n is not present in the workspace." + ) + data_key = _Diffractogram._diffraction_data_key(peaks_mask) + if data_key not in ws._diff_data_set: + raise ValueError( + f"Reduced data required for mask '{peaks_mask}', required by `PeakCollection`,\n" + " is not present in the workspace." + ) diff --git a/pyrs/utilities/NXstress/_input_data.py b/pyrs/utilities/NXstress/_input_data.py new file mode 100644 index 000000000..cbd97674d --- /dev/null +++ b/pyrs/utilities/NXstress/_input_data.py @@ -0,0 +1,73 @@ +""" +pyrs/utilities/NXstress/_input_data.py + +Private service class for NeXus NXstress-compatible I/O. +This class provides I/O for the `input_data` `NXdata` subgroup. +""" + +from nexusformat.nexus import NXdata, NXfield +import numpy as np + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.utilities.pydantic_transition import validate_call_ + +from ._definitions import CHUNK_SHAPE, FIELD_DTYPE + + +""" +REQUIRED PARAMETERS FOR NXstress: +--------------------------------- + +NONE: 'input_data' (NXdata, group) is allowed by the NXstress schema, but it is optional. +""" + + +class _InputData: + ######################################## + # ALL methods must be `classmethod`. ## + ######################################## + + @classmethod + @validate_call_ + def init_group(cls, ws: HidraWorkspace, data: NXdata = None): + # Initialize the input-data group. + + # Raw data may not actually be loaded in the `HidraWorkspace`: + # in that case, just initialize an empty NXdata group. + scan_points = ws._raw_counts.keys() + scans = ( + np.stack([ws.get_detector_counts(p).astype(FIELD_DTYPE.FLOAT_DATA.value) for p in scan_points]) + if len(scan_points) + else np.empty((0, 0), dtype=FIELD_DTYPE.FLOAT_DATA.value) + ) + + # TODO: append to the group, if it already exists. + if data is not None: + raise RuntimeError("not implemented: append detector_counts data to NXstress file") + else: + data = NXdata() + data["detector_counts"] = NXfield(scans, maxshape=(None, None), chunks=CHUNK_SHAPE(2)) + data["scan_point"] = scan_points + + # Set attributes for axes and signal + data.attrs["signal"] = "detector_counts" + data.attrs["axes"] = ["scan_point", "."] + + return data + + @classmethod + @validate_call_ + def readSubruns(cls, ws: HidraWorkspace, data: NXdata): + # Initialize `HidraWorkspace` detector_counts from input-data group. + + # TODO: append to the `HidraWorkspace`, if any detector_counts data already exists. + scan_points = data["scan_point"].nxdata + + # `HidraWorkspace` must already contain its `SampleLogs`, and scan-points must match. + if ws.get_sub_runs() != scan_points: + raise RuntimeError("not implemented: append or change detector_counts data on existing workspace") + + scan_points = data["scan_point"].nxdata + scans = data["detector_counts"].nxdata + for n, p in enumerate(scan_points): + ws.set_raw_counts(p, scans[n]) diff --git a/pyrs/utilities/NXstress/_instrument.py b/pyrs/utilities/NXstress/_instrument.py new file mode 100644 index 000000000..4ad957ed7 --- /dev/null +++ b/pyrs/utilities/NXstress/_instrument.py @@ -0,0 +1,487 @@ +""" +pyrs/utilities/NXstress/_instrument.py + +Private service class for NeXus NXstress-compatible I/O. +This class provides I/O for the `instrument` `NXinstrument` subgroup. +""" + +import logging +from nexusformat.nexus import ( + NXbeam, + NXcollection, + NXdetector, + NXdetector_module, + NXfield, + NXinstrument, + NXlink, + NXmonochromator, + NXnote, + NXsource, + NXtransformations, +) +import numpy as np +import json + +from pyrs.core.instrument_geometry import DENEXDetectorGeometry, DENEXDetectorShift +from pyrs.core.workspaces import HidraWorkspace +from pyrs.utilities.pydantic_transition import validate_call_ + +from ._definitions import CHUNK_SHAPE, DEFAULT_TAG, FIELD_DTYPE, GROUP_NAME + + +_logger = logging.getLogger(__name__) + +""" +REQUIRED PARAMETERS FOR NXstress: +--------------------------------- + +├─ instrument (NXinstrument, group) +│ ├─ name (dataset) +│ ├─ source (NXsource, group) +│ ├─ detector (NXdetector, group) +│ └─ masks (optional) (NXcollection, group) +""" + + +class _Instrument: + ######################################## + # ALL methods must be `classmethod`. ## + ######################################## + + @classmethod + def _init(cls, name: str, short_name: str) -> NXinstrument: + inst = NXinstrument() # WARNING: cannot assign 'name' field via kwarg! + inst["name"] = name + inst["name"].attrs["short_name"] = short_name + return inst + + @classmethod + @validate_call_ + def init_group(cls, ws: HidraWorkspace) -> NXinstrument: + """ + Create a new NXinstrument group subtree. + Conventions: + - Array datasets use explicit NumPy dtypes (np.int64 / np.float64). + - Python native int/float are used for scalars. + - DENEXDetectorGeometry.detectorsize -> (rows, cols) + - DENEXDetectorGeometry.pixeldimension -> (px, py) (meters) + - If present, setup._geometryshift is DENEXDetectorShift. + """ + inst = cls._init("HB2B", "HB2B") + + N_scan_point = len(ws.get_sub_runs()) + + # Detector base geometry and transformations + geom: DENEXDetectorGeometry = ws.get_instrument_setup() + shift: DENEXDetectorShift | None = ws.get_detector_shift() + is_calibrated = shift is not None + + # Wavelength (`get_wavelength` returns either a single `float` or a `dict` keyed by subrun) + wavelength = ws.get_wavelength(is_calibrated, False) + if isinstance(wavelength, dict): + # `dict` order should be the same as the sorted subruns order + wavelength = [l_ for l_ in wavelength.values()] + elif isinstance(wavelength, float): + wavelength = list((wavelength,) * N_scan_point) + elif wavelength is None: + wavelength = list((np.nan,) * N_scan_point) + else: + raise RuntimeError(f"unable to parse wavelength from `HidraWorkspace.get_wavelength`: {wavelength}") + if len(wavelength) != N_scan_point: + raise ValueError( + "Workspace must have either a single wavelength value,\n" + " or one wavelength value for each of {N_scan_point} subruns." + ) + + # Construct required NeXus subgroups: + # NXsource, NXmonochromator, NXdetector, NXtransformations. + src = NXsource() + src["type"] = NXfield("Reactor Neutron Source") + src["probe"] = NXfield("neutron") + + mono = NXmonochromator() + # `wavelength` by ? + mono["wavelength"] = NXfield(wavelength, units="angstrom", calibrated=is_calibrated) + + det = NXdetector() + det["type"] = "He_3 PSD" + # Detector size (in rows and columns) and pixel size (in meters) + nrows, ncols = geom.detector_size + px_m, py_m = geom.pixel_dimension # meters + + # det['data_size'] = NXfield(np.array([nrows, ncols], dtype=np.int64), dtype=np.int64) + # det['x_pixel_size'] = NXfield(np.array(px_m, dtype=np.float64), dtype=np.float64, units='m') + # det['y_pixel_size'] = NXfield(np.array(py_m, dtype=np.float64), dtype=np.float64, units='m') + + # Note: moving these fields to a subgroup `NXdetector_module` allows us to use scalars here, + # otherwise, the strict-mode validators require that we enter one value for each pixel! + det["detector_bank"] = NXdetector_module( + data_size=NXfield(np.array([nrows, ncols], dtype=np.int64), dtype=np.int64), + fast_pixel_direction=NXfield(np.array(px_m, dtype=np.float64), dtype=np.float64, units="m"), + slow_pixel_direction=NXfield(np.array(py_m, dtype=np.float64), dtype=np.float64, units="m"), + depends_on=".", + ) + + # Beam intensity profile + beam = NXbeam() + # TODO: fill in the beam-intensity profile. + + # Transformations chain (values as native floats; axis vectors as float64 arrays) + trans = NXtransformations() + + if is_calibrated and shift is not None: + tx = float(shift.center_shift_x) # meters + ty = float(shift.center_shift_y) # meters + tz = float(shift.center_shift_z) # meters + + # Sample-to-detector distance: + # TODO: RE `L2`: At present there seems no way to determine if the `DENEXDetectorGeometry` + # already has had the _arm_ shift applied to it -- this issue needs to be fixed! + distance = float(geom.arm_length) # meters + + rotx = float(shift.rotation_x) # degrees + roty = float(shift.rotation_y) # degrees + rotz = float(shift.rotation_z) # degrees + tth0 = float(shift.two_theta_0) # degrees + else: + tx = ty = tz = 0.0 + # Always write the actual arm_length, not 0.0 + distance = float(geom.arm_length) # meters + rotx = roty = rotz = tth0 = 0.0 + + ex = np.array([1.0, 0.0, 0.0], dtype=np.float64) + ey = np.array([0.0, 1.0, 0.0], dtype=np.float64) + ez = np.array([0.0, 0.0, 1.0], dtype=np.float64) + + depends = "." + for name, val, vec, units, trtype in [ + ("translation_x", tx, ex, "m", "translation"), + ("translation_y", ty, ey, "m", "translation"), + ("translation_z", tz, ez, "m", "translation"), + ("distance", distance, ez, "m", "translation"), + ("rotation_x", rotx, ex, "deg", "rotation"), + ("rotation_y", roty, ey, "deg", "rotation"), + ("rotation_z", rotz, ez, "deg", "rotation"), + # TODO: check order of rotations here!!! + ("two_theta_zero", tth0, ex, "deg", "rotation"), + ]: + f = NXfield(val, units=units) + f.attrs["transformation_type"] = trtype + f.attrs["vector"] = vec + # each transformation depends on the previous one in the chain + f.attrs["depends_on"] = depends + trans[name] = f + depends = f"./transformations/{name}" + + det["transformations"] = trans + # detector depends on the first transformation in the chain + det["depends_on"] = "./transformations/translation_x" + + # Add a calibrated flag as extra metadata + det["transformations"].attrs["calibrated"] = bool(is_calibrated) + + # Optional calibration provenance + if is_calibrated and shift is not None: + try: + caldict = shift.convert_to_dict() + except Exception: + caldict = { + "center_shift_x": tx, + "center_shift_y": ty, + "center_shift_z": tz, + "rotation_x": rotx, + "rotation_y": roty, + "rotation_z": rotz, + } + note = NXnote() + note["type"] = NXfield("text/plain") + # Note: calibration_file may not be available on all shift objects + try: + note["file_name"] = shift.calibration_file + except AttributeError: + note["file_name"] = "" + note["data"] = NXfield(json.dumps(caldict, indent=2)) + else: + note = None + + inst[GROUP_NAME.SOURCE] = src + inst[GROUP_NAME.BEAM] = beam + inst[GROUP_NAME.MONOCHROMATOR] = mono + inst[GROUP_NAME.DETECTOR] = det + if note is not None: + inst["detector_calibration"] = note + + # Add an optional 'masks' subgroup, to contain any detector or solid-angle masks. + # For the moment, we only write detector masks -- the `HidraWorkspace` doesn't + # yet seem to provide a way to distinguish between a detector and a solid-angle mask. + inst[GROUP_NAME.MASKS] = _Masks.init_group(ws) + + return inst + + @classmethod + @validate_call_ + def instrumentFromNexus(cls, instrument): + """Read instrument geometry, detector shift, and wavelength from NXinstrument group. + + Parameters + ---------- + instrument : NXinstrument + The NXinstrument group from the HDF5 file + + Returns + ------- + tuple + (DENEXDetectorGeometry, DENEXDetectorShift | None, wavelength: np.ndarray) + """ + + # Read detector geometry from detector/detector_bank + detector = instrument[GROUP_NAME.DETECTOR] + detector_bank = detector["detector_bank"] + + # data_size: (nrows, ncols) + data_size = detector_bank["data_size"].nxdata + nrows, ncols = int(data_size[0]), int(data_size[1]) + + # Pixel sizes in meters + px_m = float(detector_bank["fast_pixel_direction"].nxdata) + py_m = float(detector_bank["slow_pixel_direction"].nxdata) + + # Read transformations + trans = detector["transformations"] + calibrated = bool(trans.attrs.get("calibrated", False)) + + # Read distance (arm_length) + distance = float(trans["distance"].nxdata) if "distance" in trans else 0.0 + arm_length = distance + + # Create geometry object + geometry = DENEXDetectorGeometry(nrows, ncols, px_m, py_m, arm_length, calibrated) + + # If calibrated, read shift parameters + shift = None + if calibrated: + tx = float(trans["translation_x"].nxdata) if "translation_x" in trans else 0.0 + ty = float(trans["translation_y"].nxdata) if "translation_y" in trans else 0.0 + tz = float(trans["translation_z"].nxdata) if "translation_z" in trans else 0.0 + rotx = float(trans["rotation_x"].nxdata) if "rotation_x" in trans else 0.0 + roty = float(trans["rotation_y"].nxdata) if "rotation_y" in trans else 0.0 + rotz = float(trans["rotation_z"].nxdata) if "rotation_z" in trans else 0.0 + tth0 = float(trans["two_theta_zero"].nxdata) if "two_theta_zero" in trans else 0.0 + + shift = DENEXDetectorShift(tx, ty, tz, rotx, roty, rotz, tth0) + + # Read wavelength from monochromator: + # we don't have access to the scan-point indices at this level, + # so we just return an `np.ndarray` in scan-point order. + wavelength = None + if GROUP_NAME.MONOCHROMATOR in instrument: + mono = instrument[GROUP_NAME.MONOCHROMATOR] + if "wavelength" in mono: + wavelength = mono["wavelength"].nxdata + + return geometry, shift, wavelength + + +class _Masks: + # `INSTRUMENT/masks` (NXcollection) is allowed by the `NXstress` schema, + # but is not specified by the schema. + + # + # * Masks are stored by name. + # + # * Mask names must be distinct over both and : + # this allows us to successfully use the mask name as a suffix tag on other groups, + # without requiring the same sub-categorization for those groups. + # + # * Throughout the PyRS codebase `None` is used to indicate that the default mask is + # being used. For the purposes of the NXstress-compliant output, `None` will be + # mapped to `_definitions.DEFAULT_TAG`. For this key *only*, the mask-name suffix + # is _omitted_ from gener + # + + @classmethod + @validate_call_ + def _init(cls) -> NXcollection: + # initialize the `masks` (NXcollection) group + masks = NXcollection() + masks["names"] = NXfield( + np.empty((0,), dtype=FIELD_DTYPE.STRING.value), maxshape=(None,), chunks=CHUNK_SHAPE(1) + ) + masks["detector"] = NXcollection() + masks["solid_angle"] = NXcollection() + + return masks + + @classmethod + @validate_call_ + def init_group(cls, ws: HidraWorkspace, *, masks: NXcollection = None): + # Write or append masks to the `NXcollection` + + # Allow append: both 'detector' and 'solid_angle' masks may exist, + # and if so, the masks will need to be added in separate steps. + masks = masks if masks is not None else cls._init() + names = masks["names"].nxvalue + + appending = len(names) > 0 + detector_masks = masks["detector"] + solid_angle_masks = masks["solid_angle"] + + # Unify the `_mask_dict` to a standard Python `dict`. + _mask_dict = ws._mask_dict.copy() + if not appending: + # There is only *one* default detector-mask, and for output purposes, + # the default mask *must* be initialized. + default_mask = ws.get_detector_mask(True) + if default_mask is None: + _logger.warning( + "NXstress._instrument: no default " + " detector-mask is defined;\n" + " for output purposes, a default mask will be created." + ) + _mask_dict[DEFAULT_TAG] = ( + default_mask if default_mask is not None else cls._generate_default_mask(ws, detector_mask=True) + ) + + # Write the default-mask *once* to the masks group: + # this must happen first, as we may re-use it below as an `NXlink`. + detector_masks[DEFAULT_TAG] = NXfield(_mask_dict[DEFAULT_TAG], units="") + names.append(DEFAULT_TAG) + + # Check key correspondance in order to generate warning messages: + # here we do NOT replace the `None` key with `_definitions.DEFAULT_TAG`! + ws_data_keys = set(ws._diff_data_set.keys()) + ws_mask_keys = set(ws._mask_dict.keys()) + + for mask in cls.mask_keys(ws): + if mask == DEFAULT_TAG: + # WARNING: the default-mask should have been written before this point. + continue + + if mask in names: + raise RuntimeError( + f'Usage error: mask "{mask}" has already been written;\n' + + " names must be distinct over both detector and solid-angle masks." + ) + if mask in ws_data_keys and mask not in ws_mask_keys: + _logger.warning( + f"NXstress._instrument: no mask entry exists corresponding to diffraction data '{mask}';\n" + " for output purposes, the *default* mask will be written for this mask." + ) + + # WARNING: this section assumes that `detector_masks[DEFAULT_TAG]` already exists: + # it should have been written above. + mask_array = _mask_dict.get(mask) + units = "degrees" if (mask_array and cls._is_solid_angle_mask(mask_array)) else "" + + # If no specific mask is present corresponding to a reduced diffraction dataset, + # a link will be created to the default detector-mask. + if mask_array: + ds = NXfield(mask_array, units=units) + else: + # WORKAROUND to create an `NXlink` within an *unattached* group: + # this bypasses `NXlink.__init__` attempt to dereferene the parent group. + ds = NXlink(target=DEFAULT_TAG, name=f"link_to_{DEFAULT_TAG}") + ds._group = detector_masks + + if cls._is_solid_angle_mask(ds.nxdata): + solid_angle_masks[mask] = ds + else: + detector_masks[mask] = ds + + # append the mask's name to the `names` list + names.append(mask) + + masks["names"].resize((len(names),)) + masks["names"] = names + + return masks + + @classmethod + def mask_keys(cls, ws: HidraWorkspace): + # The complete set of mask names to be used for the `NXstress`-format file: + # + # * The default mask is a detector-mask and will use `_definitions.DEFAULT_TAG` as a key; + # for output purposes, a default-mask will be generated, if not present. + # + # * mask entries may be either detector or solid-angle masks, but they must have distinct names; + # + # * There may be more mask entries than entries in `ws._diff_data_set`. + # For example, if the reduction process may not have been completed for all mask entries. + # + # * Each entry in `ws._diff_data_set` *must* have a corresponding mask. + # When a corresponding entry is not present in `ws._mask_dict`, + # this will be logged (as a warning), and then such an entry will be *linked* + # to the default mask, if not present. + # + # * Any entry in `ws._var_data_set` that does not have a corresponding + # entry in `ws._diff_data_set` will be logged (as a warning) and skipped. + # + # * At present, there's no special name for any default solid-angle mask. + # + + keys = set(ws._mask_dict.keys()).union(ws._diff_data_set.keys()) + keys.discard(None) + # a key for the default detector-mask must always be present + keys.add(DEFAULT_TAG) + return keys + + @classmethod + def _generate_default_mask(cls, ws: HidraWorkspace, *, detector_mask: bool) -> np.ndarray | list[float]: + # Generate an unmasked default mask. + if not detector_mask: + _logger.warning( + "NXstress._instrument: *generating* a default solid-angle mask as `[-180.0, 180.0]`;\n" + " if this is not correct for your usage, please contact the developers." + ) + return [-180.0, 180.0] + + if not ws._instrument_setup: + raise RuntimeError("`_Masks._generate_default_mask`: workspace must have an instrument") + return np.ones(ws._instrument_setup.detector_size, dtype=np.int64) + + @classmethod + def _is_solid_angle_mask(cls, mask: np.ndarray) -> bool: + # Check if a mask is a solid-angle mask + + # Solid-angle masks are comprised of pairs of + # azimuthal *inclusion* zones. + return len(mask.shape) == 1 and mask.shape[0] % 2 == 0 and np.issubdtype(mask.dtype, np.floating) + + @classmethod + @validate_call_ + def masksFromNexus(cls, masks): + """Read masks from NXcollection group. + + Parameters + ---------- + masks : NXcollection + The masks NXcollection group from the HDF5 file + + Returns + ------- + tuple + (default_mask_or_None, {mask_name: np.ndarray}) + """ + # Read mask names + mask_names = masks["names"].nxdata + if isinstance(mask_names, np.ndarray): + mask_names = [name.decode("utf-8") if isinstance(name, bytes) else name for name in mask_names] + else: + mask_names = [mask_names.decode("utf-8") if isinstance(mask_names, bytes) else mask_names] + + default_mask = None + mask_dict = {} + + # Check both detector and solid_angle collections + for collection_name in ["detector", "solid_angle"]: + if collection_name in masks: + collection = masks[collection_name] + for name in mask_names: + if name in collection: + mask_array = collection[name].nxdata + if name == DEFAULT_TAG: + default_mask = mask_array + else: + mask_dict[name] = mask_array + + return default_mask, mask_dict diff --git a/pyrs/utilities/NXstress/_peaks.py b/pyrs/utilities/NXstress/_peaks.py new file mode 100644 index 000000000..43b74b5cd --- /dev/null +++ b/pyrs/utilities/NXstress/_peaks.py @@ -0,0 +1,459 @@ +# ruff: noqa: E741 # use `l` for `l` in `(h, k, l)`! +""" +pyrs/utilities/NXstress/_peaks.py + +Private service class for NeXus NXstress-compatible I/O. +This class provides I/O for the `peaks` `NXreflections` subgroup: + this subgroup includes fitted peak data, as used in reduction. +""" + +import numpy as np +from nexusformat.nexus import NXreflections, NXfield +import re +from typing import NamedTuple + +from pyrs.peaks.peak_collection import PeakCollection +from pyrs.dataobjects.sample_logs import SampleLogs +from pyrs.utilities.pydantic_transition import validate_call_ + +from ._definitions import CHUNK_SHAPE, FIELD_DTYPE + + +""" +REQUIRED PARAMETERS FOR NXstress: +--------------------------------- + +├─ peaks (NXreflections, group) +│ ├─ h (dataset) +│ ├─ k (dataset) +│ ├─ l (dataset) +│ └─ phase_name (dataset) + +`PeakCollection` to `peaks` (NXreflections), `FIT` (NXprocess) mapping: +----------------------------------------------------------------------- + +1. `peaks` provides the `n_Peaks` index which identifies `FIT` entries, with the exception of the `diffractogram` which are indexed separately. + +- A flattened index is used `(, h, k, l, , )`: all may not be present, and to support legacy code specifying the is optional, + and it will default to the key '_DEFAULT_'; Note that was not retained as a `PeakCollection` field prior to this implementation, but it does seem to be required; + +- This flattened index allows appending (not yet implemented), however each index value must identify a *unique* entry (i.e. there can be no duplicates); + +- Each combination of `(, h, k, l, , ...)` corresponds to *one* `PeakCollection` instance; + +- For input and output purposes (to and from HDF5), the entire index set will be sorted lexographically prior to output. This makes the append operation more complicated, + but provides robustness against duplicates (or overwrites). + +2. `diffractogram` are stored as 'diffractogram_', and indexed by . Any single that does not have an entry will be filled in with `NaN`. + +""" + + +class _Peaks: + ######################################## + # ALL methods must be `classmethod`. ## + ######################################## + + class PeakIndex(NamedTuple): + # Corresponds to the `n_Peaks` index in the `NXstress` schema. + # Each `PeakCollection` instance provides + # `(, h, k, l, , ...)`, i.e. multiple scan_point; + # `scan_point` are distinct, but are not required to be contiguous, nor complete. + phase_name: str + h: int + k: int + l: int # noqa: E741 + mask: str + scan_point: int + + @classmethod + def sort_key(cls, peaks: PeakCollection) -> tuple[str, int, int, int, str]: + # Define an ordering for `PeakCollection` instances + phase_name, (h, k, l) = _Peaks._parse_peak_tag(peaks.peak_tag) + mask = peaks.mask + return (phase_name, h, k, l, mask) + + @classmethod + def _parse_peak_tag(cls, tag: str) -> tuple[str, tuple[int, int, int]]: + # Parse a peak-tag string into its and Miller indices (h, k, l). + match: re.Match[str] | None = max( + re.finditer(r"\d+", tag), + key=lambda m: len(m.group(0)), + default=None, + ) + if match is None or len(match.group(0)) % 3 != 0: + raise RuntimeError(f"Unable to parse peak tag '{tag}' into its and Miller indices (h, k, l).") + # Extract as the rest of the tag. + i, j = match.span() + phase = (tag[:i] + tag[j:]).strip() + if not bool(phase): + raise RuntimeError(f"Unable to parse from peak tag '{tag}'.") + + # Extract (h, k, l) + maybeHKL = match.group(0) + N_d = len(maybeHKL) // 3 + h, k, l_ = int(maybeHKL[0:N_d]), int(maybeHKL[N_d : 2 * N_d]), int(maybeHKL[2 * N_d : 3 * N_d]) + + return phase, (h, k, l_) + + @classmethod + def _init(cls, logs: SampleLogs) -> NXreflections: + # Initialize the 'PEAKS' group + peaks = NXreflections() + + peaks["scan_point"] = NXfield(np.empty((0,), dtype=np.int32), maxshape=(None,), chunks=CHUNK_SHAPE(1)) + + peaks["h"] = NXfield(np.empty((0,), dtype=np.int32), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="") + peaks["k"] = NXfield(np.empty((0,), dtype=np.int32), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="") + peaks["l"] = NXfield(np.empty((0,), dtype=np.int32), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="") + + peaks["phase_name"] = NXfield( + np.empty((0,), dtype=FIELD_DTYPE.STRING.value), maxshape=(None,), chunks=CHUNK_SHAPE(1) + ) + + peaks["mask"] = NXfield( + np.empty((0,), dtype=FIELD_DTYPE.STRING.value), maxshape=(None,), chunks=CHUNK_SHAPE(1) + ) + + ## Components of the normalized scattering vector Q in the sample reference frame + ## 'qx', 'qy', and 'qz' are *required* by NXstress, but it looks as if PyRS doesn't + ## use these -- initialize to `NaN`. + peaks["qx"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), fillvalue=np.nan + ) + peaks["qx"].attrs["units"] = "1" + peaks["qy"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), fillvalue=np.nan + ) + peaks["qy"].attrs["units"] = "1" + peaks["qz"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), fillvalue=np.nan + ) + peaks["qz"].attrs["units"] = "1" + ## + + peaks["center"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="angstrom" + ) + peaks["center_errors"] = NXfield( + np.empty((0,), dtype=np.float64), maxshape=(None,), chunks=CHUNK_SHAPE(1), units="angstrom" + ) + peaks["center_type"] = NXfield("d-spacing") + + # Sample position for each subrun -- initialize to `NaN`. + ss_units = { + ## work around: units may be an empty string + "sx": logs.units("sx") if bool(logs.units("sx")) else "mm", + "sy": logs.units("sy") if bool(logs.units("sy")) else "mm", + "sz": logs.units("sz") if bool(logs.units("sz")) else "mm", + } + peaks["sx"] = NXfield( + np.empty((0,), dtype=np.float64), + maxshape=(None,), + chunks=CHUNK_SHAPE(1), + fillvalue=np.nan, + units=ss_units["sx"], + ) + peaks["sy"] = NXfield( + np.empty((0,), dtype=np.float64), + maxshape=(None,), + chunks=CHUNK_SHAPE(1), + fillvalue=np.nan, + units=ss_units["sy"], + ) + peaks["sz"] = NXfield( + np.empty((0,), dtype=np.float64), + maxshape=(None,), + chunks=CHUNK_SHAPE(1), + fillvalue=np.nan, + units=ss_units["sz"], + ) + + return peaks + + @classmethod + def init_group(cls, peakss: list[PeakCollection], logs: SampleLogs) -> NXreflections: + # Initialize the PEAKS group: + # according to the NXstress schema, this group contains the canonical reduction data, + # in a form usable for stress / strain calculations. + + # TODO: these code sections are implemented in a form that allows new scan-point data to be appended + # However, at present, appending data is not yet supported. + peaks = cls._init(logs) + + for peak_collection in sorted(peakss, key=_Peaks.PeakIndex.sort_key): + cls._append_peak(peaks, peak_collection, logs) + + return peaks + + @classmethod + def _append_peak(cls, peaks: NXreflections, peak_collection: PeakCollection, logs: SampleLogs) -> NXreflections: + # Append a `PeakCollection` to an initialized PEAKS group. + scan_point = peak_collection.sub_runs.raw_copy() + N_scan = len(scan_point) + phase_name, (h, k, l_) = cls._parse_peak_tag(peak_collection.peak_tag) + mask = peak_collection.mask + + # Each dataset has scan point as its first index. + phase_name_arr = np.array((phase_name,) * N_scan) + h_arr = np.array((h,) * N_scan) + k_arr = np.array((k,) * N_scan) + l_arr = np.array((l_,) * N_scan) + mask_arr = np.array((mask,) * N_scan) + + d_reference, d_reference_error = peak_collection.get_d_reference() + d_reference_arr = np.array((d_reference,) * N_scan) + d_reference_error_arr = np.array((d_reference_error,) * N_scan) + + curr_len = peaks["h"].shape[0] + new_len = curr_len + N_scan + + peaks["scan_point"].resize((new_len,)) + + peaks["h"].resize((new_len,)) + peaks["k"].resize((new_len,)) + peaks["l"].resize((new_len,)) + peaks["phase_name"].resize((new_len,)) + peaks["mask"].resize((new_len,)) + + # For `PEAKS` (NXreflections) group: 'center' means `d_reference`. + peaks["center"].resize((new_len,)) + peaks["center_errors"].resize((new_len,)) + + peaks["sx"].resize((new_len,)) + peaks["sy"].resize((new_len,)) + peaks["sz"].resize((new_len,)) + + peaks["scan_point"][curr_len:] = scan_point + peaks["h"][curr_len:] = h_arr + peaks["k"][curr_len:] = k_arr + peaks["l"][curr_len:] = l_arr + peaks["phase_name"][curr_len:] = phase_name_arr + peaks["mask"][curr_len:] = mask_arr + + peaks["center"][curr_len:] = d_reference_arr.ravel() + peaks["center_errors"][curr_len:] = d_reference_error_arr.ravel() + + """ # This doesn't make sense! + peaks['sx'][curr_len:] = logs['sx'] + peaks['sy'][curr_len:] = logs['sy'] + peaks['sz'][curr_len:] = logs['sz'] + """ # TODO: fix this! + peaks["sx"][curr_len:] = np.full((N_scan,), np.nan) + peaks["sy"][curr_len:] = np.full((N_scan,), np.nan) + peaks["sz"][curr_len:] = np.full((N_scan,), np.nan) + + return peaks + + @classmethod + def peakCollectionRanges(cls, peaks) -> list[tuple[tuple[str, int, int, int, str], int, int]]: + """Identify contiguous blocks of PeakCollection data in NXreflections group. + + Each PeakCollection corresponds to a unique 5-tuple (phase_name, h, k, l, mask) + with multiple scan-points written as a contiguous block in increasing order. + + Parameters + ---------- + peaks : NXreflections + The peaks group from which to read the flattened index + + Returns + ------- + list[tuple[tuple[str, int, int, int, str], int, int]] + List of (key, start, end) where: + - key is (phase_name, h, k, l, mask) + - start is the first index (inclusive) + - end is the last index (exclusive) + + Raises + ------ + RuntimeError + If scan_point values are not strictly increasing within a block + RuntimeError + If interleaved blocks are detected for the same sub-index key + """ + # Read index arrays via .nxdata + phase_name = peaks["phase_name"].nxdata[:] + h = peaks["h"].nxdata[:] + k = peaks["k"].nxdata[:] + l_ = peaks["l"].nxdata[:] + mask = peaks["mask"].nxdata[:] + scan_point = peaks["scan_point"].nxdata[:] + + if len(phase_name) == 0: + return [] + + # Decode bytes to strings if necessary + if phase_name.dtype.kind == "S" or phase_name.dtype.kind == "O": + phase_name = np.array([p.decode("utf-8") if isinstance(p, bytes) else str(p) for p in phase_name]) + if mask.dtype.kind == "S" or mask.dtype.kind == "O": + mask = np.array([m.decode("utf-8") if isinstance(m, bytes) else str(m) for m in mask]) + + ranges = [] + seen_keys = set() + + # Track current block + current_key = (str(phase_name[0]), int(h[0]), int(k[0]), int(l_[0]), str(mask[0])) + start_idx = 0 + + for i in range(1, len(phase_name)): + key = (str(phase_name[i]), int(h[i]), int(k[i]), int(l_[i]), str(mask[i])) + + if key != current_key: + # Block boundary - validate and record current block + end_idx = i + + # Check for strictly increasing scan_point within block + block_scan_points = scan_point[start_idx:end_idx] + if not np.all(block_scan_points[1:] > block_scan_points[:-1]): + raise RuntimeError( + f"scan_point values are not strictly increasing within PeakCollection block " + f"at {current_key}, indices [{start_idx}, {end_idx})" + ) + + # Check for interleaved blocks + if current_key in seen_keys: + raise RuntimeError(f"Interleaved blocks detected for sub-index {current_key}") + + seen_keys.add(current_key) + ranges.append((current_key, start_idx, end_idx)) + + # Start new block + current_key = key + start_idx = i + + # Handle last block + end_idx = len(phase_name) + block_scan_points = scan_point[start_idx:end_idx] + if not np.all(block_scan_points[1:] > block_scan_points[:-1]): + raise RuntimeError( + f"scan_point values are not strictly increasing within PeakCollection block " + f"at {current_key}, indices [{start_idx}, {end_idx})" + ) + + if current_key in seen_keys: + raise RuntimeError(f"Interleaved blocks detected for sub-index {current_key}") + + seen_keys.add(current_key) + ranges.append((current_key, start_idx, end_idx)) + + return ranges + + @classmethod + def validateNoDuplicatePeaks(cls, peakss: list[PeakCollection]) -> None: + """Validate that no duplicate PeakCollections exist in the list. + + Each PeakCollection must have a unique 5-tuple key (phase_name, h, k, l, mask). + + Parameters + ---------- + peakss : list[PeakCollection] + List of PeakCollection instances to validate + + Raises + ------ + ValueError + If any duplicate keys are found + """ + seen_keys = {} + for peaks in peakss: + key = cls.PeakIndex.sort_key(peaks) + if key in seen_keys: + raise ValueError( + f"Duplicate PeakCollection detected in output list at {key} " + f"-- did you forget to initialize the `mask` key?" + ) + seen_keys[key] = peaks + + @classmethod + @validate_call_ + def peakCollectionsFromNexus(cls, peaks, fit) -> list[PeakCollection]: + """Read PeakCollections from NXreflections and NXprocess groups. + + Note: This implementation assumes positive Miller indices. Negative indices + are not supported by the current _parse_peak_tag implementation. + + Parameters + ---------- + peaks : NXreflections + The peaks (NXreflections) group containing d-spacing and Miller indices + fit : NXprocess + The FIT (NXprocess) group containing peak_parameters and background_parameters + + Returns + ------- + list[PeakCollection] + List of reconstructed PeakCollection instances + """ + from ._fit import _PeakParameters, _BackgroundParameters + from ._definitions import GROUP_NAME + + # Get the parameter groups + pp = fit[GROUP_NAME.PEAK_PARAMETERS] + bp = fit[GROUP_NAME.BACKGROUND_PARAMETERS] + + # Get peak profile and background function from titles + from pyrs.core.peak_profile_utility import PeakShape, BackgroundFunction + + peak_profile = PeakShape.getShape(pp["title"].nxdata) + background_function = BackgroundFunction.getFunction(bp["title"].nxdata) + + # Get ranges for each PeakCollection + ranges = cls.peakCollectionRanges(peaks) + + peak_collections = [] + for (phase_name, h, k, l_, mask), start, end in ranges: + # Extract scan points for this range + sub_runs_array = peaks["scan_point"].nxdata[start:end] + + # Get peak parameters + native_peak_values, native_peak_errors = _PeakParameters.peakParametersForRange(pp, start, end) + + # Get background parameters + bg_values, bg_errors = _BackgroundParameters.backgroundParametersForRange(bp, start, end) + + # Merge background into native peak arrays + # A0 and A1 are always present, A2 only for Quadratic background + native_peak_values["A0"] = bg_values["A0"] + native_peak_values["A1"] = bg_values["A1"] + native_peak_errors["A0"] = bg_errors["A0"] + native_peak_errors["A1"] = bg_errors["A1"] + # A2 only exists if background is Quadratic + if "A2" in native_peak_values.dtype.names: + native_peak_values["A2"] = bg_values["A2"] + native_peak_errors["A2"] = bg_errors["A2"] + + param_values = native_peak_values + param_errors = native_peak_errors + + # Reconstruct peak_tag with zero-padded Miller indices + # Use max absolute value to ensure all indices have the same digit count + max_val = max(abs(h), abs(k), abs(l_)) + N_d = len(str(max_val)) + peak_tag = f"{phase_name}{str(h).zfill(N_d)}{str(k).zfill(N_d)}{str(l_).zfill(N_d)}" + + # Extract d_reference and errors + d_reference = peaks["center"].nxdata[start] + d_reference_error = peaks["center_errors"].nxdata[start] + + # Construct PeakCollection with mask keyword + pc = PeakCollection( + peak_tag=peak_tag, + peak_profile=peak_profile, + background_type=background_function, + wavelength=np.nan, # Will be set by workspace if needed + projectfilename="", + runnumber=0, + d_reference=d_reference, + d_reference_error=d_reference_error, + mask=mask, + ) + + # Set peak fitting values + N = len(sub_runs_array) + fit_costs = np.full(N, np.nan) + pc.set_peak_fitting_values(sub_runs_array, param_values, param_errors, fit_costs) + + peak_collections.append(pc) + + return peak_collections diff --git a/pyrs/utilities/NXstress/_sample.py b/pyrs/utilities/NXstress/_sample.py new file mode 100644 index 000000000..91d09a7ce --- /dev/null +++ b/pyrs/utilities/NXstress/_sample.py @@ -0,0 +1,211 @@ +""" +pyrs/utilities/NXstress/_sample.py + +Private service class for NeXus NXstress-compatible I/O. +This class provides I/O for the `sample` `NXsample` subgroup. +""" + +import numpy as np +from nexusformat.nexus import NXcollection, NXsample, NXfield + +from pyrs.dataobjects.constants import HidraConstants +from pyrs.dataobjects.sample_logs import SampleLogs, SubRuns +from pyrs.utilities.pydantic_transition import validate_call_ + +from ._definitions import allowed_identifier, CHUNK_SHAPE, FIELD_DTYPE + + +""" +REQUIRED PARAMETERS FOR NXstress: +--------------------------------- + +├─ sample (NXsample, group) +│ ├─ name (dataset) +│ ├─ chemical_formula (optional) (dataset) +│ ├─ temperature (optional) (dataset) +│ ├─ stress_field (optional) (dataset) +│ └─ gauge_volume (optional) (NXparameters, group) +""" + + +class _Sample: + ######################################## + # ALL methods must be `classmethod`. ## + ######################################## + + # Log keys included in the NXstress schema. + NXstress_logs = { + HidraConstants.SAMPLE_NAME, + *HidraConstants.SAMPLE_COORDINATE_NAMES, + HidraConstants.CHEMICAL_FORMULA, + HidraConstants.TEMPERATURE, + HidraConstants.STRESS_FIELD, + HidraConstants.STRESS_FIELD_DIRECTION, + } + + @classmethod + def init_group(cls, sampleLogs: SampleLogs) -> NXsample: + """ + Create SAMPLE_DESCRIPTION (NXsample) group following NXstress schema: + - subrun[nP]: link to the scanpoint axis + - vx[nP], vy[nP], vz[nP]: sample positions in mm (from SampleLogs, converted via PointList) + - name: sample descriptive name if present in logs; otherwise 'unknown' + - chemical_formula: sample formula if present in logs; otherwise 'unknown' + - [optional fields, only if present in the logs]: 'temperature', 'stress_field' + """ + # Create SAMPLE_DESCRIPTION as an NXsample + sd = NXsample() + + # Name of sample (required): try the expected log key; fall back to 'unknown'. + sd["name"] = NXfield(sampleLogs.get(HidraConstants.SAMPLE_NAME, ("unknown",))[0]) + + # Link scanpoints to subruns: subrun[nP] (unitless) + # SampleLogs.subruns is a SubRuns object; use .raw_copy() to get a NumPy array + scan_points = sampleLogs.subruns.raw_copy() + sd["scan_point"] = NXfield( + scan_points.astype(FIELD_DTYPE.INT_DATA.value), chunks=CHUNK_SHAPE(1), maxshape=(None,), units="" + ) + N_scan = len(scan_points) + + # 3) Sample positions per scanpoint (mm). Use SampleLogs.get_pointlist(). + # PointList returns vx, vy, vz arrays in millimeters. + try: + pl = sampleLogs.get_pointlist() + vv = (pl.vx, pl.vy, pl.vz) + except AssertionError as e: + if "some coordinates do not have finite values" in str(e): + vv = (np.full((N_scan,), np.nan),) * 3 + else: + raise + for axis_name, axis_values in zip(HidraConstants.SAMPLE_COORDINATE_NAMES, vv): + vs = np.asarray(axis_values, dtype=FIELD_DTYPE.FLOAT_DATA.value) + if vs.shape[0] != N_scan: + raise RuntimeError( + f"NXstress required log '{axis_name}' has unexpected shape.\n" + f" First axis should be (== {N_scan}), not {vs.shape[0]}" + ) + f = NXfield(vs, name=axis_name, units="mm") + sd[axis_name] = f + + # Optionally, add other NXstress SAMPLE_DESCRIPTION fields if available in logs: + # - `HidraConstants.CHEMICAL_FORMULA` (NXCHAR) + # - `HidraConstants.TEMPERATURE`[nTemp] (NXTEMPERATURE) + # - `HidraConstants.STRESS_FIELD`[nsField] (with `@direction` attr = 'x'|'y'|'z') + # The lines below are safe no-ops if the corresponding logs are not present. + sd["chemical_formula"] = NXfield(sampleLogs.get(HidraConstants.CHEMICAL_FORMULA, ("unknown",))[0]) + + # Example of temperature if present (stored as numeric array and units carried separately) + if HidraConstants.TEMPERATURE in sampleLogs: + tkey = HidraConstants.TEMPERATURE + tvals = np.asarray(sampleLogs[tkey], dtype=FIELD_DTYPE.FLOAT_DATA.value) + tf = NXfield(tvals, name="temperature") + tf.attrs["units"] = sampleLogs.units(tkey) or "K" + sd["temperature"] = tf + + # Example of stress_field if present (values + direction attribute) + if HidraConstants.STRESS_FIELD in sampleLogs: + # TODO: we don't have an example of these entries, so the dimensions may not be correct! + # -- Assuming: + # :: (, ...) + # :: {'x', 'y', 'z'}: scalar + # + sf = np.asarray(sampleLogs[HidraConstants.STRESS_FIELD], dtype=FIELD_DTYPE.FLOAT_DATA.value) + if sf.shape[0] != N_scan: + raise RuntimeError( + f"NXstress required log '{HidraConstants.STRESS_FIELD}' has unexpected shape.\n" + f" First axis should be (== {N_scan}), not {sf.shape[0]}" + ) + sff = NXfield(sf, name="stress_field") + # If a direction log exists, attach it; otherwise default to 'x' + direction_key = HidraConstants.STRESS_FIELD_DIRECTION + direction = sampleLogs[direction_key] if direction_key in sampleLogs else "x" + sff.attrs["direction"] = direction + sd["stress_field"] = sff + + # Retain any additional logs that happen to be present. + sd["logs"] = NXcollection() + for key in sampleLogs: + # convert ':' to '_': + name = allowed_identifier(key) + if key not in cls.NXstress_logs: + sd["logs"][name] = NXfield( + sampleLogs[key], + # source PV-log name as attribute + local_name=key, + # 'units' as attribute + units=sampleLogs.units(key), + ) + + return sd + + @classmethod + @validate_call_ + def sampleLogsFromNexus(cls, sample) -> SampleLogs: + """Read SampleLogs from an NXsample group. + + Parameters + ---------- + sample : NXsample + The NXsample group from the HDF5 file + + Returns + ------- + SampleLogs + Populated SampleLogs object + """ + + # Read scan_point array + scan_point = sample["scan_point"].nxdata + + # Initialize SampleLogs and set subruns + logs = SampleLogs() + logs.subruns = SubRuns(scan_point) + + # Read vx, vy, vz coordinates (stored at top level of NXsample) + for coord_name in HidraConstants.SAMPLE_COORDINATE_NAMES: + if coord_name in sample: + coord_field = sample[coord_name] + values = coord_field.nxdata + units = coord_field.attrs.get("units", "mm") + logs[coord_name, units] = values + + # Read extra logs from the 'logs' NXcollection (if present) + if "logs" in sample: + logs_collection = sample["logs"] + for field_name in logs_collection: + field = logs_collection[field_name] + # Get the original PV-log key from local_name attribute + original_key = field.attrs.get("local_name", field_name) + units = field.attrs.get("units", "") + values = field.nxdata + logs[original_key, units] = values + + # Read optional scalar fields + if "name" in sample: + sample_name = sample["name"].nxdata + if isinstance(sample_name, (bytes, np.bytes_)): + sample_name = sample_name.decode("utf-8") + logs[HidraConstants.SAMPLE_NAME, ""] = np.array([sample_name] * len(scan_point)) + + if "chemical_formula" in sample: + chem_formula = sample["chemical_formula"].nxdata + if isinstance(chem_formula, (bytes, np.bytes_)): + chem_formula = chem_formula.decode("utf-8") + logs[HidraConstants.CHEMICAL_FORMULA, ""] = np.array([chem_formula] * len(scan_point)) + + if "temperature" in sample: + temp_field = sample["temperature"] + temp_values = temp_field.nxdata + temp_units = temp_field.attrs.get("units", "K") + logs[HidraConstants.TEMPERATURE, temp_units] = temp_values + + if "stress_field" in sample: + stress_field = sample["stress_field"] + stress_values = stress_field.nxdata + logs[HidraConstants.STRESS_FIELD, ""] = stress_values + # Read direction attribute if present + if "direction" in stress_field.attrs: + direction = stress_field.attrs["direction"] + logs[HidraConstants.STRESS_FIELD_DIRECTION, ""] = np.array([direction] * len(scan_point)) + + return logs diff --git a/pyrs/utilities/pydantic_transition.py b/pyrs/utilities/pydantic_transition.py new file mode 100644 index 000000000..9290b7210 --- /dev/null +++ b/pyrs/utilities/pydantic_transition.py @@ -0,0 +1,8 @@ +from pydantic import ConfigDict, validate_call + + +# Use Pydantic's `validate_call` decorator with arbitrary types: +# name with trailing-underscore to avoid conflicts if `pydantic.validate_call` +# is itself ever used anywhere in the code-base. +def validate_call_(func): + return validate_call(config=ConfigDict(arbitrary_types_allowed=True))(func) diff --git a/tests/conftest.py b/tests/conftest.py index bb332bcfe..0ba239f8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import numpy as np import os import pytest -import sys from pyrs.dataobjects.fields import StrainField, StrainFieldSingle, StressField from pyrs.dataobjects.sample_logs import _coerce_to_ndarray, PointList @@ -16,10 +15,11 @@ @pytest.fixture(scope="session") -def test_data_dir(): - this_module_path = sys.modules[__name__].__file__ - this_module_directory = os.path.dirname(this_module_path) - return os.path.join(this_module_directory, "data") +def test_data_dir(request): + # WARNING, there may be multiple `conftest.py`, + # and pytest often screws with the import sequence: + # do _not_ use `sys.modules[__name__].__file__` here! + return str(request.config.rootpath / "tests" / "data") @pytest.fixture(scope="session") @@ -142,7 +142,7 @@ def wrapped_function(peaks_data): ) # Back-calculate the peak centers from supplied lattice spacings - centers = 2 * np.rad2deg(np.arcsin(peaks_data["wavelength"] / (2 * peaks_data["d_spacing"]))) + centers = 2.0 * np.rad2deg(np.arcsin(peaks_data["wavelength"] / (2.0 * peaks_data["d_spacing"]))) # Enter the native parameters in the peak collection subruns_count = len(peaks_data["subruns"]) diff --git a/tests/data/HB2B_1017_w_mask.h5 b/tests/data/HB2B_1017_w_mask.h5 new file mode 100644 index 000000000..9a3600c5f Binary files /dev/null and b/tests/data/HB2B_1017_w_mask.h5 differ diff --git a/tests/scripts/cis_tests/NXstress_demo_script.py b/tests/scripts/cis_tests/NXstress_demo_script.py new file mode 100644 index 000000000..f4c9d5ea2 --- /dev/null +++ b/tests/scripts/cis_tests/NXstress_demo_script.py @@ -0,0 +1,145 @@ +""" +tests/scripts/cis_tests/NXstress_demo_script.py + +Smoke-test / "by hand" demo script for the NXstress I/O implementation. + +Features demonstrated +--------------------- +1. Loading a ``HidraWorkspace`` from an existing HiDRA project file + (``tests/data/3393_PWHT-TD.h5``). + +2. Fitting two diffraction peaks with + ``tests.util.peak_collection_helpers.generate_PeakCollection_from_workspace`` + to produce a ``list[PeakCollection]``. + + The ``fit_dic`` below mirrors the starting point given in the docstring of + that helper, but with ``peak_label`` values adjusted to follow the + ``peak_tag`` convention required by ``NXstress``: + + " " e.g. "Fe 311" + + where ```` is a string of 3 N digits that encodes the Miller indices + (h, k, l) as N-digit zero-padded integers. The two peaks present in the + data file are the austenitic-iron reflections "Fe 311" and "Fe 222", as + confirmed by the ``hklPhase`` log stored in the file. + +3. Writing the workspace and fitted peak collections to a new + NXstress-compatible NeXus file via ``NXstress`` used as a context manager. + +4. Reading the data back from the NXstress file and printing a short summary + to confirm that the round-trip succeeded. + +Usage +----- +Run this script directly (not via pytest):: + + python tests/scripts/cis_tests/NXstress_demo_script.py + +The output NXstress file is written to the current working directory as +``NXstress_demo_output.nxs``. +""" + +from pathlib import Path + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.projectfile.file_object import HidraProjectFile, HidraProjectFileMode +from pyrs.utilities.NXstress import NXstress + +from tests.util.peak_collection_helpers import generate_PeakCollection_from_workspace + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +# Repository root is two levels above the directory of this script: +# tests/scripts/cis_tests/ -> tests/scripts/ -> tests/ -> +_REPO_ROOT = Path(__file__).resolve().parents[3] + +DATA_FILE = _REPO_ROOT / "tests" / "data" / "3393_PWHT-TD.h5" +OUTPUT_FILE = Path("NXstress_demo_output.nxs") + +# --------------------------------------------------------------------------- +# Peak-fit configuration +# --------------------------------------------------------------------------- +# ``peak_label`` values MUST follow the ``peak_tag`` convention so that +# ``_Peaks._parse_peak_tag`` can extract a phase name and Miller indices. +# The data file records "Fe 311, Fe 222" in its ``hklPhase`` log. +# +# fit_dic format: +# key – arbitrary string used as an ordered loop index +# value – dict with: +# "peak_range" : [x_min, x_max] (2θ in degrees) +# "peak_label" : peak_tag string (" ") +# "d0" : reference d-spacing in Å (for strain calculation) +FIT_DIC = { + "0": {"peak_range": [87.599, 91.569], "peak_label": "Fe 311", "d0": 1.08}, + "1": {"peak_range": [93.544, 95.890], "peak_label": "Fe 222", "d0": 1.03}, +} + +# --------------------------------------------------------------------------- +# Step 1 – Load the HidraWorkspace +# --------------------------------------------------------------------------- +print("=" * 60) +print("Step 1: Loading HidraWorkspace") +print(f" file: {DATA_FILE}") + +ws = HidraWorkspace("3393_PWHT-TD") +with HidraProjectFile(DATA_FILE, mode=HidraProjectFileMode.READONLY) as project_file: + ws.load_hidra_project(project_file, load_raw_counts=True, load_reduced_diffraction=True) + +print(f" sub-runs loaded : {len(ws.get_sub_runs())}") +print(f" wavelength : {ws.get_wavelength(calibrated=True, throw_if_not_set=False)} Å") + +# --------------------------------------------------------------------------- +# Step 2 – Fit peaks and build list[PeakCollection] +# --------------------------------------------------------------------------- +print() +print("=" * 60) +print("Step 2: Fitting peaks with generate_PeakCollection_from_workspace") + +peak_collections = generate_PeakCollection_from_workspace(ws, FIT_DIC) + +print(f" PeakCollections fitted: {len(peak_collections)}") +for pc in peak_collections: + print(f" peak_tag : {pc.peak_tag!r}") + print(f" peak_profile : {pc.peak_profile}") + print(f" background : {pc.background_type}") + +# --------------------------------------------------------------------------- +# Step 3 – Write to NXstress file +# --------------------------------------------------------------------------- +print() +print("=" * 60) +print(f"Step 3: Writing NXstress file -> {OUTPUT_FILE}") + +with NXstress(OUTPUT_FILE, mode="w") as nxs: + nxs.write(ws, peak_collections) + +print(f" Written: {OUTPUT_FILE.resolve()}") + +# --------------------------------------------------------------------------- +# Step 4 – Read back and verify round-trip +# --------------------------------------------------------------------------- +print() +print("=" * 60) +print("Step 4: Reading back from NXstress file") + +with NXstress(OUTPUT_FILE, mode="r") as nxs: + ws_back, peaks_back = nxs.read(entry_number=1) + +print(f" sub-runs read back : {len(ws_back.get_sub_runs())}") +print(f" PeakCollections read : {len(peaks_back)}") +for pc in peaks_back: + print(f" peak_tag (read back): {pc.peak_tag!r}") + +# Quick consistency check +assert len(ws_back.get_sub_runs()) == len(ws.get_sub_runs()), ( + "Round-trip sub-run count mismatch!" +) +assert len(peaks_back) == len(peak_collections), ( + "Round-trip PeakCollection count mismatch!" +) + +print() +print("=" * 60) +print("Demo completed successfully.") diff --git a/tests/scripts/cis_tests/README.rst b/tests/scripts/cis_tests/README.rst new file mode 100644 index 000000000..3e8d059e0 --- /dev/null +++ b/tests/scripts/cis_tests/README.rst @@ -0,0 +1,3 @@ +This directory contains scripts used for by hand smoke-tests to verify the implementation of PyRS features. + +All scripts in this directory will be automatically ignored by the pytest collection system. diff --git a/tests/unit/pyrs/utilities/NXstress/conftest.py b/tests/unit/pyrs/utilities/NXstress/conftest.py new file mode 100644 index 000000000..9d6b8027a --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/conftest.py @@ -0,0 +1,28 @@ +from collections.abc import Callable, Generator +from pathlib import Path + + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.projectfile.file_object import HidraProjectFile, HidraProjectFileMode + +import pytest +from tests.util.peak_collection_helpers import createPeakCollection + + +@pytest.fixture +def load_HidraWorkspace(test_data_dir) -> Generator[Callable[..., HidraWorkspace]]: + # This fixture loads a `HidraWorkspace` instance from a `HidraProject`-format file. + + def _init(*, file_name: str, name: str, load_raw_counts=True, load_reduced_diffraction=True) -> HidraWorkspace: + file_path = Path(test_data_dir) / file_name + ws = HidraWorkspace(name) + with HidraProjectFile(file_path, mode=HidraProjectFileMode.READONLY) as project_file: + ws.load_hidra_project( + project_file, load_raw_counts=load_raw_counts, load_reduced_diffraction=load_reduced_diffraction + ) + return ws + + yield _init + + # teardown follows + pass diff --git a/tests/unit/pyrs/utilities/NXstress/test_NXstress.py b/tests/unit/pyrs/utilities/NXstress/test_NXstress.py new file mode 100644 index 000000000..39bb5e0d9 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_NXstress.py @@ -0,0 +1,759 @@ +# ruff: noqa: E741 # use `l` for `l` in `(h, k, l)`! +from collections.abc import Callable +from nexusformat.nexus import ( + NXbeam, + NXcollection, + NXdata, + NXdetector, + NXentry, + NXinstrument, + NXmonochromator, + NXnote, + NXparameters, + NXprocess, + NXreflections, + NXsample, + NXsource, +) +from pathlib import Path + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.peaks.peak_collection import PeakCollection +from pyrs.utilities.NXstress._definitions import FIELD_DTYPE, GROUP_NAME +from pyrs.utilities.NXstress._fit import _Diffractogram, _Fit, _PeakParameters, _BackgroundParameters +from pyrs.utilities.NXstress._input_data import _InputData +from pyrs.utilities.NXstress._instrument import _Instrument, _Masks +from pyrs.utilities.NXstress._peaks import _Peaks +from pyrs.utilities.NXstress._sample import _Sample +from pyrs.utilities.NXstress.NXstress import NXstress + +import pytest + + +class TestNXstress: + # instrument, input data, reduced data, no mask + PROJECT_FILE_A = "HB2B_1017.h5" + + # instrument, mask, reduced data, but no input data + PROJECT_FILE_B = "HB2B_1628.h5" + + # instrument, mask (from '1628'), input data, reduced data + PROJECT_FILE_C = "HB2B_1017_w_mask.h5" + + @pytest.fixture(autouse=True) + def setUp(self, load_HidraWorkspace, createPeakCollection): + """ + self.sampleLogs = self.ws._sample_logs + self.subruns = self.ws._sample_logs.subruns.raw_copy() + + N_subrun = len(self.subruns) + self.peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun + ) + self.peak1 = createPeakCollection( + peak_tag="111 Si", + peak_profile="PseudoVoigt", + background_type="Linear", + wavelength=10.1, + projectfilename="/does/not/exist2.h5", + runnumber=12346, + N_subrun=N_subrun + ) + """ + # Unfortunately, no available project file actually includes + # all of the necessary data to initialize the NXstress file. + # The tests below, use different files to verify different sections. + yield + + # teardown follows ... + pass + + def test_NXstress_context_manager( + self, + tmp_path: Path, + load_HidraWorkspace: Callable[..., HidraWorkspace], + createPeakCollection: Callable[..., PeakCollection], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + file_path = tmp_path / "test_NXstress_context_manager.nxs" + assert not file_path.exists() + + with NXstress(file_path, "w") as nx: + nx.write(ws, [peak0]) + assert nx._root is not None + assert file_path.exists() + + def test_NXentry_fields( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + # Verify that all required datasets, and attributes are present + # on the `NXentry` + + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + + required_datasets = ("definition", "start_time", "end_time", "processing_type") + + entry = NXstress._init(ws) + assert isinstance(entry, NXentry) + for key in required_datasets: + assert key in entry + + def test_NXentry_subgroups( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + # Verify that all required subgroups are present + # on the `NXentry` + + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + required_groups = ( + (GROUP_NAME.SAMPLE_DESCRIPTION, NXsample), + (GROUP_NAME.FIT, NXprocess), + (GROUP_NAME.PEAKS, NXreflections), + ) + + entry = NXstress.init_group(ws, [peak0]) + assert isinstance(entry, NXentry) + for key, NXclass_ in required_groups: + assert key in entry + assert isinstance(entry[key], NXclass_) + + def test_NXentry_input_data( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + # Verify that an optional `input_data` `NXdata` group will be created on the `NXentry` + # when detector-counts data is attached to the source workspace. + + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + entry = NXstress.init_group(ws, [peak0]) + assert isinstance(entry, NXentry) + key, NXclass_ = GROUP_NAME.INPUT_DATA, NXdata + assert key in entry + assert isinstance(entry[key], NXclass_) + + def test_NXentry_input_data_optional( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + # When no input data is attached to the source workspace: + # verify that an empty (i.e. no scan-points) `input_data` `NXdata` group is created on the `NXentry`. + + # Notes: + # -- A successful instrument load is required; this is keyed to detector-counts data load. + # So we need to fudge the workspace after the load in order to _remove_ the attached input data. + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + # remove the input data: + ws._raw_counts = dict() + + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + entry = NXstress.init_group(ws, [peak0]) + assert isinstance(entry, NXentry) + key, NXclass_ = GROUP_NAME.INPUT_DATA, NXdata + assert key in entry + assert isinstance(entry[key], NXclass_) + assert len(entry[key]["scan_point"]) == 0 + + def test_NXentry_multiple( + self, + tmp_path: Path, + load_HidraWorkspace: Callable[..., HidraWorkspace], + createPeakCollection: Callable[..., PeakCollection], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + peak1 = createPeakCollection( + peak_tag="Si 111", + peak_profile="PseudoVoigt", + background_type="Quadratic", + wavelength=20.6, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + file_path = tmp_path / "test_NXstress_multiple_NXentry.nxs" + with NXstress(file_path, "w") as nx: + nx.write(ws, [peak0]) + nx.write(ws, [peak1]) + root = nx._root + + assert root is not None + assert len(root.NXentry) == 2 + assert "entry" in root + assert "entry_2" in root + + assert file_path.exists() + + # *** DEBUG *** : for validation: + # shutil.copy2(file_path, Path('${workspaces}/ORNL-work/PyRS/tmp/validation')) + + def test__Instrument_fields_and_subgroups( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + + required_fields = ("name",) + required_subgroups = ( + (GROUP_NAME.SOURCE, NXsource), + (GROUP_NAME.BEAM, NXbeam), + (GROUP_NAME.MONOCHROMATOR, NXmonochromator), + (GROUP_NAME.DETECTOR, NXdetector), + # Optional field for NXstress: + (GROUP_NAME.MASKS, NXcollection), + ) + + inst = _Instrument.init_group(ws) + assert isinstance(inst, NXinstrument) + for key in required_fields: + assert key in inst + for key, NXclass_ in required_subgroups: + assert key in inst + assert isinstance(inst[key], NXclass_) + + def test__Masks_fields_and_subgroups( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, + name="test_workspace", + # raw-counts load => instrument load + load_raw_counts=True, + load_reduced_diffraction=True, + ) + + required_fields = ("names",) + required_subgroups = (("detector", NXcollection), ("solid_angle", NXcollection)) + + masks = _Masks.init_group(ws) + assert isinstance(masks, NXcollection) + for key in required_fields: + assert key in masks + for key, NXclass_ in required_subgroups: + assert key in masks + assert isinstance(masks[key], NXclass_) + + def test__Sample_fields_and_subgroups( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + required_fields = ( + "name", + # not required by `NXstress`, but _possibly_ required by PyRS: + "vx", + "vy", + "vz", + ) + required_subgroups: list[tuple[str, type[object]]] = [] + + sample = _Sample.init_group(ws._sample_logs) + assert isinstance(sample, NXsample) + for key in required_fields: + assert key in sample + # Placeholder: no required subgroups yet: + for key, NXclass_ in required_subgroups: + assert key in sample + assert isinstance(sample[key], NXclass_) + + def test__Fit_fields_and_subgroups( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + required_fields = ("date", "program") + required_subgroups = ( + (GROUP_NAME.DESCRIPTION, NXnote), + (GROUP_NAME.INPUT, NXparameters), + (GROUP_NAME.PEAK_PARAMETERS, NXparameters), + (GROUP_NAME.BACKGROUND_PARAMETERS, NXparameters), + (GROUP_NAME.DIFFRACTOGRAM, NXdata), + ) + + fit = _Fit.init_group(ws, [peak0], sampleLogs) + assert isinstance(fit, NXprocess) + for key in required_fields: + assert key in fit + for key, NXclass_ in required_subgroups: + assert key in fit + assert isinstance(fit[key], NXclass_) + + def test_write_without_context_manager( + self, + tmp_path: Path, + load_HidraWorkspace: Callable[..., HidraWorkspace], + createPeakCollection: Callable[..., PeakCollection], + ): + """Verify RuntimeError when write() is called without context manager""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + file_path = tmp_path / "test_no_context.nxs" + nx = NXstress(file_path, "w") + + with pytest.raises(RuntimeError, match=r".*only usage as context manager is supported.*"): + nx.write(ws, [peak0]) + + def test_NXentry_init_fallback_timestamps( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify NXstress._init succeeds when timestamps are not valid ISO-8601""" + # Load workspace and deliberately corrupt the timestamps + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + # Corrupt the timestamps to trigger the fallback path + bad_timestamps = [b"not-valid-iso8601" for _ in ws._sample_logs.subruns] + ws._sample_logs["start_time"] = bad_timestamps + ws._sample_logs["end_time"] = bad_timestamps + + # Should not raise - fallback path handles this + entry = NXstress._init(ws) + assert isinstance(entry, NXentry) + assert "start_time" in entry + assert "end_time" in entry + + def test_validateWorkspaceAndPeaksData_valid( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify _validateWorkspaceAndPeaksData completes without error for valid data""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + # Should not raise + NXstress._validateWorkspaceAndPeaksData(ws, [peak0]) + + def test_NXentry_definition_value( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify entry['definition'] is 'NXstress' and processing_type is 'd-spacing'""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + entry = NXstress._init(ws) + assert entry["definition"] == "NXstress" + assert entry["processing_type"] == "d-spacing" + + def test__PeakParameters_fields_and_subgroups( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + # Load a workspace in order to get a realistic axis. + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="PseudoVoigt", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + peak0_params_value, peak0_params_error = peak0.get_effective_params() + + required_fields = ( + "title", + "center", + # Not all of these fields are required by `NXstress`, but + # as these are available in PyRS, they will be used here. + "center_errors", + "height", + "height_errors", + "fwhm", + "fwhm_errors", + "form_factor", + "form_factor_errors", + ) + + peak_parameters = _PeakParameters.init_group([peak0]) + assert isinstance(peak_parameters, NXparameters) + for key in required_fields: + assert key in peak_parameters + + # Verify mixing to form factor conversion. + assert pytest.approx(peak_parameters["form_factor"], 1.0e-6) == (1.0 - peak0_params_value["Mixing"]).astype( + FIELD_DTYPE.FLOAT_DATA.value + ) + + def test__BackgroundParameters_fields_and_subgroups( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + # Load a workspace in order to get a realistic axis. + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="PseudoVoigt", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + peak0_params_value, peak0_params_error = peak0.get_effective_params() + + required_fields = ( + "title", + # Not all of these fields are required by `NXstress`, but + # as PyRS uses polynomial background functions, they will be used here. + "A0", + "A0_errors", + "A1", + "A1_errors", + "A2", + "A2_errors", + ) + + background_parameters = _BackgroundParameters.init_group([peak0]) + assert isinstance(background_parameters, NXparameters) + for key in required_fields: + assert key in background_parameters + + def test__Diffractogram_fields_and_subgroups( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + required_attributes = ("signal", "auxiliary_signals", "axes") + required_fields = ( + "scan_point", + GROUP_NAME.DGRAM_TWO_THETA_NAME, + GROUP_NAME.DGRAM_DIFFRACTOGRAM, + GROUP_NAME.DGRAM_DIFFRACTOGRAM_ERRORS, + GROUP_NAME.DGRAM_FIT, + GROUP_NAME.DGRAM_FIT_ERRORS, + ) + required_subgroups: list[tuple[str, type[object]]] = [] + + dgram = _Diffractogram.init_group(ws, "_DEFAULT_", [peak0]) + assert isinstance(dgram, NXdata) + for key in required_attributes: + assert key in dgram.attrs + for key in required_fields: + assert key in dgram + # Placeholder: there are currently no required subgroups: + for key, NXclass_ in required_subgroups: + assert key in dgram + assert isinstance(dgram[key], NXclass_) + + def test__Peaks_fields_and_subgroups( + self, + tmp_path: Path, + load_HidraWorkspace: Callable[..., HidraWorkspace], + createPeakCollection: Callable[..., PeakCollection], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + sampleLogs = ws._sample_logs + subruns = sampleLogs.subruns.raw_copy() + + N_subrun = len(subruns) + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + required_fields = ( + "h", + "k", + "l", + "phase_name", + "qx", + "qy", + "qz", + "center", + "center_errors", + "center_type", + "sx", + "sy", + "sz", + ) + required_subgroups: list[tuple[str, type[object]]] = [] + + peaks = _Peaks.init_group([peak0], sampleLogs) + assert isinstance(peaks, NXreflections) + for key in required_fields: + assert key in peaks + # Placeholder: no required subgroups yet: + for key, NXclass_ in required_subgroups: + assert key in peaks + assert isinstance(peaks[key], NXclass_) + + def test__parse_peak_tag(self): + phase, (h, k, l) = _Peaks._parse_peak_tag("Al 452411") + assert phase == "Al" + assert (45, 24, 11) == (h, k, l) + + phase, (h, k, l) = _Peaks._parse_peak_tag("111 Al2O3") + assert phase == "Al2O3" + assert (1, 1, 1) == (h, k, l) + + phase, (h, k, l) = _Peaks._parse_peak_tag("010203 Silicon") + assert phase == "Silicon" + assert (1, 2, 3) == (h, k, l) + + # Behavior check: takes longest digits substring. + # This would not be a "real" peak tag! + phase, (h, k, l) = _Peaks._parse_peak_tag("321 010203 Silicon") + assert phase == "321 Silicon" + assert (1, 2, 3) == (h, k, l) + + with pytest.raises(RuntimeError, match=r".*Unable to parse peak tag.*"): + # substrings must have length divisible by 3 + _phase, (_h, _k, _l) = _Peaks._parse_peak_tag("0102 Silicon") + + with pytest.raises(RuntimeError, match=r".*Unable to parse peak tag.*"): + # must contain an (h, k, l) substring + _phase, (_h, _k, _l) = _Peaks._parse_peak_tag("Silicon") + + with pytest.raises(RuntimeError, match=r".*Unable to parse from peak tag.*"): + # must contain a phase substring + _phase, (_h, _k, _l) = _Peaks._parse_peak_tag("102030") + + def test__InputData_fields_and_subgroups( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + required_attributes = ("axes", "signal") + required_fields = ("scan_point", "detector_counts") + required_subgroups: list[tuple[str, type[object]]] = [] + + data = _InputData.init_group(ws) + assert isinstance(data, NXdata) + for key in required_attributes: + assert key in data.attrs + for key in required_fields: + assert key in data + # Placeholder: no required subgroups yet: + for key, NXclass_ in required_subgroups: + assert key in data + assert isinstance(data[key], NXclass_) + + def test__InputData_omitted( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + # When input-data is not attached to the source workspace, + # the structure of the input-data group should still be filled in. + ws = load_HidraWorkspace( + # PROJECT_B doesn't include any raw-counts data. + file_name=self.PROJECT_FILE_B, + name="test_workspace", + load_raw_counts=False, + load_reduced_diffraction=True, + ) + + required_attributes = ("axes", "signal") + required_fields = ("scan_point", "detector_counts") + required_subgroups: list[tuple[str, type[object]]] = [] + + data = _InputData.init_group(ws) + assert isinstance(data, NXdata) + for key in required_attributes: + assert key in data.attrs + for key in required_fields: + assert key in data + # Placeholder: no required subgroups yet: + for key, NXclass_ in required_subgroups: + assert key in data + assert isinstance(data[key], NXclass_) diff --git a/tests/unit/pyrs/utilities/NXstress/test_definitions.py b/tests/unit/pyrs/utilities/NXstress/test_definitions.py new file mode 100644 index 000000000..02cecc411 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_definitions.py @@ -0,0 +1,117 @@ +""" +Tests for pyrs/utilities/NXstress/_definitions.py +""" + +import numpy as np +import pytest + +from pyrs.utilities.NXstress._definitions import ( + CHUNK_SHAPE, + FIELD_DTYPE, + GROUP_NAME, + group_naming_scheme, + allowed_identifier, + is_ISO_8601, + DEFAULT_TAG, +) + + +class TestDefinitions: + """Test suite for _definitions.py utility functions and enums""" + + def test_CHUNK_SHAPE(self): + """Verify CHUNK_SHAPE returns correct tuples for ranks 1-3""" + assert CHUNK_SHAPE(1) == (100,) + assert CHUNK_SHAPE(2) == (1, 100) + assert CHUNK_SHAPE(3) == (1, 1, 100) + + def test_FIELD_DTYPE_call(self): + """Verify calling a FIELD_DTYPE enum member returns expected NumPy dtype""" + # Test that calling enum members constructs values of the expected type + float_val = FIELD_DTYPE.FLOAT_DATA(3.14) + assert isinstance(float_val, np.float32) + assert float_val == np.float32(3.14) + + int_val = FIELD_DTYPE.INT_DATA(42) + assert isinstance(int_val, np.int32) + assert int_val == np.int32(42) + + def test_FIELD_DTYPE_is_instance(self): + """Verify FIELD_DTYPE.is_instance correctly identifies instances""" + assert FIELD_DTYPE.FLOAT_DATA.is_instance(np.float32(1.0)) is True + assert FIELD_DTYPE.INT_DATA.is_instance(np.float32(1.0)) is False + + assert FIELD_DTYPE.INT_DATA.is_instance(np.int32(42)) is True + assert FIELD_DTYPE.FLOAT_DATA.is_instance(np.int32(42)) is False + + def test_FIELD_DTYPE_is_subclass(self): + """Verify FIELD_DTYPE.is_subclass correctly identifies subclasses""" + assert FIELD_DTYPE.FLOAT_DATA.is_subclass(np.float32) is True + assert FIELD_DTYPE.FLOAT_DATA.is_subclass(np.int32) is False + + assert FIELD_DTYPE.INT_DATA.is_subclass(np.int32) is True + assert FIELD_DTYPE.INT_DATA.is_subclass(np.float32) is False + + def test_FIELD_DTYPE_str(self): + """Verify str(FIELD_DTYPE) returns the underlying type's __name__""" + assert str(FIELD_DTYPE.FLOAT_DATA) == "float32" + assert str(FIELD_DTYPE.INT_DATA) == "int32" + assert str(FIELD_DTYPE.FLOAT_CONSTANT) == "float64" + + def test_GROUP_NAME_attributes(self): + """Verify GROUP_NAME enum members have allowMultiple and nxClass attributes""" + # Check that all enum members have the required attributes + for group in GROUP_NAME: + assert hasattr(group, "allowMultiple") + assert hasattr(group, "nxClass") + assert isinstance(group.allowMultiple, bool) + + # Verify specific known members + assert GROUP_NAME.ENTRY.allowMultiple is True + assert GROUP_NAME.DETECTOR.allowMultiple is True + assert GROUP_NAME.FIT.allowMultiple is True + + assert GROUP_NAME.INSTRUMENT.allowMultiple is False + assert GROUP_NAME.SAMPLE_DESCRIPTION.allowMultiple is False + + def test_group_naming_scheme_int_first(self): + """Verify group_naming_scheme with int suffix=1 omits suffix""" + assert group_naming_scheme("entry", 1) == "entry" + + def test_group_naming_scheme_int_second(self): + """Verify group_naming_scheme with int suffix>1 adds suffix""" + assert group_naming_scheme("entry", 2) == "entry_2" + assert group_naming_scheme("entry", 3) == "entry_3" + + def test_group_naming_scheme_str_default(self): + """Verify group_naming_scheme with DEFAULT_TAG omits suffix""" + assert group_naming_scheme("DIFFRACTOGRAM", DEFAULT_TAG) == "DIFFRACTOGRAM" + + def test_group_naming_scheme_str_nondefault(self): + """Verify group_naming_scheme with non-default string adds suffix""" + assert group_naming_scheme("DIFFRACTOGRAM", "custom_mask") == "DIFFRACTOGRAM_custom_mask" + assert group_naming_scheme("FIT", "mask_2") == "FIT_mask_2" + + def test_group_naming_scheme_invalid_suffix(self): + """Verify group_naming_scheme raises RuntimeError for invalid suffix type""" + with pytest.raises(RuntimeError, match=r".*not implemented for suffix.*"): + group_naming_scheme("entry", 3.14) + + def test_allowed_identifier(self): + """Verify allowed_identifier replaces : with _ and leaves other chars unchanged""" + assert allowed_identifier("HB2B:CS:Wavelength") == "HB2B_CS_Wavelength" + assert allowed_identifier("simple_name") == "simple_name" + assert allowed_identifier("name.with.dots") == "name.with.dots" + assert allowed_identifier("A:B:C:D") == "A_B_C_D" + + def test_is_ISO_8601_valid(self): + """Verify is_ISO_8601 returns True for valid ISO 8601 strings""" + assert is_ISO_8601("2024-01-15T10:30:00") is True + assert is_ISO_8601("2024-12-31T23:59:59") is True + assert is_ISO_8601("2024-01-01T00:00:00") is True + + def test_is_ISO_8601_invalid(self): + """Verify is_ISO_8601 returns False for invalid date strings""" + assert is_ISO_8601("not-a-date") is False + assert is_ISO_8601("2024/01/15 10:30:00") is False + assert is_ISO_8601("invalid") is False diff --git a/tests/unit/pyrs/utilities/NXstress/test_fit.py b/tests/unit/pyrs/utilities/NXstress/test_fit.py new file mode 100644 index 000000000..ad0c28217 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_fit.py @@ -0,0 +1,481 @@ +""" +Tests for pyrs/utilities/NXstress/_fit.py +""" + +from collections.abc import Callable +import numpy as np +from nexusformat.nexus import NXdata, NXnote, NXparameters, NXprocess +import pytest + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.peaks.peak_collection import PeakCollection +from pyrs.utilities.NXstress._fit import _BackgroundParameters, _Diffractogram, _Fit, _PeakParameters +from pyrs.utilities.NXstress._definitions import DEFAULT_TAG + + +class TestFit: + """Test suite for _fit.py""" + + PROJECT_FILE_B = "HB2B_1628.h5" # instrument, mask, reduced data, but no input data + PROJECT_FILE_C = "HB2B_1017_w_mask.h5" # instrument, mask, input data, reduced data + + def test_PeakParameters_data_values( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify numeric values in peak parameters match get_effective_params()""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="PseudoVoigt", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + params_value, params_error = peak0.get_effective_params() + + peak_params = _PeakParameters.init_group([peak0]) + + assert isinstance(peak_params, NXparameters) + + # Verify all required fields exist + assert "center" in peak_params + assert "center_errors" in peak_params + assert "height" in peak_params + assert "height_errors" in peak_params + assert "fwhm" in peak_params + assert "fwhm_errors" in peak_params + assert "form_factor" in peak_params + assert "form_factor_errors" in peak_params + + # Verify data values match + np.testing.assert_array_almost_equal(peak_params["center"].nxdata, params_value["Center"].astype(np.float64)) + np.testing.assert_array_almost_equal(peak_params["height"].nxdata, params_value["Height"].astype(np.float64)) + np.testing.assert_array_almost_equal(peak_params["fwhm"].nxdata, params_value["FWHM"].astype(np.float64)) + + # Form factor is (1.0 - Mixing) + expected_form_factor = (1.0 - params_value["Mixing"]).astype(np.float64) + np.testing.assert_array_almost_equal(peak_params["form_factor"].nxdata, expected_form_factor) + + def test_PeakParameters_multiple_peaks( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify two PeakCollections create 2×N_scan rows in sort order""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak_params = _PeakParameters.init_group([peak0, peak1]) + + # Should have 2 * N_subrun rows + assert peak_params["center"].shape[0] == 2 * N_subrun + + def test_PeakParameters_mismatched_profile_raises( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify ValueError when PeakCollections have different peak_profile""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak1 = createPeakCollection( + peak_tag="Si 111", + peak_profile="PseudoVoigt", # Different! + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + with pytest.raises(ValueError, match=r".*must share the same peak profile.*"): + _PeakParameters.init_group([peak0, peak1]) + + def test_BackgroundParameters_data_values( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify A0, A1, A2 (and errors) match get_effective_params()""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + params_value, params_error = peak0.get_effective_params() + + bg_params = _BackgroundParameters.init_group([peak0]) + + assert isinstance(bg_params, NXparameters) + + # Verify all background parameters + for param in ["A0", "A1", "A2"]: + assert param in bg_params + assert f"{param}_errors" in bg_params + + np.testing.assert_array_almost_equal(bg_params[param].nxdata, params_value[param].astype(np.float64)) + np.testing.assert_array_almost_equal( + bg_params[f"{param}_errors"].nxdata, params_error[param].astype(np.float64) + ) + + def test_BackgroundParameters_multiple_peaks( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify two PeakCollections create 2×N_scan rows""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak1 = createPeakCollection( + peak_tag="Al 222", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + bg_params = _BackgroundParameters.init_group([peak0, peak1]) + + # Should have 2 * N_subrun rows + assert bg_params["A0"].shape[0] == 2 * N_subrun + + def test_BackgroundParameters_mismatched_type_raises( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify ValueError when PeakCollections have different background_type""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak1 = createPeakCollection( + peak_tag="Si 111", + peak_profile="Gaussian", + background_type="Linear", # Different! + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + with pytest.raises(ValueError, match=r".*must share the same background type.*"): + _BackgroundParameters.init_group([peak0, peak1]) + + def test_Diffractogram_data_key_default(self): + """Verify _diffraction_data_key returns `None` for DEFAULT_TAG""" + data_key = _Diffractogram._diffraction_data_key(DEFAULT_TAG) + + assert data_key is None + + def test_Diffractogram_data_keys_named(self): + """Verify _diffraction_data_keys returns proper keys for named mask""" + data_key = _Diffractogram._diffraction_data_key("my_mask") + + assert data_key == "my_mask" + + def test_Diffractogram_init_no_reduced_data_raises( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify RuntimeError when workspace._2theta_matrix is None""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Set _2theta_matrix to None to simulate no reduced data + ws._2theta_matrix = None + + with pytest.raises(RuntimeError, match=r".*doesn't include any reduced data.*"): + _Diffractogram._init(ws) + + def test_Diffractogram_init_group_missing_mask_raises( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify RuntimeError when mask data not in workspace""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + # Try to create diffractogram for non-existent mask + with pytest.raises(RuntimeError, match=r".*is not present in the workspace.*"): + _Diffractogram.init_group(ws, "non_existent_mask", [peak0]) + + def test_Diffractogram_data_values( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify diffractogram/diffractogram_errors match workspace arrays""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + dgram = _Diffractogram.init_group(ws, DEFAULT_TAG, [peak0]) + + assert isinstance(dgram, NXdata) + + # Verify required fields + assert "diffractogram" in dgram + assert "diffractogram_errors" in dgram + assert "fit" in dgram + assert "fit_errors" in dgram + + # Verify data matches workspace (use allclose for float32 comparison) + data_key = _Diffractogram._diffraction_data_key(DEFAULT_TAG) + expected_data = ws._diff_data_set[data_key] + expected_errors = ws._var_data_set[data_key] + + np.testing.assert_allclose(dgram["diffractogram"].nxdata, expected_data, rtol=1e-6, equal_nan=True) + np.testing.assert_allclose(dgram["diffractogram_errors"].nxdata, expected_errors, rtol=1e-6, equal_nan=True) + + # fit and fit_errors should be empty + assert dgram["fit"].shape == (0, 0) + assert dgram["fit_errors"].shape == (0, 0) + + def test_Fit_init_fields( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify _Fit._init creates fields: date, program, raw_data_file, DESCRIPTION""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + logs = ws._sample_logs + fit = _Fit._init(logs, processing_description="Test description", processing_time="2024-01-15T10:30:00") + + assert isinstance(fit, NXprocess) + assert "date" in fit + assert "program" in fit + assert "raw_data_file" in fit + assert "DESCRIPTION" in fit + + assert fit["program"] == "PyRS" + assert fit["date"] == "2024-01-15T10:30:00" + assert isinstance(fit["DESCRIPTION"], NXnote) + + def test_Fit_multiple_masks( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify workspace with multiple masks creates one DIFFRACTOGRAM per mask""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + fit = _Fit.init_group(ws, [peak0], ws._sample_logs) + + # Count NXdata groups (diffractograms) + diffractogram_count = sum(1 for key in fit.keys() if isinstance(fit[key], NXdata)) + + # Should have at least one diffractogram + assert diffractogram_count >= 1 + + def test_Fit_duplicate_diffractogram_raises( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify RuntimeError when diffractogram name collision occurs""" + # This test checks the internal logic - would need to manipulate + # workspace to have duplicate mask names, which is prevented elsewhere + # For now, we'll skip this as it's hard to trigger in practice + pass + + def test_validateWorkspaceAndPeaksData_valid( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify validation passes for matching workspace and peaks data""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + # Should not raise + _Fit.validateWorkspaceAndPeaksData(ws, [peak0]) + + def test_validateWorkspaceAndPeaksData_missing_scan_points( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify ValueError when PeakCollection references missing scan points""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + # Add a non-existent scan point to the peak collection + import numpy as np + from pyrs.dataobjects.sample_logs import SubRuns + + # Create sub_runs with extra scan points not in workspace + extra_subruns = np.append(subruns, [9999, 10000]) + peak0._sub_run_array = SubRuns(extra_subruns) + + with pytest.raises(ValueError, match=r".*not present in workspace.*"): + _Fit.validateWorkspaceAndPeaksData(ws, [peak0]) + + def test_validateWorkspaceAndPeaksData_missing_mask_data( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify ValueError when PeakCollection references missing mask data""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + # Set a mask that doesn't exist in the workspace + peak0._mask = "non_existent_mask" + + with pytest.raises(ValueError, match=r".*not present in the workspace.*"): + _Fit.validateWorkspaceAndPeaksData(ws, [peak0]) diff --git a/tests/unit/pyrs/utilities/NXstress/test_helper_util.py b/tests/unit/pyrs/utilities/NXstress/test_helper_util.py new file mode 100644 index 000000000..1b68318b7 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_helper_util.py @@ -0,0 +1,32 @@ +# ruff: noqa: F841 +from pathlib import Path + +from pyrs.projectfile.file_object import HidraProjectFile + + +PROJECT_FILE = "HB2B_1628.h5" + + +def test_createPeakCollection(createPeakCollection): + peaks = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=25, + ) + + +def test_load_HidraWorkspace(load_HidraWorkspace): + ws = load_HidraWorkspace( + file_name=PROJECT_FILE, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + assert ws.name == "test_workspace" + + +def test_HidraProjectFile_context_manager(test_data_dir): + project_file_path = Path(test_data_dir) / PROJECT_FILE + with HidraProjectFile(project_file_path) as project_file: + assert project_file.name == str(project_file_path) diff --git a/tests/unit/pyrs/utilities/NXstress/test_input_data.py b/tests/unit/pyrs/utilities/NXstress/test_input_data.py new file mode 100644 index 000000000..1d9a2ba84 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_input_data.py @@ -0,0 +1,137 @@ +""" +Tests for pyrs/utilities/NXstress/_input_data.py +""" + +from collections.abc import Callable +import numpy as np +from nexusformat.nexus import NXdata, nxopen +from pathlib import Path +import pytest + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.utilities.NXstress._input_data import _InputData + + +class TestInputData: + """Test suite for _input_data.py""" + + PROJECT_FILE_A = "HB2B_1017.h5" # instrument, input data, reduced data, no mask + PROJECT_FILE_C = "HB2B_1017_w_mask.h5" # instrument, mask, input data, reduced data + + def test_InputData_init_group_raises_on_existing_data( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify RuntimeError when trying to append detector_counts data""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + # Create an existing NXdata group + existing_data = NXdata() + + with pytest.raises(RuntimeError, match=r".*not implemented: append detector_counts data to NXstress file.*"): + _InputData.init_group(ws, data=existing_data) + + def test_InputData_init_group_data_values( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify detector_counts shape and scan_point values match workspace""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + data = _InputData.init_group(ws) + + # Verify structure + assert isinstance(data, NXdata) + assert "detector_counts" in data + assert "scan_point" in data + + # Verify data shape + scan_points = list(ws._raw_counts.keys()) + N_scan = len(scan_points) + + # Get detector size from first scan point + first_counts = ws.get_detector_counts(scan_points[0]) + N_pixels = len(first_counts) + + assert data["detector_counts"].shape == (N_scan, N_pixels) + assert len(data["scan_point"]) == N_scan + + # Verify scan_point values match + np.testing.assert_array_equal(data["scan_point"], scan_points) + + def test_InputData_readSubruns( + self, + tmp_path: Path, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify readSubruns round-trip: write then read back""" + # Load workspace with raw counts + ws_write = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, + name="test_workspace_write", + load_raw_counts=True, + load_reduced_diffraction=True, + ) + + # Create input data + data = _InputData.init_group(ws_write) + + # Write to file + file_path = tmp_path / "test_readSubruns.nxs" + with nxopen(str(file_path), "w") as nx: + nx["input_data"] = data + + # Create empty workspace for reading + ws_read = HidraWorkspace("test_workspace_read") + # `SampleLogs` must already be attached: + # otherwise the workspace will have no `Subruns`! + ws_read._sample_logs = ws_write._sample_logs + + # Read back + with nxopen(str(file_path), "r") as nx: + _InputData.readSubruns(ws_read, nx["input_data"]) + + # Verify round-trip + assert len(ws_read.get_sub_runs()) == len(ws_write.get_sub_runs()) + + # Check that all scan points are present + original_scan_points = list(ws_write._raw_counts.keys()) + read_scan_points = list(ws_write._raw_counts.keys()) + + for scan_point in original_scan_points: + assert scan_point in read_scan_points + original_counts = ws_write.get_detector_counts(scan_point) + read_counts = ws_read.get_detector_counts(scan_point) + np.testing.assert_array_equal(read_counts, original_counts) + + def test_InputData_readSubruns_raises_on_scanpoint_mismatch( + self, + tmp_path: Path, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify RuntimeError when workspace has subruns that don't match those from input data""" + # Load workspace with data + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + # Create input data and write to file + data = _InputData.init_group(ws) + file_path = tmp_path / "test_existing_subruns.nxs" + with nxopen(str(file_path), "w") as nx: + nx["input_data"] = data + + # Try to read into workspace that has subruns that do not match + existing_subruns = ws._sample_logs._subruns._value + ws._sample_logs._subruns._value = np.append( + existing_subruns, [max(existing_subruns) + 1, max(existing_subruns) + 2] + ) + with nxopen(str(file_path), "r") as nx: + with pytest.raises( + RuntimeError, match=r".*not implemented: append or change detector_counts data on existing workspace.*" + ): + _InputData.readSubruns(ws, nx["input_data"]) diff --git a/tests/unit/pyrs/utilities/NXstress/test_instrument.py b/tests/unit/pyrs/utilities/NXstress/test_instrument.py new file mode 100644 index 000000000..39e0333cf --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_instrument.py @@ -0,0 +1,186 @@ +# ruff: noqa: F841 +""" +Tests for pyrs/utilities/NXstress/_instrument.py +""" + +from collections.abc import Callable +import numpy as np +from nexusformat.nexus import NXcollection, NXinstrument, NXdetector_module +import pytest + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.utilities.NXstress._instrument import _Instrument, _Masks +from pyrs.utilities.NXstress._definitions import DEFAULT_TAG + + +class TestInstrument: + """Test suite for _instrument.py""" + + PROJECT_FILE_A = "HB2B_1017.h5" # instrument, input data, reduced data, no mask + PROJECT_FILE_B = "HB2B_1628.h5" # instrument, mask, reduced data, but no input data + PROJECT_FILE_C = "HB2B_1017_w_mask.h5" # instrument, mask, input data, reduced data + + def test_Masks_init(self): + """Verify _Masks._init creates empty NXcollection with required fields""" + masks = _Masks._init() + + assert isinstance(masks, NXcollection) + assert "names" in masks + assert "detector" in masks + assert "solid_angle" in masks + + # Verify empty structure + assert len(masks["names"]) == 0 + assert isinstance(masks["detector"], NXcollection) + assert isinstance(masks["solid_angle"], NXcollection) + + def test_Masks_init_group_with_default_mask( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify default mask appears in masks with DEFAULT_TAG name""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + masks = _Masks.init_group(ws) + + assert isinstance(masks, NXcollection) + assert DEFAULT_TAG in masks["names"] + assert DEFAULT_TAG in masks["detector"] + + def test_Masks_init_group_append( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify calling init_group twice (detector then solid_angle) populates both""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + # First call for detector masks + masks = _Masks.init_group(ws) + initial_count = len(masks["names"]) + + # Second call for solid angle masks (appending) + # For this test, we'll use the same workspace but we'll just change the names. + defaults = ws._diff_data_set[None], ws._var_data_set[None], ws._mask_dict.get(None, None) + ws._diff_data_set = {f"{k}_2nd": v for k, v in ws._diff_data_set.items() if k is not None} + ws._var_data_set = {f"{k}_2nd": v for k, v in ws._var_data_set.items() if k is not None} + ws._mask_dict = {f"{k}_2nd": v for k, v in ws._mask_dict.items() if k is not None} + # Re-add the default items: + ws._diff_data_set[None], ws._var_data_set[None] = defaults[0:2] + if defaults[2]: + ws._mask_dict[None] = defaults[2] + + # In real usage, solid angle masks would be different data + masks = _Masks.init_group(ws, masks=masks) + + # Names should have been appended + assert len(masks["names"]) >= initial_count + + def test_Masks_init_group_duplicate_raises( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify behavior when attempting to add duplicate masks + + This test triggers the duplicate-check behavior, because links to + the default detector-mask are automatically created when there are no + masks in the workspace's mask dict. + """ + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_C, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + # Create masks: + # this will both intialize a mask at '_DEFAULT_' and also produce links to + # this default detector-mask for all entries in `ws._diff_data_set`. + masks = _Masks.init_group(ws) + + with pytest.raises(RuntimeError, match=r".*Usage error: mask .* has already been written.*"): + masks2 = _Masks.init_group(ws, masks=masks) + + def test_Instrument_init(self): + """Verify _Instrument._init creates NXinstrument with name and short_name""" + inst = _Instrument._init("HB2B", "HB2B") + + assert isinstance(inst, NXinstrument) + assert "name" in inst + assert inst["name"] == "HB2B" + assert inst["name"].attrs["short_name"] == "HB2B" + + def test_Instrument_detector_module_fields( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify NXdetector_module contains required fields""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + inst = _Instrument.init_group(ws) + + assert "DETECTOR" in inst + detector = inst["DETECTOR"] + assert "detector_bank" in detector + + det_module = detector["detector_bank"] + assert isinstance(det_module, NXdetector_module) + + # Verify required fields + assert "data_size" in det_module + assert "fast_pixel_direction" in det_module + assert "slow_pixel_direction" in det_module + assert "depends_on" in det_module + + # Verify data_size is 2D array [rows, cols] + assert len(det_module["data_size"]) == 2 + assert det_module["data_size"].dtype == np.int64 + + def test_Instrument_transformations_chain( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify all 8 transformations exist and depends_on chain is correct""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_A, name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + inst = _Instrument.init_group(ws) + + detector = inst["DETECTOR"] + assert "transformations" in detector + + trans = detector["transformations"] + + # Verify all 8 transformations exist + expected_transforms = [ + "translation_x", + "translation_y", + "translation_z", + "distance", + "rotation_x", + "rotation_y", + "rotation_z", + "two_theta_zero", + ] + + for name in expected_transforms: + assert name in trans + # Each transformation should have required attributes + assert "transformation_type" in trans[name].attrs + assert "vector" in trans[name].attrs + assert "depends_on" in trans[name].attrs + + # Verify depends_on chain + # First transformation depends on '.' + assert trans["translation_x"].attrs["depends_on"] == "." + + # Subsequent transformations form a chain + assert trans["translation_y"].attrs["depends_on"] == "./transformations/translation_x" + assert trans["translation_z"].attrs["depends_on"] == "./transformations/translation_y" + assert trans["distance"].attrs["depends_on"] == "./transformations/translation_z" + + # Detector depends on first transformation + assert detector["depends_on"] == "./transformations/translation_x" diff --git a/tests/unit/pyrs/utilities/NXstress/test_peaks.py b/tests/unit/pyrs/utilities/NXstress/test_peaks.py new file mode 100644 index 000000000..7d025f102 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_peaks.py @@ -0,0 +1,280 @@ +# ruff: noqa: E741, F841 +""" +Tests for pyrs/utilities/NXstress/_peaks.py +""" + +from collections.abc import Callable +import numpy as np +from nexusformat.nexus import NXreflections + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.peaks.peak_collection import PeakCollection +from pyrs.utilities.NXstress._peaks import _Peaks + + +class TestPeaks: + """Test suite for _peaks.py""" + + PROJECT_FILE_B = "HB2B_1628.h5" # instrument, mask, reduced data, but no input data + + def test_Peaks_init_empty( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify _Peaks._init creates empty datasets with correct dtypes/units""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + logs = ws._sample_logs + peaks = _Peaks._init(logs) + + assert isinstance(peaks, NXreflections) + + # Verify all required fields exist and array fields are empty + array_fields = [ + "scan_point", + "h", + "k", + "l", + "phase_name", + "mask", + "qx", + "qy", + "qz", + "center", + "center_errors", + "sx", + "sy", + "sz", + ] + + for field in array_fields: + assert field in peaks + assert peaks[field].shape[0] == 0 + + # Verify scalar field + assert "center_type" in peaks + assert peaks["center_type"].nxdata == "d-spacing" + + def test_Peaks_init_group_data_values( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify one PeakCollection creates N_scan rows with correct values""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 251540", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peaks = _Peaks.init_group([peak0], ws._sample_logs) + + assert isinstance(peaks, NXreflections) + + # Verify shape + assert peaks["h"].shape[0] == N_subrun + assert peaks["k"].shape[0] == N_subrun + assert peaks["l"].shape[0] == N_subrun + assert peaks["phase_name"].shape[0] == N_subrun + assert peaks["mask"].shape[0] == N_subrun + assert peaks["scan_point"].shape[0] == N_subrun + assert peaks["center"].shape[0] == N_subrun + assert peaks["center_errors"].shape[0] == N_subrun + + # Verify values + # Parse peak tag to get expected h, k, l + phase, (h, k, l) = _Peaks._parse_peak_tag(peak0.peak_tag) + + # All rows should have same h, k, l + assert all(peaks["h"].nxdata == h) + assert all(peaks["k"].nxdata == k) + assert all(peaks["l"].nxdata == l) + + # All rows should have same phase_name + assert all(p == phase for p in peaks["phase_name"].nxdata) + + # scan_point should match subruns + np.testing.assert_array_equal(peaks["scan_point"].nxdata, subruns) + + def test_Peaks_init_group_multiple_peaks( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify two PeakCollections create 2×N_scan rows in lexicographic sort order""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + # Create two peaks - they will be sorted by PeakIndex.sort_key + peak0 = createPeakCollection( + peak_tag="Al 251540", # (2, 5, 1540) -> (25, 15, 40) after parsing "251540" + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peaks = _Peaks.init_group([peak0, peak1], ws._sample_logs) + + # Should have 2 * N_subrun rows + assert peaks["h"].shape[0] == 2 * N_subrun + + # Verify sorting - first N_subrun rows should be from peak with lower sort key + # Sort key is (phase_name, h, k, l, mask) + # Both are "Al", so it's sorted by (h, k, l) + phase0, hkl0 = _Peaks._parse_peak_tag(peak0.peak_tag) + phase1, hkl1 = _Peaks._parse_peak_tag(peak1.peak_tag) + + # Determine which peak should come first + key0 = (phase0, *hkl0, peak0.mask) + key1 = (phase1, *hkl1, peak1.mask) + + if key0 < key1: + first_peak = peak0 + first_hkl = hkl0 + else: + first_peak = peak1 + first_hkl = hkl1 + + # Verify first N_subrun rows match the first peak in sort order + h, k, l = first_hkl + assert all(peaks["h"].nxdata[:N_subrun] == h) + assert all(peaks["k"].nxdata[:N_subrun] == k) + assert all(peaks["l"].nxdata[:N_subrun] == l) + + def test_PeakIndex_sort_key( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify PeakIndex.sort_key returns correct tuple for sorting""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peak1 = createPeakCollection( + peak_tag="Si 222", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + # Get sort keys + key0 = _Peaks.PeakIndex.sort_key(peak0) + key1 = _Peaks.PeakIndex.sort_key(peak1) + + # Keys should be tuples (phase_name, h, k, l, mask) + assert len(key0) == 5 + assert len(key1) == 5 + + # Verify they can be compared for sorting + assert key0 != key1 + assert (key0 < key1) or (key0 > key1) + + def test_Peaks_qxyz_nan( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify qx, qy, qz fields exist but remain empty after init_group since implementation doesn't populate them""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peaks = _Peaks.init_group([peak0], ws._sample_logs) + + # qx, qy, qz exist but remain empty (implementation doesn't populate them) + assert "qx" in peaks + assert "qy" in peaks + assert "qz" in peaks + + assert peaks["qx"].shape[0] == 0 + assert peaks["qy"].shape[0] == 0 + assert peaks["qz"].shape[0] == 0 + + def test_Peaks_sxyz_nan( + self, load_HidraWorkspace: Callable[..., HidraWorkspace], createPeakCollection: Callable[..., PeakCollection] + ): + """Verify sx, sy, sz are filled with NaN after init_group""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak0 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/does/not/exist.h5", + runnumber=12345, + N_subrun=N_subrun, + ) + + peaks = _Peaks.init_group([peak0], ws._sample_logs) + + # sx, sy, sz should exist and be filled with NaN + assert "sx" in peaks + assert "sy" in peaks + assert "sz" in peaks + + assert peaks["sx"].shape[0] == N_subrun + assert peaks["sy"].shape[0] == N_subrun + assert peaks["sz"].shape[0] == N_subrun + + # All values should be NaN + assert all(np.isnan(peaks["sx"].nxdata)) + assert all(np.isnan(peaks["sy"].nxdata)) + assert all(np.isnan(peaks["sz"].nxdata)) diff --git a/tests/unit/pyrs/utilities/NXstress/test_peaks_read.py b/tests/unit/pyrs/utilities/NXstress/test_peaks_read.py new file mode 100644 index 000000000..ec292cbf6 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_peaks_read.py @@ -0,0 +1,522 @@ +# ruff: noqa: E741, F841 +""" +Tests for NXstress read functionality in pyrs/utilities/NXstress/_peaks.py and _fit.py +""" + +import numpy as np +from nexusformat.nexus import NXparameters, NXfield +import pytest +from pathlib import Path +import tempfile + +from pyrs.utilities.NXstress._peaks import _Peaks +from pyrs.utilities.NXstress._fit import _PeakParameters, _BackgroundParameters +from pyrs.utilities.NXstress.NXstress import NXstress +from pyrs.utilities.NXstress._definitions import FIELD_DTYPE, GROUP_NAME + + +class TestPeakCollectionRanges: + """Test suite for _Peaks.peakCollectionRanges""" + + def test_peakCollectionRanges_happy_path(self, load_HidraWorkspace, createPeakCollection): + """Write 3 PeakCollections with distinct keys, read ranges, verify count and span""" + ws = load_HidraWorkspace( + file_name="HB2B_1628.h5", name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + # Create 3 distinct PeakCollections + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + peak2 = createPeakCollection( + peak_tag="Si 200", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + peak3 = createPeakCollection( + peak_tag="Fe 110", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + # Write to NXreflections group + peaks_group = _Peaks.init_group([peak1, peak2, peak3], ws._sample_logs) + + # Read ranges + ranges = _Peaks.peakCollectionRanges(peaks_group) + + # Verify we got 3 ranges + assert len(ranges) == 3 + + # Verify each range spans N_subrun entries + for (phase_name, h, k, l, mask), start, end in ranges: + assert end - start == N_subrun + + # Verify ranges are contiguous + expected_start = 0 + for (phase_name, h, k, l, mask), start, end in ranges: + assert start == expected_start + expected_start = end + + def test_peakCollectionRanges_interleaved_blocks(self, load_HidraWorkspace): + """Construct NXreflections with non-contiguous blocks for same key → RuntimeError""" + ws = load_HidraWorkspace( + file_name="HB2B_1628.h5", name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Manually create NXreflections with interleaved blocks + peaks = _Peaks._init(ws._sample_logs) + + # Create data for interleaved pattern: Al-111, Si-200, Al-111 (duplicate) + phase_names = np.array(["Al", "Si", "Al"]) + h_vals = np.array([1, 2, 1]) + k_vals = np.array([1, 0, 1]) + l_vals = np.array([1, 0, 1]) + masks = np.array(["_DEFAULT_", "_DEFAULT_", "_DEFAULT_"]) + scan_points = np.array([1, 1, 2]) + + # Resize and fill datasets + peaks["phase_name"].resize((3,)) + peaks["h"].resize((3,)) + peaks["k"].resize((3,)) + peaks["l"].resize((3,)) + peaks["mask"].resize((3,)) + peaks["scan_point"].resize((3,)) + peaks["center"].resize((3,)) + peaks["center_errors"].resize((3,)) + + peaks["phase_name"][:] = phase_names + peaks["h"][:] = h_vals + peaks["k"][:] = k_vals + peaks["l"][:] = l_vals + peaks["mask"][:] = masks + peaks["scan_point"][:] = scan_points + peaks["center"][:] = [1.0, 2.0, 1.5] + peaks["center_errors"][:] = [0.01, 0.02, 0.015] + + # Should raise RuntimeError about interleaved blocks + with pytest.raises(RuntimeError, match="Interleaved blocks detected"): + _Peaks.peakCollectionRanges(peaks) + + def test_peakCollectionRanges_scan_point_order_violation(self, load_HidraWorkspace): + """Non-increasing scan points within block → RuntimeError""" + ws = load_HidraWorkspace( + file_name="HB2B_1628.h5", name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Manually create NXreflections with non-increasing scan_point + peaks = _Peaks._init(ws._sample_logs) + + # Create data with scan_point not strictly increasing: 1, 3, 2 (wrong!) + phase_names = np.array(["Al", "Al", "Al"]) + h_vals = np.array([1, 1, 1]) + k_vals = np.array([1, 1, 1]) + l_vals = np.array([1, 1, 1]) + masks = np.array(["_DEFAULT_", "_DEFAULT_", "_DEFAULT_"]) + scan_points = np.array([1, 3, 2]) # Not strictly increasing! + + # Resize and fill datasets + peaks["phase_name"].resize((3,)) + peaks["h"].resize((3,)) + peaks["k"].resize((3,)) + peaks["l"].resize((3,)) + peaks["mask"].resize((3,)) + peaks["scan_point"].resize((3,)) + peaks["center"].resize((3,)) + peaks["center_errors"].resize((3,)) + + peaks["phase_name"][:] = phase_names + peaks["h"][:] = h_vals + peaks["k"][:] = k_vals + peaks["l"][:] = l_vals + peaks["mask"][:] = masks + peaks["scan_point"][:] = scan_points + peaks["center"][:] = [1.0, 1.0, 1.0] + peaks["center_errors"][:] = [0.01, 0.01, 0.01] + + # Should raise RuntimeError about scan_point not strictly increasing + with pytest.raises(RuntimeError, match="scan_point values are not strictly increasing"): + _Peaks.peakCollectionRanges(peaks) + + +class TestValidateNoDuplicatePeaks: + """Test suite for _Peaks.validateNoDuplicatePeaks""" + + def test_validateNoDuplicatePeaks_no_duplicates(self, createPeakCollection): + """3 distinct PeakCollections → no error""" + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=5, + ) + + peak2 = createPeakCollection( + peak_tag="Si 200", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=5, + ) + + peak3 = createPeakCollection( + peak_tag="Fe 110", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=5, + ) + + # Should not raise any error + _Peaks.validateNoDuplicatePeaks([peak1, peak2, peak3]) + + def test_validateNoDuplicatePeaks_with_duplicates(self, createPeakCollection): + """2 PeakCollections with same key → ValueError with 'Duplicate PeakCollection detected'""" + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=5, + ) + + # Create another with same peak_tag and mask (duplicate!) + peak2 = createPeakCollection( + peak_tag="Al 111", # Same as peak1 + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=5, + ) + + # Should raise ValueError + with pytest.raises(ValueError, match="Duplicate PeakCollection detected"): + _Peaks.validateNoDuplicatePeaks([peak1, peak2]) + + +class TestPeakParametersForRange: + """Test suite for _PeakParameters.peakParametersForRange""" + + def test_peakParametersForRange(self): + """Manual NXparameters with known data, slice, verify native params including form_factor→Mixing inversion""" + # Create manual NXparameters group for Gaussian peak + pp = NXparameters() + pp["title"] = NXfield("gaussian", dtype=FIELD_DTYPE.STRING.value) + + # Create datasets with known values + N = 10 + centers = np.linspace(10.0, 20.0, N) + heights = np.linspace(100.0, 200.0, N) + fwhms = np.linspace(0.5, 1.5, N) + form_factors = np.linspace(0.2, 0.8, N) # Will be inverted to Mixing + + pp["center"] = NXfield(centers) + pp["center_errors"] = NXfield(centers * 0.01) + pp["height"] = NXfield(heights) + pp["height_errors"] = NXfield(heights * 0.01) + pp["fwhm"] = NXfield(fwhms) + pp["fwhm_errors"] = NXfield(fwhms * 0.01) + pp["form_factor"] = NXfield(form_factors) + pp["form_factor_errors"] = NXfield(form_factors * 0.01) + + # Slice range [2:5] + start, end = 2, 5 + native_values, native_errors = _PeakParameters.peakParametersForRange(pp, start, end) + + # Verify we got 3 entries + assert len(native_values) == 3 + assert len(native_errors) == 3 + + # Verify native parameter fields exist (Gaussian: Height, PeakCentre, Sigma, A0, A1) + assert "Height" in native_values.dtype.names + assert "PeakCentre" in native_values.dtype.names + assert "Sigma" in native_values.dtype.names + + # Verify values match sliced data (account for float32 precision) + np.testing.assert_allclose(native_values["Height"], heights[start:end], rtol=1e-6) + np.testing.assert_allclose(native_values["PeakCentre"], centers[start:end], rtol=1e-6) + + # Verify errors match (account for float32 precision) + np.testing.assert_allclose(native_errors["Height"], heights[start:end] * 0.01, rtol=1e-6) + np.testing.assert_allclose(native_errors["PeakCentre"], centers[start:end] * 0.01, rtol=1e-6) + + # CRITICAL: Verify form_factor was inverted to Mixing (not directly visible in native Gaussian) + # For Gaussian, we converted via effective parameters where Mixing=1-form_factor + # Then converted back to native Sigma = FWHM / (2*sqrt(2*ln(2))) + expected_sigma = fwhms[start:end] / (2.0 * np.sqrt(2.0 * np.log(2.0))) + np.testing.assert_array_almost_equal(native_values["Sigma"], expected_sigma, decimal=5) + + +class TestBackgroundParametersForRange: + """Test suite for _BackgroundParameters.backgroundParametersForRange""" + + def test_backgroundParametersForRange(self): + """Manual NXparameters, slice, verify A0/A1/A2""" + # Create manual NXparameters group for background + bp = NXparameters() + bp["title"] = NXfield("quadratic", dtype=FIELD_DTYPE.STRING.value) + + # Create datasets with known values + N = 10 + A0_vals = np.linspace(1.0, 10.0, N) + A1_vals = np.linspace(0.1, 1.0, N) + A2_vals = np.linspace(0.01, 0.1, N) + + bp["A0"] = NXfield(A0_vals) + bp["A0_errors"] = NXfield(A0_vals * 0.05) + bp["A1"] = NXfield(A1_vals) + bp["A1_errors"] = NXfield(A1_vals * 0.05) + bp["A2"] = NXfield(A2_vals) + bp["A2_errors"] = NXfield(A2_vals * 0.05) + + # Slice range [3:7] + start, end = 3, 7 + bg_values, bg_errors = _BackgroundParameters.backgroundParametersForRange(bp, start, end) + + # Verify we got 4 entries + assert len(bg_values) == 4 + assert len(bg_errors) == 4 + + # Verify A0, A1, A2 fields exist + assert "A0" in bg_values.dtype.names + assert "A1" in bg_values.dtype.names + assert "A2" in bg_values.dtype.names + + # Verify values match sliced data + np.testing.assert_array_almost_equal(bg_values["A0"], A0_vals[start:end]) + np.testing.assert_array_almost_equal(bg_values["A1"], A1_vals[start:end]) + np.testing.assert_array_almost_equal(bg_values["A2"], A2_vals[start:end]) + + # Verify errors match + np.testing.assert_array_almost_equal(bg_errors["A0"], A0_vals[start:end] * 0.05) + np.testing.assert_array_almost_equal(bg_errors["A1"], A1_vals[start:end] * 0.05) + np.testing.assert_array_almost_equal(bg_errors["A2"], A2_vals[start:end] * 0.05) + + +class TestPeakCollectionsFromNexus: + """Test suite for full round-trip read/write""" + + def test_peakCollectionsFromNexus_roundtrip(self, load_HidraWorkspace, createPeakCollection): + """Write PeakCollections via NXstress.write(), read back via peakCollectionsFromNexus, verify match""" + ws = load_HidraWorkspace( + file_name="HB2B_1017_w_mask.h5", + name="test_workspace", + load_raw_counts=True, # Required to load instrument geometry + load_reduced_diffraction=True, + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + # Create test PeakCollections (must use same peak profile) + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Quadratic", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + peak2 = createPeakCollection( + peak_tag="Si 200", + peak_profile="Gaussian", # Must match peak1 + background_type="Quadratic", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + original_peaks = [peak1, peak2] + + # Write to temporary file + with tempfile.TemporaryDirectory() as tmpdir: + file_path = Path(tmpdir) / "test_roundtrip.nxs" + + with NXstress(file_path, mode="w") as nxs: + nxs.write(ws, original_peaks) + + # Read back + with NXstress(file_path, mode="r") as nxs: + # Access the first entry + entry_name = "entry" + entry = nxs._root[entry_name] + + peaks_group = entry[GROUP_NAME.PEAKS] + fit_group = entry[GROUP_NAME.FIT] + + # Read PeakCollections + reconstructed_peaks = _Peaks.peakCollectionsFromNexus(peaks_group, fit_group) + + # Verify we got 2 PeakCollections back + assert len(reconstructed_peaks) == 2 + + # Match by sub-index key (not by list position) + original_by_key = {_Peaks.PeakIndex.sort_key(p): p for p in original_peaks} + reconstructed_by_key = {_Peaks.PeakIndex.sort_key(p): p for p in reconstructed_peaks} + + assert set(original_by_key.keys()) == set(reconstructed_by_key.keys()) + + # Verify each PeakCollection + for key in original_by_key: + orig = original_by_key[key] + recon = reconstructed_by_key[key] + + # Verify peak_tag parses to same (phase, h, k, l) + # Exact string match is not required (spaces are not significant) + orig_phase, orig_hkl = _Peaks._parse_peak_tag(orig.peak_tag) + recon_phase, recon_hkl = _Peaks._parse_peak_tag(recon.peak_tag) + assert orig_phase == recon_phase + assert orig_hkl == recon_hkl + + # Verify mask + assert orig.mask == recon.mask + + # Verify sub_runs match + np.testing.assert_array_equal(orig.sub_runs.raw_copy(), recon.sub_runs.raw_copy()) + + # Verify d_reference (should be constant for all subruns) + orig_d, orig_d_err = orig.get_d_reference() + recon_d, recon_d_err = recon.get_d_reference() + np.testing.assert_almost_equal(orig_d, recon_d, decimal=5) + np.testing.assert_almost_equal(orig_d_err, recon_d_err, decimal=5) + + # Verify effective parameters match (within tolerance) + orig_eff_vals, orig_eff_errs = orig.get_effective_params() + recon_eff_vals, recon_eff_errs = recon.get_effective_params() + + # Check all effective parameter fields + for field in ["Center", "Height", "FWHM", "Mixing"]: + np.testing.assert_allclose( + orig_eff_vals[field], recon_eff_vals[field], atol=1e-5, err_msg=f"Mismatch in {field} values" + ) + np.testing.assert_allclose( + orig_eff_errs[field], recon_eff_errs[field], atol=1e-5, err_msg=f"Mismatch in {field} errors" + ) + + def test_peak_tag_roundtrip_multidigit_miller(self, load_HidraWorkspace, createPeakCollection): + """PeakCollection with peak_tag='Fe120100' (h=12,k=1,l=0) round-trips correctly""" + ws = load_HidraWorkspace( + file_name="HB2B_1017_w_mask.h5", + name="test_workspace", + load_raw_counts=True, # Required to load instrument geometry + load_reduced_diffraction=True, + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + # Create PeakCollection with multi-digit Miller indices + peak = createPeakCollection( + peak_tag="Fe120100", # h=12, k=1, l=0 → N_d=2, each index is 2 digits + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + original_peaks = [peak] + + # Write and read back + with tempfile.TemporaryDirectory() as tmpdir: + file_path = Path(tmpdir) / "test_multidigit.nxs" + + with NXstress(file_path, mode="w") as nxs: + nxs.write(ws, original_peaks) + + with NXstress(file_path, mode="r") as nxs: + entry = nxs._root["entry"] + peaks_group = entry[GROUP_NAME.PEAKS] + fit_group = entry[GROUP_NAME.FIT] + + reconstructed_peaks = _Peaks.peakCollectionsFromNexus(peaks_group, fit_group) + + # Verify peak_tag matches + assert len(reconstructed_peaks) == 1 + assert reconstructed_peaks[0].peak_tag == "Fe120100" + + # Verify parsing produces correct Miller indices + phase, (h, k, l) = _Peaks._parse_peak_tag(reconstructed_peaks[0].peak_tag) + assert phase == "Fe" + assert h == 12 + assert k == 1 + assert l == 0 + + +class TestValidateNoDuplicatePeaksIntegration: + """Test validateNoDuplicatePeaks integration in NXstress.write()""" + + def test_validateNoDuplicatePeaks_integration_in_write(self, load_HidraWorkspace, createPeakCollection): + """NXstress.write() with duplicates → ValueError before any file content written""" + ws = load_HidraWorkspace( + file_name="HB2B_1628.h5", name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + subruns = ws._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + # Create duplicate PeakCollections + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + peak2 = createPeakCollection( + peak_tag="Al 111", # Duplicate! + peak_profile="Gaussian", + background_type="Linear", + wavelength=25.4, + projectfilename="/tmp/test.h5", + runnumber=1, + N_subrun=N_subrun, + ) + + duplicate_peaks = [peak1, peak2] + + # Attempt to write should raise ValueError + with tempfile.TemporaryDirectory() as tmpdir: + file_path = Path(tmpdir) / "test_duplicate_check.nxs" + + # The validation happens before any writes, so the error is raised early + with pytest.raises(ValueError, match="Duplicate PeakCollection detected"): + with NXstress(file_path, mode="w") as nxs: + nxs.write(ws, duplicate_peaks) diff --git a/tests/unit/pyrs/utilities/NXstress/test_sample.py b/tests/unit/pyrs/utilities/NXstress/test_sample.py new file mode 100644 index 000000000..0a966eb87 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_sample.py @@ -0,0 +1,232 @@ +""" +Tests for pyrs/utilities/NXstress/_sample.py +""" + +from collections.abc import Callable +import numpy as np +from nexusformat.nexus import NXsample, NXcollection +import pytest + +from pyrs.core.workspaces import HidraWorkspace +from pyrs.dataobjects.constants import HidraConstants +from pyrs.utilities.NXstress._sample import _Sample +from pyrs.utilities.NXstress._definitions import FIELD_DTYPE + + +class TestSample: + """Test suite for _sample.py""" + + PROJECT_FILE_A = "HB2B_1017.h5" # instrument, input data, reduced data, no mask + PROJECT_FILE_B = "HB2B_1628.h5" # instrument, mask, reduced data, but no input data + PROJECT_FILE_C = "HB2B_1017_w_mask.h5" # instrument, mask, input data, reduced data + + def test_Sample_scan_point_and_coordinates( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify scan_point matches subruns and vx,vy,vz have correct shape/dtype""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + sample = _Sample.init_group(ws._sample_logs) + + assert isinstance(sample, NXsample) + assert "scan_point" in sample + assert "vx" in sample + assert "vy" in sample + assert "vz" in sample + + # Verify scan_point matches subruns + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + + np.testing.assert_array_equal(sample["scan_point"].nxdata, subruns.astype(FIELD_DTYPE.INT_DATA.value)) + + # Verify coordinate arrays have correct shape and dtype + for coord in ["vx", "vy", "vz"]: + assert sample[coord].shape == (N_scan,) + assert sample[coord].dtype == FIELD_DTYPE.FLOAT_DATA.value + + def test_Sample_chemical_formula_present( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify chemical_formula field when CHEMICAL_FORMULA log is present""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Add chemical formula to logs - must match number of subruns + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + ws._sample_logs[HidraConstants.CHEMICAL_FORMULA] = ["Fe3O4"] * N_scan + + sample = _Sample.init_group(ws._sample_logs) + + assert "chemical_formula" in sample + # _Sample takes the first value from the log + assert sample["chemical_formula"] == "Fe3O4" + + def test_Sample_chemical_formula_absent( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify chemical_formula defaults to 'unknown' when not in logs""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Ensure chemical formula is not in logs + if HidraConstants.CHEMICAL_FORMULA in ws._sample_logs: + del ws._sample_logs[HidraConstants.CHEMICAL_FORMULA] + + sample = _Sample.init_group(ws._sample_logs) + + assert "chemical_formula" in sample + assert sample["chemical_formula"] == "unknown" + + def test_Sample_temperature_present(self, load_HidraWorkspace: Callable[..., HidraWorkspace]): + """Verify temperature field and units when TEMPERATURE log is present""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Add temperature data to logs with units using tuple syntax + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + temp_values = np.linspace(300, 400, N_scan) + + # Use tuple (key, units) to set value with units + ws._sample_logs[(HidraConstants.TEMPERATURE, "K")] = temp_values + + sample = _Sample.init_group(ws._sample_logs) + + assert "temperature" in sample + assert sample["temperature"].shape == (N_scan,) + assert sample["temperature"].dtype == FIELD_DTYPE.FLOAT_DATA.value + assert sample["temperature"].attrs["units"] == "K" + + def test_Sample_temperature_absent( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify no temperature field when TEMPERATURE log is absent""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Ensure temperature is not in logs + if HidraConstants.TEMPERATURE in ws._sample_logs: + del ws._sample_logs[HidraConstants.TEMPERATURE] + + sample = _Sample.init_group(ws._sample_logs) + + assert "temperature" not in sample + + def test_Sample_stress_field_present( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify stress_field field, shape, and direction attr when present""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Add stress field data to logs + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + stress_values = np.random.randn(N_scan, 3) + + ws._sample_logs[HidraConstants.STRESS_FIELD] = stress_values + # Direction is stored as array with same value for each subrun + ws._sample_logs[HidraConstants.STRESS_FIELD_DIRECTION] = np.array(["z"] * N_scan) + + sample = _Sample.init_group(ws._sample_logs) + + assert "stress_field" in sample + assert sample["stress_field"].shape[0] == N_scan + assert sample["stress_field"].dtype == FIELD_DTYPE.FLOAT_DATA.value + # The direction attribute gets the array value + direction_val = sample["stress_field"].attrs["direction"] + # Using `nexusformat`, for a string attribute it will return a list, check first element + if isinstance(direction_val, list): + assert direction_val[0] == "z" + else: + assert direction_val == "z" + + def test_Sample_stress_field_shape_mismatch(self, load_HidraWorkspace: Callable[..., HidraWorkspace]): + """Verify RuntimeError when stress_field first axis != N_scan""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Add stress field with wrong shape + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + wrong_shape_stress = np.random.randn(N_scan + 5, 3) # Wrong first dimension + + # Set `_data` dict directly, otherwise `SampleLogs.__setitem__` itself will raise an exception. + ws._sample_logs._data[HidraConstants.STRESS_FIELD] = wrong_shape_stress + + with pytest.raises(RuntimeError, match=r".*unexpected shape.*"): + _Sample.init_group(ws._sample_logs) + + def test_Sample_coordinate_shape_mismatch( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify RuntimeError when coordinate array axis != N_scan""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Corrupt vx to have wrong size by directly manipulating the logs + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + + # Create bad coordinate data with wrong length + # Set `_data` dict directly, otherwise `SampleLogs.__setitem__` itself will raise an exception. + ws._sample_logs._data["vx"] = np.zeros(N_scan + 5) # Wrong size + ws._sample_logs._data["vy"] = np.zeros(N_scan + 5) + ws._sample_logs._data["vz"] = np.zeros(N_scan + 5) + + with pytest.raises(RuntimeError, match=r".*unexpected shape.*"): + _Sample.init_group(ws._sample_logs) + + def test_Sample_extra_logs( + self, + load_HidraWorkspace: Callable[..., HidraWorkspace], + ): + """Verify logs not in NXstress_logs go to logs NXcollection with local_name""" + ws = load_HidraWorkspace( + file_name=self.PROJECT_FILE_B, name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Add a custom log with ':' in the name and units using tuple syntax + custom_log_name = "HB2B:CS:CustomValue" + subruns = ws._sample_logs.subruns.raw_copy() + N_scan = len(subruns) + custom_log_value = np.full(N_scan, 42.0) + ws._sample_logs[(custom_log_name, "mm")] = custom_log_value + + sample = _Sample.init_group(ws._sample_logs) + + assert "logs" in sample + assert isinstance(sample["logs"], NXcollection) + + # The ':' should be replaced by '_' + expected_field_name = "HB2B_CS_CustomValue" + assert expected_field_name in sample["logs"] + + # Verify attributes + assert sample["logs"][expected_field_name].attrs["local_name"] == custom_log_name + assert sample["logs"][expected_field_name].attrs["units"] == "mm" + + # The ':' should be replaced by '_' + expected_field_name = "HB2B_CS_CustomValue" + assert expected_field_name in sample["logs"] + + # Verify attributes + assert sample["logs"][expected_field_name].attrs["local_name"] == custom_log_name + assert sample["logs"][expected_field_name].attrs["units"] == "mm" diff --git a/tests/unit/pyrs/utilities/NXstress/test_workspace_read.py b/tests/unit/pyrs/utilities/NXstress/test_workspace_read.py new file mode 100644 index 000000000..bf0d54818 --- /dev/null +++ b/tests/unit/pyrs/utilities/NXstress/test_workspace_read.py @@ -0,0 +1,426 @@ +""" +Tests for NXstress workspace read functionality (Part 2) +""" + +import numpy as np +from nexusformat.nexus import ( + NXsample, + NXinstrument, + NXcollection, + NXfield, + NXdetector, + NXdetector_module, + NXmonochromator, + NXsource, + NXtransformations, +) +from nexusformat.nexus.tree import NeXusError + +from pyrs.dataobjects.constants import HidraConstants +from pyrs.utilities.NXstress.NXstress import NXstress +from pyrs.utilities.NXstress._sample import _Sample +from pyrs.utilities.NXstress._instrument import _Instrument, _Masks +from pyrs.utilities.NXstress._definitions import DEFAULT_TAG, FIELD_DTYPE +from pyrs.utilities.NXstress._peaks import _Peaks + +import pytest + + +@pytest.fixture +def roundtrip_nxstress(load_HidraWorkspace, createPeakCollection, tmp_path): + """Fixture that writes and reads back a workspace with peaks""" + + # Load a workspace with instrument geometry + ws_original = load_HidraWorkspace( + file_name="HB2B_1017_w_mask.h5", name="test_workspace", load_raw_counts=True, load_reduced_diffraction=True + ) + + # Set up instrument geometry if not present + if ws_original._instrument_setup is None: + from pyrs.core.instrument_geometry import DENEXDetectorGeometry, DENEXDetectorShift + + geometry = DENEXDetectorGeometry( + num_rows=512, + num_columns=512, + pixel_size_x=0.001, # 1 mm in meters + pixel_size_y=0.001, # 1 mm in meters + arm_length=2.0, # 2 meters + calibrated=True, + ) + ws_original.set_instrument_geometry(geometry) + + # Set detector shift for calibrated geometry + shift = DENEXDetectorShift( + shift_x=0.01, shift_y=0.02, shift_z=0.03, rotation_x=1.0, rotation_y=2.0, rotation_z=3.0, tth_0=0.5 + ) + ws_original.set_detector_shift(shift) + + # Set wavelength if not present (test data may lack monochromator settings) + if ws_original.get_wavelength(calibrated=True, throw_if_not_set=False) is None: + ws_original.set_wavelength(1.486, calibrated=True) + + # Create 2 PeakCollection objects + subruns = ws_original._sample_logs.subruns.raw_copy() + N_subrun = len(subruns) + + peak1 = createPeakCollection( + peak_tag="Al 111", + peak_profile="Gaussian", + background_type="Linear", + wavelength=1.486, + projectfilename="test.h5", + runnumber=1017, + N_subrun=N_subrun, + ) + + peak2 = createPeakCollection( + peak_tag="Si 220", + peak_profile="Gaussian", + background_type="Linear", + wavelength=1.486, + projectfilename="test.h5", + runnumber=1017, + N_subrun=N_subrun, + ) + + peaks_original = [peak1, peak2] + + # Write to NXstress file + nxstress_file = tmp_path / "test_roundtrip.nxs" + with NXstress(nxstress_file, mode="w") as nxs: + nxs.write(ws_original, peaks_original) + + # Re-open and read back + with NXstress(nxstress_file, mode="r") as nxs: + ws_readback, peaks_readback = nxs.read() + + yield ws_original, peaks_original, ws_readback, peaks_readback + + +class TestWorkspaceRoundtrip: + """Test suite for workspace reading via roundtrip""" + + def test_workspace_roundtrip_sample_logs(self, roundtrip_nxstress): + """Verify sample log names and values match between original and readback""" + ws_original, _, ws_readback, _ = roundtrip_nxstress + + # Verify sample log names match + original_logs = set(ws_original.get_sample_log_names()) + readback_logs = set(ws_readback.get_sample_log_names()) + + # All original logs should be present in readback + assert original_logs.issubset(readback_logs), f"Missing logs: {original_logs - readback_logs}" + + # Verify sample log values for vx, vy, vz + for coord_name in HidraConstants.SAMPLE_COORDINATE_NAMES: + if coord_name in ws_original.get_sample_log_names(): + orig_values = ws_original.get_sample_log_values(coord_name) + read_values = ws_readback.get_sample_log_values(coord_name) + np.testing.assert_allclose( + orig_values, read_values, atol=1e-5, err_msg=f"Mismatch in {coord_name} values" + ) + + # Verify subruns match + assert np.array_equal(ws_original.get_sub_runs().raw_copy(), ws_readback.get_sub_runs().raw_copy()) + + def test_workspace_roundtrip_wavelength(self, roundtrip_nxstress): + """Verify wavelength round-trips correctly""" + ws_original, _, ws_readback, _ = roundtrip_nxstress + + # here `get_wavelength` returns `float | dict[int, float]` + wl_original = ws_original.get_wavelength(calibrated=True, throw_if_not_set=False) + wl_readback = ws_readback.get_wavelength(calibrated=True, throw_if_not_set=False) + + if wl_original is not None: + # `HidraWorkspace` *may* hold its wavelength as a `float`, + # so here we just normalize it over all scan-points. + # Readback from NeXus will always return a non-scalar. + if not isinstance(wl_original, dict): + wl_original = {n: wl_original for n in ws_original.get_sub_runs()} + assert wl_readback is not None + np.testing.assert_allclose(list(wl_original.values()), list(wl_readback.values()), rtol=1e-6) + + def test_workspace_roundtrip_instrument(self, roundtrip_nxstress): + """Verify instrument geometry and detector shift round-trip""" + ws_original, _, ws_readback, _ = roundtrip_nxstress + + geom_original = ws_original.get_instrument_setup() + geom_readback = ws_readback.get_instrument_setup() + + # Verify detector size + assert geom_original.detector_size == geom_readback.detector_size + + # Verify pixel dimensions + orig_px = geom_original.pixel_dimension + read_px = geom_readback.pixel_dimension + np.testing.assert_allclose(orig_px, read_px, rtol=1e-6) + + # Verify arm length + np.testing.assert_allclose(geom_original.arm_length, geom_readback.arm_length, rtol=1e-6) + + # Verify detector shift (if present) + shift_original = ws_original.get_detector_shift() + shift_readback = ws_readback.get_detector_shift() + + if shift_original is not None: + assert shift_readback is not None + np.testing.assert_allclose(shift_original.center_shift_x, shift_readback.center_shift_x, rtol=1e-6) + np.testing.assert_allclose(shift_original.center_shift_y, shift_readback.center_shift_y, rtol=1e-6) + np.testing.assert_allclose(shift_original.center_shift_z, shift_readback.center_shift_z, rtol=1e-6) + else: + assert shift_readback is None + + def test_workspace_roundtrip_masks(self, roundtrip_nxstress): + """Verify masks round-trip correctly""" + ws_original, _, ws_readback, _ = roundtrip_nxstress + + # Verify default mask + default_orig = ws_original.get_detector_mask(is_default=True) + default_read = ws_readback.get_detector_mask(is_default=True) + + if default_orig is not None: + assert default_read is not None + assert np.array_equal(default_orig, default_read) + + # Verify user masks + for mask_id in ws_original._mask_dict.keys(): + mask_orig = ws_original.get_detector_mask(is_default=False, mask_id=mask_id) + mask_read = ws_readback.get_detector_mask(is_default=False, mask_id=mask_id) + assert np.array_equal(mask_orig, mask_read), f"Mask {mask_id} doesn't match" + + def test_workspace_roundtrip_reduced_data(self, roundtrip_nxstress): + """Verify reduced diffraction data round-trips correctly""" + ws_original, _, ws_readback, _ = roundtrip_nxstress + + # Verify 2theta matrix + if ws_original._2theta_matrix is not None: + assert ws_readback._2theta_matrix is not None + assert ws_original._2theta_matrix.shape == ws_readback._2theta_matrix.shape + np.testing.assert_allclose(ws_original._2theta_matrix, ws_readback._2theta_matrix, atol=1e-5) + + # Verify diff_data_set and var_data_set for each mask + for mask_id in ws_original._diff_data_set.keys(): + assert mask_id in ws_readback._diff_data_set, f"Mask {mask_id} missing in readback diff_data_set" + + orig_data = ws_original._diff_data_set[mask_id] + read_data = ws_readback._diff_data_set[mask_id] + assert orig_data.shape == read_data.shape + np.testing.assert_allclose(orig_data, read_data, atol=1e-5) + + for mask_id in ws_original._var_data_set.keys(): + assert mask_id in ws_readback._var_data_set, f"Mask {mask_id} missing in readback var_data_set" + + orig_var = ws_original._var_data_set[mask_id] + read_var = ws_readback._var_data_set[mask_id] + assert orig_var.shape == read_var.shape + np.testing.assert_allclose(orig_var, read_var, atol=1e-5) + + def test_workspace_roundtrip_raw_counts(self, roundtrip_nxstress): + """Verify raw counts round-trip correctly""" + ws_original, _, ws_readback, _ = roundtrip_nxstress + + # Verify all subruns have raw counts + for subrun in ws_original._raw_counts.keys(): + assert subrun in ws_readback._raw_counts, f"Subrun {subrun} missing in readback raw_counts" + + orig_counts = ws_original.get_detector_counts(subrun) + read_counts = ws_readback.get_detector_counts(subrun) + assert orig_counts.shape == read_counts.shape + np.testing.assert_allclose(orig_counts, read_counts, atol=1e-5) + + def test_full_roundtrip(self, roundtrip_nxstress): + """Comprehensive test: verify workspace and peaks together""" + ws_original, peaks_original, ws_readback, peaks_readback = roundtrip_nxstress + + # Verify peak collection count + assert len(peaks_original) == len(peaks_readback) + + # Verify each peak collection + for peak_orig, peak_read in zip(peaks_original, peaks_readback): + # we don't care about small changes to the format (e.g. omitted spaces), we only care + # that they parse to the same `(, h, k, l)` tuples + assert _Peaks._parse_peak_tag(peak_orig.peak_tag) == _Peaks._parse_peak_tag(peak_read.peak_tag) + assert peak_orig.peak_profile == peak_read.peak_profile + assert peak_orig.background_type == peak_read.background_type + + # Verify subruns match + assert np.array_equal(peak_orig._sub_run_array.raw_copy(), peak_read._sub_run_array.raw_copy()) + + +class TestReadErrors: + """Test error handling in read operations""" + + def test_read_nonexistent_entry(self, load_HidraWorkspace, tmp_path): + """Attempt to read non-existent entry → KeyError""" + ws = load_HidraWorkspace( + file_name="HB2B_1017.h5", name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Set up instrument geometry if not present + if ws._instrument_setup is None: + from pyrs.core.instrument_geometry import DENEXDetectorGeometry + + geometry = DENEXDetectorGeometry( + num_rows=512, num_columns=512, pixel_size_x=0.001, pixel_size_y=0.001, arm_length=2.0, calibrated=False + ) + ws.set_instrument_geometry(geometry) + + nxstress_file = tmp_path / "test_nonexistent.nxs" + with NXstress(nxstress_file, mode="w") as nxs: + nxs.write(ws, []) + + with pytest.raises(NeXusError, match=r".*Invalid path.*"): + with NXstress(nxstress_file, mode="r") as nxs: + nxs.read(entry_number=99) + + def test_read_outside_context_manager(self, load_HidraWorkspace, tmp_path): + """Call read() outside context manager → RuntimeError""" + # First create a valid NXstress file + ws = load_HidraWorkspace( + file_name="HB2B_1017.h5", name="test_workspace", load_raw_counts=False, load_reduced_diffraction=True + ) + + # Set up instrument geometry if not present + if ws._instrument_setup is None: + from pyrs.core.instrument_geometry import DENEXDetectorGeometry + + geometry = DENEXDetectorGeometry( + num_rows=512, num_columns=512, pixel_size_x=0.001, pixel_size_y=0.001, arm_length=2.0, calibrated=False + ) + ws.set_instrument_geometry(geometry) + + nxstress_file = tmp_path / "test_outside_context.nxs" + with NXstress(nxstress_file, mode="w") as nxs: + nxs.write(ws, []) + + # Now try to read without context manager + nxs = NXstress(nxstress_file, mode="r") + with pytest.raises(RuntimeError, match="context manager"): + nxs.read() + + +class TestStandaloneMethods: + """Test standalone read methods""" + + def test_sampleLogsFromNexus_standalone(self): + """Build NXsample manually, read with sampleLogsFromNexus""" + # Create NXsample group manually + sample = NXsample() + + scan_points = np.array([1, 2, 3], dtype=np.int32) + sample["scan_point"] = NXfield(scan_points, units="") + + # Add coordinates + sample["vx"] = NXfield(np.array([0.0, 1.0, 2.0], dtype=np.float32), units="mm") + sample["vy"] = NXfield(np.array([0.0, 0.0, 0.0], dtype=np.float32), units="mm") + sample["vz"] = NXfield(np.array([0.0, 0.0, 0.0], dtype=np.float32), units="mm") + + # Add name and formula + sample["name"] = NXfield("TestSample") + sample["chemical_formula"] = NXfield("H2O") + + # Add logs collection + logs_coll = NXcollection() + logs_coll["test_log"] = NXfield(np.array([10.0, 20.0, 30.0]), local_name="test:log:pv", units="V") + sample["logs"] = logs_coll + + # Read back + sample_logs = _Sample.sampleLogsFromNexus(sample) + + # Verify + assert np.array_equal(sample_logs.subruns.raw_copy(), scan_points) + assert "vx" in sample_logs + assert "test:log:pv" in sample_logs + np.testing.assert_array_equal(sample_logs["vx"], np.array([0.0, 1.0, 2.0])) + np.testing.assert_array_equal(sample_logs["test:log:pv"], np.array([10.0, 20.0, 30.0])) + + def test_instrumentFromNexus_standalone(self): + """Build NXinstrument manually, read with instrumentFromNexus""" + # Create NXinstrument group manually + inst = NXinstrument() + inst["name"] = "HB2B" + + # Add source + inst["SOURCE"] = NXsource(type="Reactor Neutron Source", probe="neutron") + + # Add monochromator with wavelength + mono = NXmonochromator() + mono["wavelength"] = NXfield(1.486, units="angstrom") + inst["monochromator"] = mono + + # Add detector + det = NXdetector() + det["type"] = "He_3 PSD" + + # Detector bank + det["detector_bank"] = NXdetector_module( + data_size=NXfield(np.array([512, 512], dtype=np.int64)), + fast_pixel_direction=NXfield(np.array(0.001, dtype=np.float64), units="m"), + slow_pixel_direction=NXfield(np.array(0.001, dtype=np.float64), units="m"), + ) + + # Transformations + trans = NXtransformations() + trans.attrs["calibrated"] = True + trans["distance"] = NXfield(2.0, units="m") + trans["translation_x"] = NXfield(0.01, units="m") + trans["translation_y"] = NXfield(0.02, units="m") + trans["translation_z"] = NXfield(0.03, units="m") + trans["rotation_x"] = NXfield(1.0, units="deg") + trans["rotation_y"] = NXfield(2.0, units="deg") + trans["rotation_z"] = NXfield(3.0, units="deg") + trans["two_theta_zero"] = NXfield(0.5, units="deg") + + det["transformations"] = trans + inst["DETECTOR"] = det + + # Read back + geometry, shift, wavelength = _Instrument.instrumentFromNexus(inst) + + # Verify geometry + assert geometry.detector_size == (512, 512) + np.testing.assert_allclose(geometry.pixel_dimension, (0.001, 0.001)) + np.testing.assert_allclose(geometry.arm_length, 2.0) + + # Verify shift + assert shift is not None + np.testing.assert_allclose(shift.center_shift_x, 0.01) + np.testing.assert_allclose(shift.center_shift_y, 0.02) + np.testing.assert_allclose(shift.center_shift_z, 0.03) + np.testing.assert_allclose(shift.rotation_x, 1.0) + np.testing.assert_allclose(shift.rotation_y, 2.0) + np.testing.assert_allclose(shift.rotation_z, 3.0) + np.testing.assert_allclose(shift.two_theta_0, 0.5) + + # Verify wavelength + np.testing.assert_allclose(wavelength, 1.486) + + def test_masksFromNexus_standalone(self): + """Build masks NXcollection manually, read with masksFromNexus""" + # Create masks collection manually + masks = NXcollection() + + # Add mask names + mask_names = np.array([DEFAULT_TAG, "mask_A", "mask_B"], dtype=FIELD_DTYPE.STRING.value) + masks["names"] = NXfield(mask_names) + + # Add detector masks + det_coll = NXcollection() + det_coll[DEFAULT_TAG] = NXfield(np.ones(100, dtype=bool), units="") + det_coll["mask_A"] = NXfield(np.zeros(100, dtype=bool), units="") + masks["detector"] = det_coll + + # Add solid_angle mask + sa_coll = NXcollection() + sa_coll["mask_B"] = NXfield(np.ones(100, dtype=bool), units="") + masks["solid_angle"] = sa_coll + + # Read back + default_mask, mask_dict = _Masks.masksFromNexus(masks) + + # Verify + assert default_mask is not None + assert len(default_mask) == 100 + assert "mask_A" in mask_dict + assert "mask_B" in mask_dict + assert len(mask_dict) == 2 diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/util/peak_collection_helpers.py b/tests/util/peak_collection_helpers.py new file mode 100644 index 000000000..7a6ba95d9 --- /dev/null +++ b/tests/util/peak_collection_helpers.py @@ -0,0 +1,109 @@ +from collections.abc import Callable, Generator +import numpy as np + +from pyrs.core.peak_profile_utility import get_parameter_dtype +from pyrs.core.workspaces import HidraWorkspace +from pyrs.peaks import FitEngineFactory as PeakFitEngineFactory # type: ignore +from pyrs.peaks.peak_collection import PeakCollection + +import pytest + +RNG = np.random.default_rng(seed=0x923F109B1D944AF5) + + +@pytest.fixture +def createPeakCollection() -> Generator[Callable[..., PeakCollection]]: + # This fixture generates a `PeakCollection` instance initialized using random values. + + def _init( + *, + peak_tag: str, + peak_profile: str, + background_type: str, + wavelength: float, + projectfilename: str, + runnumber: int, + N_subrun: int, + exclude_list=None, + N_counts=1000, # range for random counts + N_span=10000.0, # domain for random axes + error_fraction=0.01, # fractional error for various initializations + ) -> PeakCollection: + peaks = PeakCollection( + peak_tag, + peak_profile, + background_type, + wavelength=wavelength, + projectfilename=projectfilename, + runnumber=runnumber, + ) + + """ + # Grab some random indices from somewhere in the middle of the permutations sequence. + all_runs = [n for n in range(3 * N_subrun)] + subruns = next(islice(permutations((n for n in range(3 * N_subrun)), N_subrun), 2 * N_subrun, 2 * N_subrun + 1)) + """ + # Assume subruns are supposed to be in order. Why would that be the case? + subruns = [n + 1 for n in range(N_subrun)] + + # Ensure that the parameter values are somewhat physically meaningful: + # for example, no negative peak widths or out-of-range mixing fractions. + params = peaks._peak_profile.native_parameters + dtypes = dict(get_parameter_dtype(peaks._peak_profile, peaks._background_type)) + param_values = np.zeros(N_subrun, list(dtypes.items())) + param_errors = np.zeros(N_subrun, list(dtypes.items())) + for param in params: + dtype = dtypes[param] + match param: + case "Height" | "Intensity": + vs = RNG.uniform(0.0, N_counts, size=(N_subrun,)).astype(dtype) + es = RNG.uniform(0.0, error_fraction * N_counts, size=(N_subrun,)).astype(dtype) + case "PeakCentre": + vs = RNG.uniform(0.0, N_span, size=(N_subrun,)).astype(dtype) + es = RNG.uniform(0.0, error_fraction * N_span, size=(N_subrun,)).astype(dtype) + case "Sigma" | "FWHM": + vs = RNG.uniform(0.0, N_span / 10.0, size=(N_subrun,)).astype(dtype) + es = RNG.uniform(0.0, error_fraction * N_span / 10.0, size=(N_subrun,)).astype(dtype) + case "Mixing": + vs = RNG.uniform(0.0, 1.0, size=(N_subrun,)).astype(dtype) + es = RNG.uniform(0.0, error_fraction * 1.0, size=(N_subrun,)).astype(dtype) + case _: + raise RuntimeError(f"`createPeakCollection`: unexpected param '{param}'") + + param_values[param] = vs + param_errors[param] = es + + fit_costs = RNG.uniform(0.0, 100.0, size=(N_subrun,)).astype(dtype) + + peaks.set_peak_fitting_values(subruns, param_values, param_errors, fit_costs, exclude_list) + return peaks + + yield _init + + # teardown follows + pass + + +def generate_PeakCollection_from_workspace(hidra_ws: HidraWorkspace, fit_dic: dict={}) -> list[PeakCollection]: + """ + You can use file tests/data/3393_PWHT-TD.h5 with fit_dic={"0": {"peak_range": [87.599, 91.569], "peak_label": "Peak0", "d0": 1.08}, "1": {"peak_range": [93.544, 95.89], "peak_label": "Peak1", "d0": 1.03}} + """ + fit_result = [] + fit_engine = PeakFitEngineFactory.getInstance(hidraworkspace=hidra_ws, + peak_function_name='PseudoVoigt', + background_function_name='Linear', + wavelength=hidra_ws.get_wavelength(True, True)) + + for peak in fit_dic.keys(): + print('Fitting data') + print('peak_tag: {}'.format(fit_dic[peak]['peak_label'])) + print('x_min: {}'.format(fit_dic[peak]['peak_range'][0])) + print('x_max: {}'.format(fit_dic[peak]['peak_range'][1])) + print('') + + fit_result.append(fit_engine.fit_peaks(peak_tag=fit_dic[peak]['peak_label'], + x_min=fit_dic[peak]['peak_range'][0], + x_max=fit_dic[peak]['peak_range'][1])) + + + return fit_result