From 777d88765be3770b702cdd86fbbb649b8d5fdf32 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Wed, 9 Sep 2026 17:10:11 -0400 Subject: [PATCH 01/33] refactor: collect correction factors into one corrections package --- orgui/app/AGENTS.md | 23 + orgui/app/integration_corrections.py | 149 +-- orgui/app/orGUI.py | 37 +- orgui/app/peak1Dintegr.py | 22 +- .../app/test/test_integration_corrections.py | 7 +- .../test_integration_corrections_dialog.py | 7 +- orgui/datautils/xrayutils/AGENTS.md | 10 + orgui/datautils/xrayutils/beamprofile.py | 1029 +--------------- .../xrayutils/corrections/__init__.py | 73 ++ .../xrayutils/corrections/beamprofile.py | 1049 +++++++++++++++++ .../xrayutils/corrections/detector.py | 89 ++ .../xrayutils/corrections/geometry.py | 219 ++++ .../xrayutils/corrections/normalization.py | 103 ++ orgui/datautils/xrayutils/corrections/roi.py | 93 ++ .../xrayutils/geometrycorrections.py | 201 +--- ...ile.py => test_corrections_beamprofile.py} | 8 +- ...ctions.py => test_corrections_geometry.py} | 4 +- .../test/test_corrections_package.py | 143 +++ orgui/reconstruction_job.py | 34 +- 19 files changed, 1959 insertions(+), 1341 deletions(-) create mode 100644 orgui/datautils/xrayutils/corrections/__init__.py create mode 100644 orgui/datautils/xrayutils/corrections/beamprofile.py create mode 100644 orgui/datautils/xrayutils/corrections/detector.py create mode 100644 orgui/datautils/xrayutils/corrections/geometry.py create mode 100644 orgui/datautils/xrayutils/corrections/normalization.py create mode 100644 orgui/datautils/xrayutils/corrections/roi.py rename orgui/datautils/xrayutils/test/{test_beamprofile.py => test_corrections_beamprofile.py} (98%) rename orgui/datautils/xrayutils/test/{test_geometrycorrections.py => test_corrections_geometry.py} (96%) create mode 100644 orgui/datautils/xrayutils/test/test_corrections_package.py diff --git a/orgui/app/AGENTS.md b/orgui/app/AGENTS.md index 5ffe761..2094e65 100644 --- a/orgui/app/AGENTS.md +++ b/orgui/app/AGENTS.md @@ -12,11 +12,34 @@ This directory contains GUI components and user workflows: - `ReconstructionDialog.py`: GUI front-end for the out-of-core reciprocal-space reconstruction pipeline (`orgui/reconstruction_job.py`, `reconstruction_cli.py`, `reconstruction_cluster.py`). +- `integration_corrections.py`: the adapter between the integration workflow + and `orgui/datautils/xrayutils/corrections/`. It reads which corrections + were switched on and what a loaded scan calls its counters, and assembles + the factor bundle stored beside the intensity. - Dialogs and tests under this directory cover user-facing behavior and GUI regressions. Use the repository root instructions together with this file. +## Corrections Belong In datautils + +`datautils` holds self-consistent physics modules. Never let UI or UI state +code leak into it: no widget, no configuration object, and no scan object may +appear in `orgui/datautils/`. A correction factor is defined once, in +`orgui/datautils/xrayutils/corrections/`, as a function of numbers. + +What belongs on this side of the boundary instead: + +- reading checkbox and dialog state, and turning it into arguments; +- resolving a beamline scan's counter names into values (see also + `orgui/backend/scans.py`, which owns scan-object conventions); +- assembling the named factors into the bundle that is saved. + +When a new correction is needed, add the physics to `corrections/` and call it +from here. Do not compute a factor inline in `orGUI.py` or `peak1Dintegr.py`: +the rocking integration, the stationary integration and the reconstruction +must share one definition or they silently drift onto different scales. + ## GUI And Shared-Code Boundary CLI/headless mode still creates a Qt application and an `orGUI` main-window diff --git a/orgui/app/integration_corrections.py b/orgui/app/integration_corrections.py index ccf90bf..dcd51b2 100644 --- a/orgui/app/integration_corrections.py +++ b/orgui/app/integration_corrections.py @@ -21,7 +21,15 @@ # THE SOFTWARE. # # ###########################################################################*/ -r"""Per-image correction factors of a stationary-scan integration. +r"""Adapter between the integration workflow and the correction physics. + +Every correction factor itself lives in +:mod:`orgui.datautils.xrayutils.corrections`, which takes numbers and returns +numbers and knows nothing about scans, configurations or widgets. What is +left here is the translation in the other direction: turning *which +corrections were switched on* and *what a loaded scan calls its counters* +into arguments for those functions, and assembling the result into the bundle +that is stored beside the integrated intensity. The normalization and numerical active-area factor are intensity divisors: @@ -33,16 +41,14 @@ ``C_flux_on_sample`` is retained as a diagnostic component of ``C_illum_area``. It must not be divided out separately: the numerical active -area already contains the same beam/sample overlap integral. Stationary -integration has no rod-interception factor; rocking scans retain theirs in -:mod:`orgui.app.peak1Dintegr`. +area already contains the same beam/sample overlap integral. Which angular +factors a stationary measurement applies -- and that it has no +rod-interception factor, unlike a rocking scan -- follows the z-axis table of +:mod:`~orgui.datautils.xrayutils.corrections.geometry`. -The geometrical factors come from -:mod:`orgui.datautils.xrayutils.geometrycorrections`, the footprint factors -from :mod:`orgui.datautils.xrayutils.beamprofile`, and the exposure and -monitor normalization mirrors the reciprocal-space reconstruction -(:mod:`orgui.reconstruction_job`), so a stationary integration and a -reconstruction of the same scan are normalized identically. +The exposure and monitor normalization mirrors the reciprocal-space +reconstruction (:mod:`orgui.reconstruction_job`), so a stationary integration +and a reconstruction of the same scan are normalized identically. This module holds no Qt state and reads only public scan attributes, so it is safe in CLI and batch use. @@ -50,7 +56,14 @@ import numpy as np -from ..datautils.xrayutils import geometrycorrections +from ..datautils.xrayutils.corrections import geometry +from ..datautils.xrayutils.corrections.normalization import ( + normalization_divisor as _divisor_from_counters, +) +from ..datautils.xrayutils.corrections.roi import ( # noqa: F401 + CorrectionFactors, + roi_mean_correction, +) __all__ = [ "CorrectionFactors", @@ -63,63 +76,6 @@ ] -class CorrectionFactors(dict): - """Per-image correction divisors, plus the names of what was applied. - - A plain dict of ``name -> array`` with two conveniences: :attr:`applied` - lists the corrections that actually contributed, and :meth:`divisor` - multiplies a chosen subset together. - """ - - def __init__(self, factors, applied): - super().__init__(factors) - #: Names of the corrections that contributed, in application order. - self.applied = tuple(applied) - - def divisor(self, *names): - """Product of the named factors, or 1.0 when none are present. - - :param names: Factor names to multiply. Missing names are skipped, - so a caller can ask for a correction that was not enabled. - :rtype: numpy.ndarray or float - """ - product = 1.0 - for name in names: - if name in self: - product = product * self[name] - return product - - -def roi_mean_correction(correction_sum, pixel_count): - """Mean per-pixel correction over one ROI. - - The ROI summing accumulates the correction array over the same pixels it - sums the counts over, giving ``correction_sum`` and the number of valid - pixels ``pixel_count``. An ROI-summed intensity is corrected by the - *mean* of the correction across those pixels. - - The nominal ROI area must not enter here: the integrated intensity is - already rescaled from the valid pixels to the nominal ROI area when the - background is subtracted. Multiplying by the area a second time scaled - every corrected intensity by the size of its ROI, and because the - projected ROI size varies over the detector, two measurements of one rod - taken on different parts of it were scaled apart. - - :param correction_sum: Summed correction array over the ROI. - :param pixel_count: Number of valid pixels contributing to that sum. - :returns: The mean correction, and 0 where no pixel was valid. - :rtype: numpy.ndarray - """ - correction_sum = np.asarray(correction_sum, dtype=np.float64) - pixel_count = np.asarray(pixel_count, dtype=np.float64) - return np.divide( - correction_sum, - pixel_count, - out=np.zeros_like(correction_sum), - where=pixel_count > 0, - ) - - def monitor_counter_candidates(scan): """Counter names of ``scan`` usable as a monitor normalization. @@ -152,8 +108,11 @@ def monitor_counter_candidates(scan): def normalization_divisor(scan, normalize_exposure, monitor_corrections, size): """Exposure and monitor divisor for every image of a scan. - Mirrors the reciprocal-space reconstruction, which divides each frame by - its exposure time and by every configured monitor counter. + Pulls the counters off the scan object and hands them to + :func:`~orgui.datautils.xrayutils.corrections.normalization.normalization_divisor`, + which owns the arithmetic and the validation. Mirrors the reciprocal-space + reconstruction, which divides each frame by its exposure time and by every + configured monitor counter. :param scan: Scan object providing ``exposure_time`` and the monitor counters by attribute. @@ -167,44 +126,22 @@ def normalization_divisor(scan, normalize_exposure, monitor_corrections, size): :raises ValueError: If a counter is missing, or has a non-positive or non-finite value that would make the normalization undefined. """ - divisor = np.ones(int(size), dtype=np.float64) - applied = [] - + exposure = None if normalize_exposure: + # A scan backend that reports no exposure time is not an error; the + # reconstruction records the normalization as unavailable rather than + # failing the job, and this follows it. exposure = getattr(scan, "exposure_time", None) - if exposure is None: - # Matches the reconstruction, which records the normalization as - # unavailable rather than failing the job. - pass - else: - values = _broadcast(exposure, size, "exposure_time") - if np.any(values <= 0) or not np.all(np.isfinite(values)): - raise ValueError("Exposure time must be finite and positive") - divisor *= values - applied.append("exposure") + monitors = {} for name in monitor_corrections: if not hasattr(scan, name): raise ValueError(f"Active scan has no monitor counter named {name!r}") - values = _broadcast(getattr(scan, name), size, name) - if np.any(values == 0) or not np.all(np.isfinite(values)): - raise ValueError(f"Monitor {name} must be finite and nonzero") - divisor *= values - applied.append(f"monitor:{name}") - - return divisor, applied + monitors[name] = getattr(scan, name) - -def _broadcast(value, size, name): - """Broadcast a scalar or per-image counter to ``size`` values.""" - array = np.atleast_1d(np.asarray(value, dtype=np.float64)).ravel() - if array.size == 1: - return np.full(int(size), array[0], dtype=np.float64) - if array.size != int(size): - raise ValueError( - f"Counter {name!r} has {array.size:d} values for {int(size):d} images" - ) - return array + return _divisor_from_counters( + size, exposure_time=exposure, monitors=monitors + ) def stationary_correction_factors( @@ -224,13 +161,13 @@ def stationary_correction_factors( :param delta: In-plane detector angle per image, in radian. :param gamma: Out-of-plane detector angle per image, in radian. :param bool use_lorentz: Add ``C_Lorentz`` -- the *stationary-mode* - factor :math:`1/\sin\gamma`, not the rocking-scan one. Stationary - integration has no rod-interception factor. + factor :math:`1/\sin\gamma`, not the rocking-scan one. + Stationary integration has no rod-interception factor. :param bool use_footprint: Add the ``C_illum_area`` divisor and its diagnostic numerator ``C_flux_on_sample``. :param beam_profile: A - :class:`~orgui.datautils.xrayutils.beamprofile.BeamProfile`, required - when ``use_footprint`` is set. + :class:`~orgui.datautils.xrayutils.corrections.beamprofile.BeamProfile`, + required when ``use_footprint`` is set. :param float sample_size: Sample size along the beam in meters, required when ``use_footprint`` is set. :param normalization: Optional per-image exposure and monitor divisor @@ -268,7 +205,7 @@ def stationary_correction_factors( if use_lorentz: factors["C_Lorentz"] = np.broadcast_to( - geometrycorrections.lorentz_stationary(gamma), alpha.shape + geometry.lorentz_factor(geometry.STATIONARY, gamma=gamma), alpha.shape ).copy() applied.append("lorentz") diff --git a/orgui/app/orGUI.py b/orgui/app/orGUI.py index 855fe62..6ec5bef 100644 --- a/orgui/app/orGUI.py +++ b/orgui/app/orGUI.py @@ -63,6 +63,7 @@ from . import qutils, ROIutils, autoBraggWorkflow from .QScanSelector import QScanSelector from . import integration_corrections +from ..datautils.xrayutils.corrections import detector as detector_corrections from .QReflectionSelector import QReflectionSelector, QReflectionAnglesDialog from .QUBCalculator import QUBCalculator from .peak1Dintegr import RockingPeakIntegrator @@ -1583,11 +1584,19 @@ def rocking_integrate(self, xylist, rois, hkl_del_gam, refldict, name): or self.scanSelector.usePolarizationBox.isChecked() ) - C_arr = np.ones(dc.detector.shape, dtype=np.float64) - if self.scanSelector.useSolidAngleBox.isChecked(): - C_arr /= dc.solidAngleArray() - if self.scanSelector.usePolarizationBox.isChecked(): - C_arr /= dc.polarizationArray() + # One definition of the per-pixel factors, shared with the rocking + # integration and the reciprocal-space reconstruction. It returns None + # when neither correction is enabled, so that the reconstruction can + # skip its multiplication; here the array of ones is required, because + # the branch below that rebuilds it runs only under HAS_ACCEL and the + # NumPy-only path would otherwise be handed None. + C_arr = detector_corrections.pixel_factors( + dc, + solid_angle=self.scanSelector.useSolidAngleBox.isChecked(), + polarization=self.scanSelector.usePolarizationBox.isChecked(), + ) + if C_arr is None: + C_arr = np.ones(dc.detector.shape, dtype=np.float64) def fill_counters(image, pixelavail, key, bkgkey): """CLI-safe: sum one center ROI and its background ROIs.""" @@ -5745,11 +5754,19 @@ def integrateROI(self): or self.scanSelector.usePolarizationBox.isChecked() ) - C_arr = np.ones(dc.detector.shape, dtype=np.float64) - if self.scanSelector.useSolidAngleBox.isChecked(): - C_arr /= dc.solidAngleArray() - if self.scanSelector.usePolarizationBox.isChecked(): - C_arr /= dc.polarizationArray() + # One definition of the per-pixel factors, shared with the rocking + # integration and the reciprocal-space reconstruction. It returns None + # when neither correction is enabled, so that the reconstruction can + # skip its multiplication; here the array of ones is required, because + # the branch below that rebuilds it runs only under HAS_ACCEL and the + # NumPy-only path would otherwise be handed None. + C_arr = detector_corrections.pixel_factors( + dc, + solid_angle=self.scanSelector.useSolidAngleBox.isChecked(), + polarization=self.scanSelector.usePolarizationBox.isChecked(), + ) + if C_arr is None: + C_arr = np.ones(dc.detector.shape, dtype=np.float64) hkl_del_gam_s1, hkl_del_gam_s2 = self.getROIloc() diff --git a/orgui/app/peak1Dintegr.py b/orgui/app/peak1Dintegr.py index 86a2703..61530c8 100644 --- a/orgui/app/peak1Dintegr.py +++ b/orgui/app/peak1Dintegr.py @@ -57,7 +57,7 @@ from .config_data import ConfigData from .. import resources from .. import logger_utils -from ..datautils.xrayutils import beamprofile, geometrycorrections +from ..datautils.xrayutils.corrections import beamprofile, geometry import numpy as np from scipy import interpolate as interp @@ -1337,19 +1337,19 @@ def integrate(self): # The two take different Lorentz factors from the z-axis table, # and neither is the stationary-scan factor. if curves["axisname"] == "mu": - C_Lor = geometrycorrections.lorentz_factor( - geometrycorrections.REFLECTIVITY_ROCKING, alpha=alpha + C_Lor = geometry.lorentz_factor( + geometry.REFLECTIVITY_ROCKING, alpha=alpha ) elif curves["axisname"] == "th": - C_Lor = geometrycorrections.lorentz_factor( - geometrycorrections.ROCKING, + C_Lor = geometry.lorentz_factor( + geometry.ROCKING, alpha=alpha, delta=delta, gamma=gamma, ) else: raise NotImplementedError() - C_rod = geometrycorrections.rod_interception(gamma) + C_rod = geometry.rod_interception(gamma) else: C_Lor = 1.0 C_rod = 1.0 @@ -2165,7 +2165,7 @@ def _width(label, default): #: Analytical beam shapes, in the order they appear in the dialog. The first #: is the default and reproduces the Gaussian correction orGUI has always -#: applied; see :mod:`orgui.datautils.xrayutils.beamprofile`. +#: applied; see :mod:`orgui.datautils.xrayutils.corrections.beamprofile`. BEAM_SHAPES = ( _BeamShape( "Gaussian", @@ -2214,7 +2214,7 @@ class IntegrationCorrectionsDialog(qt.QDialog): The incident beam is described either by an analytical shape from :data:`BEAM_SHAPES` or by a beam profile measured at the beamline. Both - are evaluated by :mod:`orgui.datautils.xrayutils.beamprofile`, which + are evaluated by :mod:`orgui.datautils.xrayutils.corrections.beamprofile`, which evaluates the illuminated surface integral over the projected sample footprint. The intercepted-flux fraction is available as a diagnostic numerator but is not applied as a second correction. Only a measured @@ -2573,7 +2573,7 @@ def measuredProfile(self): :returns: The tabulated profile, referenced to the sample center chosen in the dialog. - :rtype: orgui.datautils.xrayutils.beamprofile.MeasuredBeamProfile + :rtype: orgui.datautils.xrayutils.corrections.beamprofile.MeasuredBeamProfile :raises ValueError: If no profile file has been loaded. """ if self._profile_z is None: @@ -2592,7 +2592,7 @@ def measuredProfile(self): def analyticalProfile(self): """Return the analytical beam profile described by the dialog. - :rtype: orgui.datautils.xrayutils.beamprofile.BeamProfile + :rtype: orgui.datautils.xrayutils.corrections.beamprofile.BeamProfile :raises ValueError: If the shape rejects the entered parameters. """ shape = self.currentShape() @@ -2610,7 +2610,7 @@ def beamProfile(self): """Return the incident-beam profile selected in the dialog. :returns: An analytical or measured beam profile, in meters. - :rtype: orgui.datautils.xrayutils.beamprofile.BeamProfile + :rtype: orgui.datautils.xrayutils.corrections.beamprofile.BeamProfile :raises ValueError: If the measured profile is selected but no profile file has been loaded, or if the analytical parameters do not describe a usable profile. diff --git a/orgui/app/test/test_integration_corrections.py b/orgui/app/test/test_integration_corrections.py index 752fda9..226bcdb 100644 --- a/orgui/app/test/test_integration_corrections.py +++ b/orgui/app/test/test_integration_corrections.py @@ -11,8 +11,11 @@ import pytest from orgui.app import integration_corrections as ic -from orgui.datautils.xrayutils import geometrycorrections as gc -from orgui.datautils.xrayutils.beamprofile import gaussian_profile, top_hat_profile +from orgui.datautils.xrayutils.corrections import geometry as gc +from orgui.datautils.xrayutils.corrections.beamprofile import ( + gaussian_profile, + top_hat_profile, +) roi_sum = pytest.importorskip("orgui.app._roi_sum_accel") diff --git a/orgui/app/test/test_integration_corrections_dialog.py b/orgui/app/test/test_integration_corrections_dialog.py index c7145bc..c395aff 100644 --- a/orgui/app/test/test_integration_corrections_dialog.py +++ b/orgui/app/test/test_integration_corrections_dialog.py @@ -3,8 +3,9 @@ :class:`orgui.app.peak1Dintegr.IntegrationCorrectionsDialog` owns the user side of the numerical active-area correction: it converts the millimeter and micrometer values shown to the user into the meters -:mod:`orgui.datautils.xrayutils.beamprofile` works in, and decides whether an -integration runs against an analytical beam shape or a measured profile. +:mod:`orgui.datautils.xrayutils.corrections.beamprofile` works in, and +decides whether an integration runs against an analytical beam shape or a +measured profile. These tests pin that boundary. They construct the dialog directly, without showing it, so no user interaction is involved and no plot widget is built. @@ -15,7 +16,7 @@ from silx.gui import qt from orgui.app.peak1Dintegr import BEAM_SHAPES, IntegrationCorrectionsDialog -from orgui.datautils.xrayutils.beamprofile import ( +from orgui.datautils.xrayutils.corrections.beamprofile import ( DistributionBeamProfile, GaussianBeamProfile, MeasuredBeamProfile, diff --git a/orgui/datautils/xrayutils/AGENTS.md b/orgui/datautils/xrayutils/AGENTS.md index e57db94..317c00b 100644 --- a/orgui/datautils/xrayutils/AGENTS.md +++ b/orgui/datautils/xrayutils/AGENTS.md @@ -22,6 +22,16 @@ This directory contains the highest-risk scientific code: and `reconstruction_cluster.py` (outside this directory, no nested `AGENTS.md` of their own) build on it and follow the same conventions as this file. +- `corrections/`: every factor between detector counts and a structure factor + -- `geometry.py` (z-axis Lorentz/rod-interception/area table), + `beamprofile.py`, `activearea.py`, `detector.py` (per-pixel solid angle and + polarization), `normalization.py` (counting time and monitor), `roi.py`, + and `measurement.py` (which factors each scan mode applies, and the + reduction to `|F_hkl|^2` and absolute reflectivity). The rocking + integration, the stationary integration and the reconstruction all correct + their data through this package, so a factor must be defined here once + rather than per caller. `geometrycorrections.py` and `beamprofile.py` at + this level are released aliases that re-export it; do not add code to them. - `cpp/`: native C++ kernels (`CTRcalc_cpp.cpp`, `reciprocal_reconstruction_cpp.cpp`) backing performance-critical CTR and reconstruction paths. diff --git a/orgui/datautils/xrayutils/beamprofile.py b/orgui/datautils/xrayutils/beamprofile.py index abb5937..604c787 100644 --- a/orgui/datautils/xrayutils/beamprofile.py +++ b/orgui/datautils/xrayutils/beamprofile.py @@ -21,55 +21,34 @@ # THE SOFTWARE. # # ###########################################################################*/ -r"""Incident-beam profiles for numerical footprint corrections. +"""Backwards-compatible alias of the beam profiles. -Two related quantities are evaluated at incidence angle :math:`\alpha` on a -sample of length :math:`L` along the beam: +Re-exports :mod:`orgui.datautils.xrayutils.corrections.beamprofile`. -``C_flux_on_sample`` - the fraction of the total incident flux that actually strikes the sample. - This is the overlap integral used below and is stored as a diagnostic; it - is not an additional intensity divisor. - -``C_illum_area`` - the numerical active surface area, referenced to a sample fully bathed in - a beam of the profile's peak intensity. This is the footprint divisor - applied to an integrated intensity. - -Both follow from the normalized vertical beam profile :math:`p(z)`, with -:math:`\int p(z)\,\mathrm{d}z = 1`, where :math:`z` runs perpendicular to the -beam in the scattering plane. With the projected sample size - -.. math:: h(\alpha) = L \sin\alpha - -and the sample centered at :math:`z_0` in the beam, - -.. math:: - - C_\mathrm{flux} = \int_{z_0 - h/2}^{z_0 + h/2} p(z)\,\mathrm{d}z - \qquad - C_\mathrm{area} = \frac{C_\mathrm{flux}}{p_\mathrm{max}\, h} - -:math:`C_\mathrm{area}` is the mean of :math:`p(z)/p_\mathrm{max}` over the -projected sample footprint. It is the one-dimensional form of Vlieg's -numerically illuminated area, so it already contains the overlap integral -:math:`C_\mathrm{flux}`. Multiplying the two would count beam overspill twice. -It tends to 1 when the sample is small compared with a centered beam and falls -off as :math:`1/\sin\alpha` once the beam is fully on the sample. - -For a Gaussian :math:`p` both integrals have the closed form orGUI used before -this module existed, reproduced exactly by :class:`GaussianBeamProfile`. -:class:`MeasuredBeamProfile` evaluates the same two definitions by numerical -integration of a tabulated profile, so an asymmetric or multiply-peaked beam -measured at the beamline can be used instead of the Gaussian idealization. - -All lengths in this module are in **meters**, all angles in **radians**. +The beam profiles moved into the +:mod:`orgui.datautils.xrayutils.corrections` package, which collects every +factor between detector counts and a structure factor. This module was +released under its own name, so it stays importable and re-exports the same +objects. New code should import from +:mod:`orgui.datautils.xrayutils.corrections.beamprofile`. """ -from abc import ABC, abstractmethod - -import numpy as np -from scipy import optimize, special, stats +from .corrections.beamprofile import ( # noqa: F401 + BeamProfile, + DistributionBeamProfile, + GaussianBeamProfile, + MeasuredBeamProfile, + gaussian_profile, + generalized_normal_profile, + profile_from_height_scan, + read_profile_file, + skew_normal_profile, + smoothed_top_hat_profile, + top_hat_profile, + trapezoid_profile, + triangular_profile, + trim_to_illuminated_edge, +) __all__ = [ "BeamProfile", @@ -87,963 +66,3 @@ "triangular_profile", "trim_to_illuminated_edge", ] - -#: Quantile at which a distribution's plotted and searched range is cut. -_TAIL = 1e-6 - -#: Half-width of that range in interquartile ranges, used as a second bound. -#: A heavy-tailed distribution puts its ``_TAIL`` quantile absurdly far out -#: -- a Cauchy profile of 0.3 mm FWHM reaches its at about 40 m -- which -#: would leave the peak unresolved by any practical number of samples. The -#: interquartile range stays finite for every distribution, and 4 of them -#: cover a Gaussian to 5.4 sigma, so the quantile bound still wins for -#: light-tailed and bounded profiles and nothing changes for them. -_RANGE_IQR = 4.0 - -#: Number of samples used to locate maxima and half-maximum crossings. -_SCAN_POINTS = 4001 - -if hasattr(np, "trapezoid"): # ToDo remove for orGUI release >1.5 - _trapz_impl = np.trapezoid # numpy >= 2.0 -else: - _trapz_impl = np.trapz # noqa: NPY201 # numpy < 2.0 - -#: Conversion of a Gaussian FWHM to its standard deviation. -_FWHM_TO_SIGMA = 1.0 / (2.0 * np.sqrt(2.0 * np.log(2.0))) - - -class BeamProfile(ABC): - """Vertical intensity profile of the incident beam. - - Subclasses provide the applied active-area factor and its intercepted-flux - diagnostic for arbitrarily shaped arrays of incidence angles. - """ - - @abstractmethod - def flux_on_sample(self, alpha, L): - """Fraction of the incident flux intercepted by the sample. - - :param alpha: Incidence angle(s) in radian, any array shape. - Expected in ``(0, pi/2]``. - :param float L: Sample size along the beam, in meters. - :returns: ``C_flux_on_sample``, broadcast to the shape of ``alpha``. - :rtype: numpy.ndarray - """ - - @abstractmethod - def illuminated_area_fraction(self, alpha, L): - """Illuminated fraction of the projected sample footprint. - - :param alpha: Incidence angle(s) in radian, any array shape. - :param float L: Sample size along the beam, in meters. - :returns: ``C_illum_area``, broadcast to the shape of ``alpha``. - :rtype: numpy.ndarray - """ - - def corrections(self, alpha, L): - """Return the intercepted flux and numerical active area. - - :param alpha: Incidence angle(s) in radian, any array shape. - :param float L: Sample size along the beam, in meters. - :returns: ``(C_flux_on_sample, C_illum_area)``. Only - ``C_illum_area`` is an integrated-intensity divisor; - ``C_flux_on_sample`` is its diagnostic numerator. - :rtype: tuple - """ - return self.flux_on_sample(alpha, L), self.illuminated_area_fraction(alpha, L) - - @abstractmethod - def profile_curve(self, n=512): - """Sample the profile for display. - - :param int n: Requested number of samples. A tabulated profile - returns its own points and ignores this. - :returns: ``(z, p)`` with ``z`` relative to the sample center in - meters and ``p`` the normalized profile in 1/meter. - :rtype: tuple of numpy.ndarray - """ - - @property - @abstractmethod - def centroid_position(self): - """Center of mass relative to the sample center, in meters. - - ``0.0`` when the sample is centered on the centroid, and ``nan`` - for a profile whose first moment does not converge. - - :rtype: float - """ - - -def _half_maximum_width(z, p, pmax): - """Width between the outermost crossings of ``pmax / 2``. - - Uses the outermost crossings so that a profile with several maxima - reports its full extent rather than the width of a single sub-peak. - - :param z: Sample positions, increasing. - :param p: Profile values at ``z``. - :param float pmax: Peak value the half maximum is taken from. - :returns: The full width at half maximum, in the units of ``z``. - :rtype: float - """ - half = pmax / 2.0 - above = np.flatnonzero(p >= half) - if above.size == 0: - return float("nan") - lo, hi = int(above[0]), int(above[-1]) - if lo == 0: - left = z[lo] - else: - left = np.interp(half, [p[lo - 1], p[lo]], [z[lo - 1], z[lo]]) - if hi == z.size - 1: - right = z[hi] - else: - right = np.interp(half, [p[hi + 1], p[hi]], [z[hi + 1], z[hi]]) - return float(right - left) - - -class _CenteredProfile(BeamProfile): - """Shared centering and correction evaluation for profiles with a CDF. - - Both corrections follow from the cumulative integral of the profile and - from its peak density, so a subclass only has to supply - ``_cumulative(x)`` and ``_density_at(x)`` -- both in coordinates relative - to the sample center -- together with ``_pmax`` and the ``_tiny`` - threshold below which the ``alpha -> 0`` limit is used. - """ - - def _set_center(self, center, offset, centroid, peak_position, median): - """Resolve the requested centering into :attr:`sample_center`. - - :param str center: ``"centroid"``, ``"peak"`` or ``"median"``. - :param float offset: Extra displacement of the sample center. - :param float centroid: Center of mass of the profile. - :param float peak_position: Position of the maximum density. - :param float median: Position at which half the flux has passed. - :raises ValueError: If ``center`` is unknown, or names a reference - point this profile does not have (a heavy-tailed distribution - has no finite centroid). - """ - try: - reference = { - "centroid": centroid, - "peak": peak_position, - "median": median, - }[center] - except KeyError: - raise ValueError( - f"center must be 'centroid', 'peak' or 'median', got {center!r}" - ) from None - if not np.isfinite(reference): - raise ValueError( - f"the {center} of this beam profile is not finite, so the " - "sample cannot be centered on it. Heavy-tailed profiles have " - "no center of mass; center on 'median' or 'peak' instead." - ) - self.centroid = float(centroid) - self.peak_position = float(peak_position) - self.median = float(median) - self.center = center - self.offset = float(offset) - #: Position of the sample center in the profile's own coordinate. - self.sample_center = float(reference) + self.offset - - @property - def centroid_position(self): - """Center of mass relative to the sample center, in meters.""" - return float(self.centroid - self.sample_center) - - def _flux(self, h): - """``C_flux_on_sample`` for a projected sample size ``h``.""" - return self._cumulative(h / 2.0) - self._cumulative(-h / 2.0) - - def flux_on_sample(self, alpha, L): - """Fraction of the incident flux intercepted by the sample. - - Integrates the profile over the projected sample size - ``h = L sin(alpha)``, centered on the sample center. - """ - return self._flux(L * np.sin(np.asarray(alpha, dtype=float))) - - def illuminated_area_fraction(self, alpha, L): - """Illuminated fraction of the projected sample footprint. - - ``C_flux_on_sample / (p_max * h)``, continued to its limit - ``p(0) / p_max`` as ``h -> 0``. - """ - h = L * np.sin(np.asarray(alpha, dtype=float)) - flux = self._flux(h) - limit = self._density_at(0.0) / self._pmax - denom = self._pmax * h - return np.where( - np.abs(h) > self._tiny, - flux / np.where(denom == 0.0, 1.0, denom), - limit, - ) - - -class GaussianBeamProfile(BeamProfile): - """Analytical Gaussian beam profile. - - Reproduces orGUI's original closed-form footprint correction exactly; it - is the reference against which :class:`MeasuredBeamProfile` is validated. - - :param float fwhm: Full width at half maximum of the vertical beam - profile, in meters. - :raises ValueError: If ``fwhm`` is not positive. - """ - - def __init__(self, fwhm): - fwhm = float(fwhm) - if not fwhm > 0: - raise ValueError(f"beam FWHM must be positive, got {fwhm:g}") - self.fwhm = fwhm - self.sigma = fwhm * _FWHM_TO_SIGMA - - def flux_on_sample(self, alpha, L): - """Fraction of the incident flux intercepted by the sample. - - Evaluates ``erf(h / (2 sqrt(2) sigma))`` with ``h = L sin(alpha)``. - """ - arg = ((L * np.sin(alpha)) / (np.sqrt(2) * self.sigma)) * 0.5 - return (1 / 2) * (special.erf(arg) - special.erf(-arg)) - - def illuminated_area_fraction(self, alpha, L): - """Illuminated fraction of the projected sample footprint.""" - return (np.sqrt(2 * np.pi) * self.sigma * self.flux_on_sample(alpha, L)) / ( - L * np.sin(alpha) - ) - - def profile_curve(self, n=512): - """Sample the Gaussian over +- 4 sigma around the sample center.""" - z = np.linspace(-4.0 * self.sigma, 4.0 * self.sigma, int(n)) - return z, np.exp(-0.5 * (z / self.sigma) ** 2) / ( - np.sqrt(2 * np.pi) * self.sigma - ) - - @property - def centroid_position(self): - """A Gaussian is symmetric, so its centroid is the sample center.""" - return 0.0 - - def __repr__(self): - return f"GaussianBeamProfile(fwhm={self.fwhm:g} m)" - - -class MeasuredBeamProfile(_CenteredProfile): - """Tabulated beam profile integrated numerically. - - The profile is treated as piecewise linear between the supplied sample - points and as exactly zero outside their range, so its cumulative - integral -- and with it ``C_flux_on_sample`` -- is evaluated in closed - form on each interval instead of being re-sampled onto an auxiliary grid. - - A measured profile carries no absolute position information, so it is - re-referenced to the point of the beam that the center of the sample is - aligned to; see ``center``. For a symmetric profile every choice - coincides, and the results then reduce to :class:`GaussianBeamProfile`. - - :param z: Positions perpendicular to the beam in the scattering plane, - in meters. Must be strictly monotonic; the direction is preserved - (increasing ``z`` upward, as in a sample height scan). - :param intensity: Beam intensity at ``z``, in arbitrary units. It is - normalized internally, so any scale or monitor normalization is fine. - :param str center: Which point of the profile the center of the sample - sits on: ``"centroid"`` (center of mass, the default), ``"peak"`` - (maximum intensity), or ``"median"`` (the half-cut position an - edge-scan alignment converges to). - :param float offset: Additional displacement of the sample center from - the ``center`` reference, in meters. Positive moves the sample - center toward larger ``z``. - :raises ValueError: If ``z`` is not strictly monotonic, if fewer than - two points are given, if any value is not finite, or if the profile - does not have a positive integral. - """ - - def __init__(self, z, intensity, center="centroid", offset=0.0): - z = np.asarray(z, dtype=float).ravel() - intensity = np.asarray(intensity, dtype=float).ravel() - if z.size != intensity.size: - raise ValueError( - "z and intensity must have the same length, got " - f"{z.size:d} and {intensity.size:d}" - ) - if z.size < 2: - raise ValueError("a beam profile needs at least two points") - if not np.all(np.isfinite(z)) or not np.all(np.isfinite(intensity)): - raise ValueError("beam profile contains non-finite values") - - dz = np.diff(z) - if np.all(dz < 0): - z, intensity = z[::-1], intensity[::-1] - dz = np.diff(z) - if not np.all(dz > 0): - raise ValueError("z must be strictly monotonic") - - norm = _trapz_impl(intensity, z) - if not norm > 0: - raise ValueError( - f"beam profile must have a positive integral, got {norm:g}. A " - "height scan differentiated with the wrong sign gives a " - "negative one." - ) - - p = intensity / norm - # Exact cumulative integral of the piecewise-linear profile at the - # sample points; interior positions are handled in _cumulative(). - cum = np.concatenate(([0.0], np.cumsum(0.5 * (p[:-1] + p[1:]) * dz))) - cum[-1] = 1.0 - - self._z_raw = z - self._p = p - self._cum = cum - self._pmax = float(p.max()) - # Guard for the alpha -> 0 limit, scaled to the profile's support so - # that it stays meaningful whatever length scale the caller works on. - self._tiny = 1e-9 * float(z[-1] - z[0]) - - centroid = float(_trapz_impl(z * p, z)) - self._set_center( - center, - offset, - centroid, - float(z[np.argmax(p)]), - float(np.interp(0.5, cum, z)), - ) - self.rms_width = float( - np.sqrt(max(float(_trapz_impl((z - centroid) ** 2 * p, z)), 0.0)) - ) - - def profile_curve(self, n=512): - """Return the tabulated points; ``n`` is ignored.""" - return self.z, self._p - - @property - def z(self): - """Profile positions relative to the sample center, in meters.""" - return self._z_raw - self.sample_center - - @property - def density(self): - """Normalized profile ``p(z)`` in 1/meter, matching :attr:`z`.""" - return self._p - - @property - def support(self): - """``(z_min, z_max)`` of the profile relative to the sample center.""" - return ( - float(self._z_raw[0] - self.sample_center), - float(self._z_raw[-1] - self.sample_center), - ) - - @property - def fwhm(self): - """Full width at half maximum of the profile, in meters. - - Measured between the outermost crossings of half the peak value, so - a profile with several maxima reports its full extent rather than - the width of a single sub-peak. - """ - return _half_maximum_width(self._z_raw, self._p, self._pmax) - - def _density_at(self, x): - """Normalized profile at ``x``, relative to the sample center.""" - return np.interp( - np.asarray(x, dtype=float) + self.sample_center, self._z_raw, self._p - ) - - def _cumulative(self, x): - """Integral of the normalized profile from ``-inf`` up to ``x``. - - ``x`` is given relative to the sample center. Outside the tabulated - range the profile is taken to be zero, so the result saturates at 0 - and 1. - """ - z, p, cum = self._z_raw, self._p, self._cum - x = np.clip(np.asarray(x, dtype=float) + self.sample_center, z[0], z[-1]) - i = np.clip(np.searchsorted(z, x, side="right") - 1, 0, z.size - 2) - d = z[i + 1] - z[i] - t = (x - z[i]) / d - # Exact integral of one linear segment over [z[i], x]. - return cum[i] + d * t * (p[i] + 0.5 * t * (p[i + 1] - p[i])) - - def __repr__(self): - return ( - f"MeasuredBeamProfile({self._z_raw.size:d} points, " - f"fwhm={self.fwhm:g} m, center={self.center!r})" - ) - - -class DistributionBeamProfile(_CenteredProfile): - """Beam profile given by an analytical probability distribution. - - Both corrections need only the cumulative distribution and the peak - density, so any continuous distribution with a ``cdf``, a ``pdf`` and a - ``ppf`` can describe the beam -- in particular any frozen - :mod:`scipy.stats` distribution. The named constructors in this module - (:func:`top_hat_profile`, :func:`trapezoid_profile`, - :func:`smoothed_top_hat_profile`, :func:`generalized_normal_profile`, - :func:`skew_normal_profile`) wrap the shapes that describe real beams, - in physical parameters rather than raw distribution arguments. - - :class:`GaussianBeamProfile` is the same model evaluated in closed form; - passing ``scipy.stats.norm`` here reproduces it to machine precision. - - ``scipy.stats`` exposes no mode, so the peak is located once at - construction by scanning the distribution's central range and refining - the best sample. A plateau, as in a top hat, resolves to its midpoint. - - :param dist: A frozen continuous distribution, e.g. - ``scipy.stats.norm(scale=1e-5)``. - :param str center: Which point of the profile the center of the sample - sits on: ``"centroid"`` (mean, the default), ``"peak"`` (mode) or - ``"median"``. All three coincide for a symmetric distribution. - :param float offset: Additional displacement of the sample center from - the ``center`` reference, in meters. - :raises TypeError: If ``dist`` is not a frozen distribution. - :raises ValueError: If the distribution has no finite central range, or - no finite positive maximum density -- a beam profile with an - unbounded peak has no active-area reference and cannot be used. - """ - - def __init__(self, dist, center="centroid", offset=0.0): - for name in ("cdf", "pdf", "ppf"): - if not callable(getattr(dist, name, None)): - raise TypeError( - "dist must be a frozen continuous distribution with cdf, " - f"pdf and ppf methods, got {dist!r}. Freeze a scipy.stats " - "distribution first, e.g. scipy.stats.norm(scale=1e-5)." - ) - if isinstance(dist, stats.rv_continuous): - # An unfrozen distribution answers every call with its standard - # form, which here would silently describe a beam one meter wide. - raise TypeError( - f"dist must be frozen, got the distribution family {dist!r}. " - "Supply its parameters, e.g. scipy.stats.norm(scale=1e-5) " - "rather than scipy.stats.norm." - ) - lo, hi = self._central_range(dist) - - self._dist = dist - self._lo = lo - self._hi = hi - peak_position, pmax = self._locate_peak(dist, lo, hi) - if not np.isfinite(pmax) or pmax <= 0: - raise ValueError( - f"the distribution has no finite positive maximum density " - f"(got {pmax:g}); the illuminated-area correction has no " - "reference intensity for such a profile." - ) - self._reject_unbounded_density(dist, pmax) - self._pmax = pmax - self._tiny = 1e-9 * (hi - lo) - - self._set_center( - center, offset, self._finite_moment(dist.mean), peak_position, dist.ppf(0.5) - ) - self.rms_width = self._finite_moment(dist.std) - - @staticmethod - def _central_range(dist): - """Range over which the profile is scanned and displayed. - - The narrower of the ``_TAIL`` quantile range and ``_RANGE_IQR`` - interquartile ranges around the median. This bounds only where the - peak is looked for, the width is measured and the preview is drawn; - the corrections always integrate the full distribution, so a - heavy-tailed profile still reports the flux that misses the sample. - - :returns: ``(lo, hi)``. - :rtype: tuple of float - :raises ValueError: If the distribution is not localized. - """ - lo = float(dist.ppf(_TAIL)) - hi = float(dist.ppf(1.0 - _TAIL)) - median = float(dist.ppf(0.5)) - iqr = float(dist.ppf(0.75)) - float(dist.ppf(0.25)) - if np.isfinite(iqr) and iqr > 0: - lo = max(lo, median - _RANGE_IQR * iqr) - hi = min(hi, median + _RANGE_IQR * iqr) - if not (np.isfinite(lo) and np.isfinite(hi) and hi > lo): - raise ValueError( - f"the distribution has no finite central range, got {lo:g} to " - f"{hi:g}. A beam profile must be localized." - ) - return lo, hi - - @staticmethod - def _reject_unbounded_density(dist, pmax): - """Refuse a distribution whose density diverges at an end. - - The peak is searched between finite quantiles, so a density that - runs to infinity -- as a gamma distribution with shape below one - does at zero -- still yields a large but finite maximum there, and - that value would depend on nothing but the chosen cut. Probing a - much deeper quantile catches it: for a bounded density the value - stops growing, for a divergent one it does not. - - :raises ValueError: If the density is still growing at either end. - """ - for quantile in (_TAIL * 1e-3, 1.0 - _TAIL * 1e-3): - edge = float(dist.ppf(quantile)) - if not np.isfinite(edge): - continue - density = float(dist.pdf(edge)) - if not np.isfinite(density) or density > pmax * 1.01: - raise ValueError( - "the density of this distribution diverges, so its " - "maximum depends only on where the tail is cut and the " - "illuminated-area correction has no reference intensity. " - "Use a distribution with a bounded peak." - ) - - @staticmethod - def _finite_moment(func): - """Evaluate a distribution moment, returning ``nan`` if it diverges.""" - try: - value = float(func()) - except (ValueError, TypeError, ZeroDivisionError): - return float("nan") - return value if np.isfinite(value) else float("nan") - - @staticmethod - def _locate_peak(dist, lo, hi): - """Find the position and value of the maximum density. - - Scans the central range first so that a plateau or a second maximum - cannot be missed, then refines a single interior maximum. The - plateau of a top hat resolves to its midpoint. - - :returns: ``(peak_position, peak_density)``. - :rtype: tuple of float - """ - z = np.linspace(lo, hi, _SCAN_POINTS) - p = np.asarray(dist.pdf(z), dtype=float) - p = np.where(np.isfinite(p), p, -np.inf) - pmax = float(p.max()) - if not np.isfinite(pmax) or pmax <= 0: - return float("nan"), pmax - # A flat top has no single argmax; take the middle of the plateau. - flat = np.flatnonzero(p >= pmax * (1.0 - 1e-12)) - if flat.size > 1: - return float(0.5 * (z[flat[0]] + z[flat[-1]])), pmax - best = int(flat[0]) - if best == 0 or best == z.size - 1: - return float(z[best]), pmax - refined = optimize.minimize_scalar( - lambda x: -float(dist.pdf(x)), - bounds=(z[best - 1], z[best + 1]), - method="bounded", - options={"xatol": (hi - lo) * 1e-12}, - ) - position = float(refined.x) - density = float(dist.pdf(position)) - return (position, density) if density >= pmax else (float(z[best]), pmax) - - def _cumulative(self, x): - """Integral of the profile up to ``x``, relative to the sample center.""" - return self._dist.cdf(np.asarray(x, dtype=float) + self.sample_center) - - def _density_at(self, x): - """Profile density at ``x``, relative to the sample center.""" - return self._dist.pdf(np.asarray(x, dtype=float) + self.sample_center) - - def profile_curve(self, n=512): - """Sample the distribution over its central range.""" - z = np.linspace(self._lo, self._hi, int(n)) - return z - self.sample_center, np.asarray(self._dist.pdf(z), dtype=float) - - @property - def support(self): - """``(z_min, z_max)`` of the plotted range, relative to the center.""" - return ( - float(self._lo - self.sample_center), - float(self._hi - self.sample_center), - ) - - @property - def fwhm(self): - """Full width at half maximum of the distribution, in meters.""" - z = np.linspace(self._lo, self._hi, _SCAN_POINTS) - return _half_maximum_width( - z, np.asarray(self._dist.pdf(z), dtype=float), self._pmax - ) - - def __repr__(self): - return ( - f"DistributionBeamProfile({self._dist!r}, " - f"fwhm={self.fwhm:g} m, center={self.center!r})" - ) - - -class _SmoothedTopHat: - """Top hat of full ``width`` convolved with a Gaussian of ``sigma``. - - The frozen-distribution interface :class:`DistributionBeamProfile` - needs, implemented in closed form. This is the beam a pair of defining - slits produces once the finite source size blurs the slit edges, so the - profile is flat in the middle with error-function flanks. - - :param float width: Full width of the top hat, in meters. - :param float sigma: Standard deviation of the edge blur, in meters. - """ - - def __init__(self, width, sigma): - width = float(width) - sigma = float(sigma) - if not width > 0: - raise ValueError(f"width must be positive, got {width:g}") - if not sigma > 0: - raise ValueError(f"edge sigma must be positive, got {sigma:g}") - self.width = width - self.sigma = sigma - self._a = -0.5 * width - self._b = 0.5 * width - - def pdf(self, x): - """Density: the difference of two shifted normal CDFs.""" - x = np.asarray(x, dtype=float) - return ( - special.ndtr((x - self._a) / self.sigma) - - special.ndtr((x - self._b) / self.sigma) - ) / self.width - - def cdf(self, x): - """Cumulative distribution, the antiderivative of :meth:`pdf`.""" - x = np.asarray(x, dtype=float) - - def term(edge): - u = (x - edge) / self.sigma - return (x - edge) * special.ndtr(u) + self.sigma * np.exp( - -0.5 * u**2 - ) / np.sqrt(2 * np.pi) - - return (term(self._a) - term(self._b)) / self.width - - def ppf(self, q): - """Quantile function, inverted numerically from :meth:`cdf`.""" - q = float(q) - if q <= 0.0 or q >= 1.0: - raise ValueError(f"quantile must be in (0, 1), got {q:g}") - span = 0.5 * self.width + 12.0 * self.sigma - return float(optimize.brentq(lambda x: self.cdf(x) - q, -span, span)) - - def mean(self): - """The profile is symmetric about zero.""" - return 0.0 - - def std(self): - """Quadrature sum of the top-hat and blur widths.""" - return float(np.sqrt(self.width**2 / 12.0 + self.sigma**2)) - - def __repr__(self): - return f"_SmoothedTopHat(width={self.width:g}, sigma={self.sigma:g})" - - -def gaussian_profile(fwhm, center="centroid", offset=0.0): - """Gaussian beam profile that supports centering and an offset. - - Numerically the same model as :class:`GaussianBeamProfile`, which - evaluates it in closed form but always sits centered on the sample. Use - this one when the sample is displaced from the beam center. - - :param float fwhm: Full width at half maximum, in meters. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - :raises ValueError: If ``fwhm`` is not positive. - """ - fwhm = float(fwhm) - if not fwhm > 0: - raise ValueError(f"fwhm must be positive, got {fwhm:g}") - return DistributionBeamProfile( - stats.norm(loc=0.0, scale=fwhm * _FWHM_TO_SIGMA), center=center, offset=offset - ) - - -def top_hat_profile(width, center="centroid", offset=0.0): - """Beam profile of uniform intensity over ``width``. - - The slit-limited beam. With this profile ``C_flux_on_sample`` is - ``L sin(alpha) / width`` until the sample intercepts the whole beam, - the linear correction of Gibaud, Vignaud & Sinha (1993), - *Acta Cryst.* A49, 642, equation (12), and ``C_illum_area`` stays at 1 - over the same range. - - :param float width: Full width of the beam, in meters. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - :raises ValueError: If ``width`` is not positive. - """ - width = float(width) - if not width > 0: - raise ValueError(f"width must be positive, got {width:g}") - return DistributionBeamProfile( - stats.uniform(loc=-0.5 * width, scale=width), center=center, offset=offset - ) - - -def trapezoid_profile(full_width, flat_width, center="centroid", offset=0.0): - """Beam profile of a trapezoid with symmetric flanks. - - Two slits of different apertures convolve to a trapezoid whose flat top - is the smaller aperture and whose base is the larger one; matched slits - give the triangle of :func:`triangular_profile`. - - :param float full_width: Width at the base, in meters. - :param float flat_width: Width of the flat top, in meters, between 0 and - ``full_width``. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - :raises ValueError: If the widths are not positive and ordered. - """ - full_width = float(full_width) - flat_width = float(flat_width) - if not full_width > 0: - raise ValueError(f"full_width must be positive, got {full_width:g}") - if not 0.0 <= flat_width <= full_width: - raise ValueError( - f"flat_width must be between 0 and full_width, got {flat_width:g} " - f"with full_width {full_width:g}" - ) - ramp = 0.5 * (1.0 - flat_width / full_width) - return DistributionBeamProfile( - stats.trapezoid(ramp, 1.0 - ramp, loc=-0.5 * full_width, scale=full_width), - center=center, - offset=offset, - ) - - -def triangular_profile(full_width, center="centroid", offset=0.0): - """Beam profile of a symmetric triangle, from two matched slits. - - :param float full_width: Width at the base, in meters. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - """ - return trapezoid_profile(full_width, 0.0, center=center, offset=offset) - - -def smoothed_top_hat_profile(width, edge_sigma, center="centroid", offset=0.0): - """Beam profile of a top hat with error-function flanks. - - A slit-defined beam whose edges are blurred by the finite source size: - flat in the middle, with flanks of standard deviation ``edge_sigma``. - - :param float width: Full width of the flat part before blurring, in - meters. - :param float edge_sigma: Standard deviation of the edge blur, in meters. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - """ - return DistributionBeamProfile( - _SmoothedTopHat(width, edge_sigma), center=center, offset=offset - ) - - -def generalized_normal_profile(fwhm, flatness, center="centroid", offset=0.0): - """Beam profile interpolating between a peaked, Gaussian and flat beam. - - The generalized normal density is proportional to - ``exp(-|z / scale| ** flatness)``: ``flatness = 1`` gives an exponential - cusp, ``flatness = 2`` is exactly Gaussian, and large values approach a - top hat. It is the one-parameter family for a focused beam that is - neither Gaussian nor flat. - - :param float fwhm: Full width at half maximum, in meters. - :param float flatness: Shape exponent, positive. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - :raises ValueError: If ``fwhm`` or ``flatness`` is not positive. - """ - fwhm = float(fwhm) - flatness = float(flatness) - if not fwhm > 0: - raise ValueError(f"fwhm must be positive, got {fwhm:g}") - if not flatness > 0: - raise ValueError(f"flatness must be positive, got {flatness:g}") - # exp(-(w/2 / scale)**flatness) = 1/2 at the half maximum. - scale = 0.5 * fwhm / np.log(2.0) ** (1.0 / flatness) - return DistributionBeamProfile( - stats.gennorm(flatness, loc=0.0, scale=scale), center=center, offset=offset - ) - - -def skew_normal_profile(fwhm, skew, center="centroid", offset=0.0): - """Asymmetric beam profile with a Gaussian core. - - The skew-normal density, scaled so that its full width at half maximum - is ``fwhm``. ``skew = 0`` is Gaussian; positive values put the tail - toward larger ``z``. - - :param float fwhm: Full width at half maximum, in meters. - :param float skew: Shape parameter; 0 is symmetric. - :param str center: Centering, see :class:`DistributionBeamProfile`. - :param float offset: Sample-center displacement, in meters. - :rtype: DistributionBeamProfile - :raises ValueError: If ``fwhm`` is not positive. - """ - fwhm = float(fwhm) - if not fwhm > 0: - raise ValueError(f"fwhm must be positive, got {fwhm:g}") - skew = float(skew) - # The skew normal has no closed-form FWHM; measure it once at unit - # scale and rescale, since the family is a location-scale family. - unit = stats.skewnorm(skew) - z = np.linspace(unit.ppf(_TAIL), unit.ppf(1.0 - _TAIL), _SCAN_POINTS) - p = unit.pdf(z) - unit_fwhm = _half_maximum_width(z, p, float(p.max())) - return DistributionBeamProfile( - stats.skewnorm(skew, loc=0.0, scale=fwhm / unit_fwhm), - center=center, - offset=offset, - ) - - -def profile_from_height_scan(z, intensity, monitor=None): - r"""Differentiate a sample height scan into a beam profile. - - In a height (``samz``) scan the sample edge cuts progressively further - into the beam, so the transmitted intensity is the beam profile - integrated over the part of the beam still passing the sample, - :math:`I(z) = I_0 \int_z^\infty p(z')\,\mathrm{d}z'`. The profile is - therefore the *negated* derivative of the measured curve, - - .. math:: p(z) \propto -\frac{\mathrm{d}I}{\mathrm{d}z} - - which also removes any constant transmission offset. ``z`` keeps its - direction, so the returned profile is the beam profile in the same - (upward) coordinate as the scan. - - Only the range over which the edge cuts into the beam is meaningful. A - scan that continues until the sample leaves the beam again has a - negative derivative there and must be sliced before use, otherwise the - result is a profile followed by its mirror image. - - :param z: Height positions of the scan, in meters, strictly monotonic. - :param intensity: Transmitted intensity at each ``z``. - :param monitor: Optional incident-beam monitor at each ``z``. When - given, ``intensity`` is divided by it before differentiation, which - removes storage-ring current drift over the scan. The mean monitor - value is multiplied back in, so the profile keeps the scale of the - raw counts. - :returns: ``(z, profile)``, the profile in arbitrary units and of the - same length as ``z``. - :rtype: tuple of numpy.ndarray - :raises ValueError: If the inputs have different lengths, if fewer than - two points are given, or if ``monitor`` contains zeros. - """ - z = np.asarray(z, dtype=float).ravel() - intensity = np.asarray(intensity, dtype=float).ravel() - if z.size != intensity.size: - raise ValueError( - "z and intensity must have the same length, got " - f"{z.size:d} and {intensity.size:d}" - ) - if z.size < 2: - raise ValueError("a height scan needs at least two points") - if monitor is not None: - monitor = np.asarray(monitor, dtype=float).ravel() - if monitor.size != z.size: - raise ValueError( - "monitor must have the same length as z, got " - f"{monitor.size:d} and {z.size:d}" - ) - if np.any(monitor == 0): - raise ValueError("monitor contains zeros") - intensity = intensity / monitor * float(np.mean(monitor)) - return z, -np.gradient(intensity, z) - - -def trim_to_illuminated_edge(z, profile): - """Restrict a differentiated height scan to its single cutting edge. - - A height scan long enough to move the sample fully through the beam - contains two edges: the sample cutting in, whose negated derivative is - the beam profile, and -- if the beam clears the sample again -- the - sample leaving, which appears as a negative excursion. Only the first is - a beam profile. - - The returned slice is the run of samples around the maximum over which - the profile stays positive, i.e. it is cut at the sign changes flanking - the peak. A profile that is positive everywhere is returned unchanged. - - :param z: Positions, strictly monotonic and increasing. - :param profile: Profile values at ``z``, as returned by - :func:`profile_from_height_scan`. - :returns: ``(z, profile)`` restricted to the edge. - :rtype: tuple of numpy.ndarray - :raises ValueError: If no positive sample exists at all, which means the - scan was differentiated with the wrong sign. - """ - z = np.asarray(z, dtype=float).ravel() - profile = np.asarray(profile, dtype=float).ravel() - if z.size != profile.size: - raise ValueError( - "z and profile must have the same length, got " - f"{z.size:d} and {profile.size:d}" - ) - if not np.any(profile > 0): - raise ValueError( - "the differentiated height scan has no positive values; it is " - "most likely differentiated with the wrong sign" - ) - peak = int(np.argmax(profile)) - nonpositive = np.flatnonzero(profile <= 0) - before = nonpositive[nonpositive < peak] - after = nonpositive[nonpositive > peak] - start = int(before[-1]) + 1 if before.size else 0 - stop = int(after[0]) if after.size else z.size - return z[start:stop], profile[start:stop] - - -def read_profile_file( - path, - z_scale=1e-3, - height_scan=False, - usecols=(0, 1), - monitor_col=None, - trim=True, -): - """Read a two-column text file into a beam profile. - - The file is read with :func:`numpy.loadtxt`, so ``#`` comments and any - common whitespace separator are accepted. - - :param str path: File to read. - :param float z_scale: Factor converting the file's position column to - meters. The default ``1e-3`` reads millimeters, the usual unit of a - diffractometer height scan; use ``1e-6`` for micrometers. - :param bool height_scan: If ``True``, the intensity column is a measured - height scan and is differentiated by - :func:`profile_from_height_scan`. If ``False`` (default), it already - is a beam profile. - :param usecols: ``(position, intensity)`` column indices. - :param monitor_col: Optional column index of a monitor counter, only - used together with ``height_scan``. - :param bool trim: Restrict a differentiated height scan to its cutting - edge with :func:`trim_to_illuminated_edge`. Ignored unless - ``height_scan`` is set. - :returns: ``(z, profile)`` with ``z`` in meters. - :rtype: tuple of numpy.ndarray - """ - cols = tuple(usecols) if monitor_col is None else tuple(usecols) + (monitor_col,) - data = np.loadtxt(path, usecols=cols, unpack=True, ndmin=2) - z = np.asarray(data[0], dtype=float) * float(z_scale) - intensity = np.asarray(data[1], dtype=float) - monitor = np.asarray(data[2], dtype=float) if monitor_col is not None else None - if not height_scan: - return z, intensity - if z.size > 1 and z[1] < z[0]: # keep the edge search on an increasing axis - z, intensity = z[::-1], intensity[::-1] - monitor = None if monitor is None else monitor[::-1] - z, profile = profile_from_height_scan(z, intensity, monitor) - if trim: - z, profile = trim_to_illuminated_edge(z, profile) - return z, profile diff --git a/orgui/datautils/xrayutils/corrections/__init__.py b/orgui/datautils/xrayutils/corrections/__init__.py new file mode 100644 index 0000000..4e6e6b6 --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/__init__.py @@ -0,0 +1,73 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""Everything that turns detector counts into a structure factor. + +One home for the correction factors of E. Vlieg, *J. Appl. Cryst.* **30** +(1997) 532, in the two-dimensional-detector form of J. Drnec *et al.*, +*J. Appl. Cryst.* **47** (2014) 365, so that a rocking scan, a stationary +area-detector measurement and a reciprocal-space reconstruction of the same +sample are corrected by the same code rather than by three copies of it. + +The split between the modules is by *what a factor depends on*, which is also +what makes each of them testable on its own: + +:mod:`~.geometry` + Diffractometer angles only. The z-axis table of the ANA/ROD manual: + Lorentz factors, rod interception, the geometric area factor. +:mod:`~.beamprofile` + The vertical profile of the incident beam and its integrals over a + finite sample. +:mod:`~.detector` + Per-pixel factors of a detector image: solid angle and polarization. +:mod:`~.normalization` + Counting time and monitor. +:mod:`~.roi` + Reducing per-pixel factors onto a summed region of interest. + +Everything here is physics: arrays and scalars in, arrays out. Nothing in +this package reads a scan object, a configuration file or a GUI widget. +Resolving *which* corrections an experiment asked for, and pulling counters +out of a beamline scan, belongs to :mod:`orgui.backend.scans` and to the +application layer. + +For backwards compatibility ``orgui.datautils.xrayutils.geometrycorrections`` +and ``orgui.datautils.xrayutils.beamprofile`` remain importable and re-export +:mod:`~.geometry` and :mod:`~.beamprofile` unchanged. +""" + +from . import ( # noqa: F401 + beamprofile, + detector, + geometry, + normalization, + roi, +) + +__all__ = [ + "beamprofile", + "detector", + "geometry", + "normalization", + "roi", +] diff --git a/orgui/datautils/xrayutils/corrections/beamprofile.py b/orgui/datautils/xrayutils/corrections/beamprofile.py new file mode 100644 index 0000000..abb5937 --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/beamprofile.py @@ -0,0 +1,1049 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""Incident-beam profiles for numerical footprint corrections. + +Two related quantities are evaluated at incidence angle :math:`\alpha` on a +sample of length :math:`L` along the beam: + +``C_flux_on_sample`` + the fraction of the total incident flux that actually strikes the sample. + This is the overlap integral used below and is stored as a diagnostic; it + is not an additional intensity divisor. + +``C_illum_area`` + the numerical active surface area, referenced to a sample fully bathed in + a beam of the profile's peak intensity. This is the footprint divisor + applied to an integrated intensity. + +Both follow from the normalized vertical beam profile :math:`p(z)`, with +:math:`\int p(z)\,\mathrm{d}z = 1`, where :math:`z` runs perpendicular to the +beam in the scattering plane. With the projected sample size + +.. math:: h(\alpha) = L \sin\alpha + +and the sample centered at :math:`z_0` in the beam, + +.. math:: + + C_\mathrm{flux} = \int_{z_0 - h/2}^{z_0 + h/2} p(z)\,\mathrm{d}z + \qquad + C_\mathrm{area} = \frac{C_\mathrm{flux}}{p_\mathrm{max}\, h} + +:math:`C_\mathrm{area}` is the mean of :math:`p(z)/p_\mathrm{max}` over the +projected sample footprint. It is the one-dimensional form of Vlieg's +numerically illuminated area, so it already contains the overlap integral +:math:`C_\mathrm{flux}`. Multiplying the two would count beam overspill twice. +It tends to 1 when the sample is small compared with a centered beam and falls +off as :math:`1/\sin\alpha` once the beam is fully on the sample. + +For a Gaussian :math:`p` both integrals have the closed form orGUI used before +this module existed, reproduced exactly by :class:`GaussianBeamProfile`. +:class:`MeasuredBeamProfile` evaluates the same two definitions by numerical +integration of a tabulated profile, so an asymmetric or multiply-peaked beam +measured at the beamline can be used instead of the Gaussian idealization. + +All lengths in this module are in **meters**, all angles in **radians**. +""" + +from abc import ABC, abstractmethod + +import numpy as np +from scipy import optimize, special, stats + +__all__ = [ + "BeamProfile", + "DistributionBeamProfile", + "GaussianBeamProfile", + "MeasuredBeamProfile", + "gaussian_profile", + "generalized_normal_profile", + "profile_from_height_scan", + "read_profile_file", + "skew_normal_profile", + "smoothed_top_hat_profile", + "top_hat_profile", + "trapezoid_profile", + "triangular_profile", + "trim_to_illuminated_edge", +] + +#: Quantile at which a distribution's plotted and searched range is cut. +_TAIL = 1e-6 + +#: Half-width of that range in interquartile ranges, used as a second bound. +#: A heavy-tailed distribution puts its ``_TAIL`` quantile absurdly far out +#: -- a Cauchy profile of 0.3 mm FWHM reaches its at about 40 m -- which +#: would leave the peak unresolved by any practical number of samples. The +#: interquartile range stays finite for every distribution, and 4 of them +#: cover a Gaussian to 5.4 sigma, so the quantile bound still wins for +#: light-tailed and bounded profiles and nothing changes for them. +_RANGE_IQR = 4.0 + +#: Number of samples used to locate maxima and half-maximum crossings. +_SCAN_POINTS = 4001 + +if hasattr(np, "trapezoid"): # ToDo remove for orGUI release >1.5 + _trapz_impl = np.trapezoid # numpy >= 2.0 +else: + _trapz_impl = np.trapz # noqa: NPY201 # numpy < 2.0 + +#: Conversion of a Gaussian FWHM to its standard deviation. +_FWHM_TO_SIGMA = 1.0 / (2.0 * np.sqrt(2.0 * np.log(2.0))) + + +class BeamProfile(ABC): + """Vertical intensity profile of the incident beam. + + Subclasses provide the applied active-area factor and its intercepted-flux + diagnostic for arbitrarily shaped arrays of incidence angles. + """ + + @abstractmethod + def flux_on_sample(self, alpha, L): + """Fraction of the incident flux intercepted by the sample. + + :param alpha: Incidence angle(s) in radian, any array shape. + Expected in ``(0, pi/2]``. + :param float L: Sample size along the beam, in meters. + :returns: ``C_flux_on_sample``, broadcast to the shape of ``alpha``. + :rtype: numpy.ndarray + """ + + @abstractmethod + def illuminated_area_fraction(self, alpha, L): + """Illuminated fraction of the projected sample footprint. + + :param alpha: Incidence angle(s) in radian, any array shape. + :param float L: Sample size along the beam, in meters. + :returns: ``C_illum_area``, broadcast to the shape of ``alpha``. + :rtype: numpy.ndarray + """ + + def corrections(self, alpha, L): + """Return the intercepted flux and numerical active area. + + :param alpha: Incidence angle(s) in radian, any array shape. + :param float L: Sample size along the beam, in meters. + :returns: ``(C_flux_on_sample, C_illum_area)``. Only + ``C_illum_area`` is an integrated-intensity divisor; + ``C_flux_on_sample`` is its diagnostic numerator. + :rtype: tuple + """ + return self.flux_on_sample(alpha, L), self.illuminated_area_fraction(alpha, L) + + @abstractmethod + def profile_curve(self, n=512): + """Sample the profile for display. + + :param int n: Requested number of samples. A tabulated profile + returns its own points and ignores this. + :returns: ``(z, p)`` with ``z`` relative to the sample center in + meters and ``p`` the normalized profile in 1/meter. + :rtype: tuple of numpy.ndarray + """ + + @property + @abstractmethod + def centroid_position(self): + """Center of mass relative to the sample center, in meters. + + ``0.0`` when the sample is centered on the centroid, and ``nan`` + for a profile whose first moment does not converge. + + :rtype: float + """ + + +def _half_maximum_width(z, p, pmax): + """Width between the outermost crossings of ``pmax / 2``. + + Uses the outermost crossings so that a profile with several maxima + reports its full extent rather than the width of a single sub-peak. + + :param z: Sample positions, increasing. + :param p: Profile values at ``z``. + :param float pmax: Peak value the half maximum is taken from. + :returns: The full width at half maximum, in the units of ``z``. + :rtype: float + """ + half = pmax / 2.0 + above = np.flatnonzero(p >= half) + if above.size == 0: + return float("nan") + lo, hi = int(above[0]), int(above[-1]) + if lo == 0: + left = z[lo] + else: + left = np.interp(half, [p[lo - 1], p[lo]], [z[lo - 1], z[lo]]) + if hi == z.size - 1: + right = z[hi] + else: + right = np.interp(half, [p[hi + 1], p[hi]], [z[hi + 1], z[hi]]) + return float(right - left) + + +class _CenteredProfile(BeamProfile): + """Shared centering and correction evaluation for profiles with a CDF. + + Both corrections follow from the cumulative integral of the profile and + from its peak density, so a subclass only has to supply + ``_cumulative(x)`` and ``_density_at(x)`` -- both in coordinates relative + to the sample center -- together with ``_pmax`` and the ``_tiny`` + threshold below which the ``alpha -> 0`` limit is used. + """ + + def _set_center(self, center, offset, centroid, peak_position, median): + """Resolve the requested centering into :attr:`sample_center`. + + :param str center: ``"centroid"``, ``"peak"`` or ``"median"``. + :param float offset: Extra displacement of the sample center. + :param float centroid: Center of mass of the profile. + :param float peak_position: Position of the maximum density. + :param float median: Position at which half the flux has passed. + :raises ValueError: If ``center`` is unknown, or names a reference + point this profile does not have (a heavy-tailed distribution + has no finite centroid). + """ + try: + reference = { + "centroid": centroid, + "peak": peak_position, + "median": median, + }[center] + except KeyError: + raise ValueError( + f"center must be 'centroid', 'peak' or 'median', got {center!r}" + ) from None + if not np.isfinite(reference): + raise ValueError( + f"the {center} of this beam profile is not finite, so the " + "sample cannot be centered on it. Heavy-tailed profiles have " + "no center of mass; center on 'median' or 'peak' instead." + ) + self.centroid = float(centroid) + self.peak_position = float(peak_position) + self.median = float(median) + self.center = center + self.offset = float(offset) + #: Position of the sample center in the profile's own coordinate. + self.sample_center = float(reference) + self.offset + + @property + def centroid_position(self): + """Center of mass relative to the sample center, in meters.""" + return float(self.centroid - self.sample_center) + + def _flux(self, h): + """``C_flux_on_sample`` for a projected sample size ``h``.""" + return self._cumulative(h / 2.0) - self._cumulative(-h / 2.0) + + def flux_on_sample(self, alpha, L): + """Fraction of the incident flux intercepted by the sample. + + Integrates the profile over the projected sample size + ``h = L sin(alpha)``, centered on the sample center. + """ + return self._flux(L * np.sin(np.asarray(alpha, dtype=float))) + + def illuminated_area_fraction(self, alpha, L): + """Illuminated fraction of the projected sample footprint. + + ``C_flux_on_sample / (p_max * h)``, continued to its limit + ``p(0) / p_max`` as ``h -> 0``. + """ + h = L * np.sin(np.asarray(alpha, dtype=float)) + flux = self._flux(h) + limit = self._density_at(0.0) / self._pmax + denom = self._pmax * h + return np.where( + np.abs(h) > self._tiny, + flux / np.where(denom == 0.0, 1.0, denom), + limit, + ) + + +class GaussianBeamProfile(BeamProfile): + """Analytical Gaussian beam profile. + + Reproduces orGUI's original closed-form footprint correction exactly; it + is the reference against which :class:`MeasuredBeamProfile` is validated. + + :param float fwhm: Full width at half maximum of the vertical beam + profile, in meters. + :raises ValueError: If ``fwhm`` is not positive. + """ + + def __init__(self, fwhm): + fwhm = float(fwhm) + if not fwhm > 0: + raise ValueError(f"beam FWHM must be positive, got {fwhm:g}") + self.fwhm = fwhm + self.sigma = fwhm * _FWHM_TO_SIGMA + + def flux_on_sample(self, alpha, L): + """Fraction of the incident flux intercepted by the sample. + + Evaluates ``erf(h / (2 sqrt(2) sigma))`` with ``h = L sin(alpha)``. + """ + arg = ((L * np.sin(alpha)) / (np.sqrt(2) * self.sigma)) * 0.5 + return (1 / 2) * (special.erf(arg) - special.erf(-arg)) + + def illuminated_area_fraction(self, alpha, L): + """Illuminated fraction of the projected sample footprint.""" + return (np.sqrt(2 * np.pi) * self.sigma * self.flux_on_sample(alpha, L)) / ( + L * np.sin(alpha) + ) + + def profile_curve(self, n=512): + """Sample the Gaussian over +- 4 sigma around the sample center.""" + z = np.linspace(-4.0 * self.sigma, 4.0 * self.sigma, int(n)) + return z, np.exp(-0.5 * (z / self.sigma) ** 2) / ( + np.sqrt(2 * np.pi) * self.sigma + ) + + @property + def centroid_position(self): + """A Gaussian is symmetric, so its centroid is the sample center.""" + return 0.0 + + def __repr__(self): + return f"GaussianBeamProfile(fwhm={self.fwhm:g} m)" + + +class MeasuredBeamProfile(_CenteredProfile): + """Tabulated beam profile integrated numerically. + + The profile is treated as piecewise linear between the supplied sample + points and as exactly zero outside their range, so its cumulative + integral -- and with it ``C_flux_on_sample`` -- is evaluated in closed + form on each interval instead of being re-sampled onto an auxiliary grid. + + A measured profile carries no absolute position information, so it is + re-referenced to the point of the beam that the center of the sample is + aligned to; see ``center``. For a symmetric profile every choice + coincides, and the results then reduce to :class:`GaussianBeamProfile`. + + :param z: Positions perpendicular to the beam in the scattering plane, + in meters. Must be strictly monotonic; the direction is preserved + (increasing ``z`` upward, as in a sample height scan). + :param intensity: Beam intensity at ``z``, in arbitrary units. It is + normalized internally, so any scale or monitor normalization is fine. + :param str center: Which point of the profile the center of the sample + sits on: ``"centroid"`` (center of mass, the default), ``"peak"`` + (maximum intensity), or ``"median"`` (the half-cut position an + edge-scan alignment converges to). + :param float offset: Additional displacement of the sample center from + the ``center`` reference, in meters. Positive moves the sample + center toward larger ``z``. + :raises ValueError: If ``z`` is not strictly monotonic, if fewer than + two points are given, if any value is not finite, or if the profile + does not have a positive integral. + """ + + def __init__(self, z, intensity, center="centroid", offset=0.0): + z = np.asarray(z, dtype=float).ravel() + intensity = np.asarray(intensity, dtype=float).ravel() + if z.size != intensity.size: + raise ValueError( + "z and intensity must have the same length, got " + f"{z.size:d} and {intensity.size:d}" + ) + if z.size < 2: + raise ValueError("a beam profile needs at least two points") + if not np.all(np.isfinite(z)) or not np.all(np.isfinite(intensity)): + raise ValueError("beam profile contains non-finite values") + + dz = np.diff(z) + if np.all(dz < 0): + z, intensity = z[::-1], intensity[::-1] + dz = np.diff(z) + if not np.all(dz > 0): + raise ValueError("z must be strictly monotonic") + + norm = _trapz_impl(intensity, z) + if not norm > 0: + raise ValueError( + f"beam profile must have a positive integral, got {norm:g}. A " + "height scan differentiated with the wrong sign gives a " + "negative one." + ) + + p = intensity / norm + # Exact cumulative integral of the piecewise-linear profile at the + # sample points; interior positions are handled in _cumulative(). + cum = np.concatenate(([0.0], np.cumsum(0.5 * (p[:-1] + p[1:]) * dz))) + cum[-1] = 1.0 + + self._z_raw = z + self._p = p + self._cum = cum + self._pmax = float(p.max()) + # Guard for the alpha -> 0 limit, scaled to the profile's support so + # that it stays meaningful whatever length scale the caller works on. + self._tiny = 1e-9 * float(z[-1] - z[0]) + + centroid = float(_trapz_impl(z * p, z)) + self._set_center( + center, + offset, + centroid, + float(z[np.argmax(p)]), + float(np.interp(0.5, cum, z)), + ) + self.rms_width = float( + np.sqrt(max(float(_trapz_impl((z - centroid) ** 2 * p, z)), 0.0)) + ) + + def profile_curve(self, n=512): + """Return the tabulated points; ``n`` is ignored.""" + return self.z, self._p + + @property + def z(self): + """Profile positions relative to the sample center, in meters.""" + return self._z_raw - self.sample_center + + @property + def density(self): + """Normalized profile ``p(z)`` in 1/meter, matching :attr:`z`.""" + return self._p + + @property + def support(self): + """``(z_min, z_max)`` of the profile relative to the sample center.""" + return ( + float(self._z_raw[0] - self.sample_center), + float(self._z_raw[-1] - self.sample_center), + ) + + @property + def fwhm(self): + """Full width at half maximum of the profile, in meters. + + Measured between the outermost crossings of half the peak value, so + a profile with several maxima reports its full extent rather than + the width of a single sub-peak. + """ + return _half_maximum_width(self._z_raw, self._p, self._pmax) + + def _density_at(self, x): + """Normalized profile at ``x``, relative to the sample center.""" + return np.interp( + np.asarray(x, dtype=float) + self.sample_center, self._z_raw, self._p + ) + + def _cumulative(self, x): + """Integral of the normalized profile from ``-inf`` up to ``x``. + + ``x`` is given relative to the sample center. Outside the tabulated + range the profile is taken to be zero, so the result saturates at 0 + and 1. + """ + z, p, cum = self._z_raw, self._p, self._cum + x = np.clip(np.asarray(x, dtype=float) + self.sample_center, z[0], z[-1]) + i = np.clip(np.searchsorted(z, x, side="right") - 1, 0, z.size - 2) + d = z[i + 1] - z[i] + t = (x - z[i]) / d + # Exact integral of one linear segment over [z[i], x]. + return cum[i] + d * t * (p[i] + 0.5 * t * (p[i + 1] - p[i])) + + def __repr__(self): + return ( + f"MeasuredBeamProfile({self._z_raw.size:d} points, " + f"fwhm={self.fwhm:g} m, center={self.center!r})" + ) + + +class DistributionBeamProfile(_CenteredProfile): + """Beam profile given by an analytical probability distribution. + + Both corrections need only the cumulative distribution and the peak + density, so any continuous distribution with a ``cdf``, a ``pdf`` and a + ``ppf`` can describe the beam -- in particular any frozen + :mod:`scipy.stats` distribution. The named constructors in this module + (:func:`top_hat_profile`, :func:`trapezoid_profile`, + :func:`smoothed_top_hat_profile`, :func:`generalized_normal_profile`, + :func:`skew_normal_profile`) wrap the shapes that describe real beams, + in physical parameters rather than raw distribution arguments. + + :class:`GaussianBeamProfile` is the same model evaluated in closed form; + passing ``scipy.stats.norm`` here reproduces it to machine precision. + + ``scipy.stats`` exposes no mode, so the peak is located once at + construction by scanning the distribution's central range and refining + the best sample. A plateau, as in a top hat, resolves to its midpoint. + + :param dist: A frozen continuous distribution, e.g. + ``scipy.stats.norm(scale=1e-5)``. + :param str center: Which point of the profile the center of the sample + sits on: ``"centroid"`` (mean, the default), ``"peak"`` (mode) or + ``"median"``. All three coincide for a symmetric distribution. + :param float offset: Additional displacement of the sample center from + the ``center`` reference, in meters. + :raises TypeError: If ``dist`` is not a frozen distribution. + :raises ValueError: If the distribution has no finite central range, or + no finite positive maximum density -- a beam profile with an + unbounded peak has no active-area reference and cannot be used. + """ + + def __init__(self, dist, center="centroid", offset=0.0): + for name in ("cdf", "pdf", "ppf"): + if not callable(getattr(dist, name, None)): + raise TypeError( + "dist must be a frozen continuous distribution with cdf, " + f"pdf and ppf methods, got {dist!r}. Freeze a scipy.stats " + "distribution first, e.g. scipy.stats.norm(scale=1e-5)." + ) + if isinstance(dist, stats.rv_continuous): + # An unfrozen distribution answers every call with its standard + # form, which here would silently describe a beam one meter wide. + raise TypeError( + f"dist must be frozen, got the distribution family {dist!r}. " + "Supply its parameters, e.g. scipy.stats.norm(scale=1e-5) " + "rather than scipy.stats.norm." + ) + lo, hi = self._central_range(dist) + + self._dist = dist + self._lo = lo + self._hi = hi + peak_position, pmax = self._locate_peak(dist, lo, hi) + if not np.isfinite(pmax) or pmax <= 0: + raise ValueError( + f"the distribution has no finite positive maximum density " + f"(got {pmax:g}); the illuminated-area correction has no " + "reference intensity for such a profile." + ) + self._reject_unbounded_density(dist, pmax) + self._pmax = pmax + self._tiny = 1e-9 * (hi - lo) + + self._set_center( + center, offset, self._finite_moment(dist.mean), peak_position, dist.ppf(0.5) + ) + self.rms_width = self._finite_moment(dist.std) + + @staticmethod + def _central_range(dist): + """Range over which the profile is scanned and displayed. + + The narrower of the ``_TAIL`` quantile range and ``_RANGE_IQR`` + interquartile ranges around the median. This bounds only where the + peak is looked for, the width is measured and the preview is drawn; + the corrections always integrate the full distribution, so a + heavy-tailed profile still reports the flux that misses the sample. + + :returns: ``(lo, hi)``. + :rtype: tuple of float + :raises ValueError: If the distribution is not localized. + """ + lo = float(dist.ppf(_TAIL)) + hi = float(dist.ppf(1.0 - _TAIL)) + median = float(dist.ppf(0.5)) + iqr = float(dist.ppf(0.75)) - float(dist.ppf(0.25)) + if np.isfinite(iqr) and iqr > 0: + lo = max(lo, median - _RANGE_IQR * iqr) + hi = min(hi, median + _RANGE_IQR * iqr) + if not (np.isfinite(lo) and np.isfinite(hi) and hi > lo): + raise ValueError( + f"the distribution has no finite central range, got {lo:g} to " + f"{hi:g}. A beam profile must be localized." + ) + return lo, hi + + @staticmethod + def _reject_unbounded_density(dist, pmax): + """Refuse a distribution whose density diverges at an end. + + The peak is searched between finite quantiles, so a density that + runs to infinity -- as a gamma distribution with shape below one + does at zero -- still yields a large but finite maximum there, and + that value would depend on nothing but the chosen cut. Probing a + much deeper quantile catches it: for a bounded density the value + stops growing, for a divergent one it does not. + + :raises ValueError: If the density is still growing at either end. + """ + for quantile in (_TAIL * 1e-3, 1.0 - _TAIL * 1e-3): + edge = float(dist.ppf(quantile)) + if not np.isfinite(edge): + continue + density = float(dist.pdf(edge)) + if not np.isfinite(density) or density > pmax * 1.01: + raise ValueError( + "the density of this distribution diverges, so its " + "maximum depends only on where the tail is cut and the " + "illuminated-area correction has no reference intensity. " + "Use a distribution with a bounded peak." + ) + + @staticmethod + def _finite_moment(func): + """Evaluate a distribution moment, returning ``nan`` if it diverges.""" + try: + value = float(func()) + except (ValueError, TypeError, ZeroDivisionError): + return float("nan") + return value if np.isfinite(value) else float("nan") + + @staticmethod + def _locate_peak(dist, lo, hi): + """Find the position and value of the maximum density. + + Scans the central range first so that a plateau or a second maximum + cannot be missed, then refines a single interior maximum. The + plateau of a top hat resolves to its midpoint. + + :returns: ``(peak_position, peak_density)``. + :rtype: tuple of float + """ + z = np.linspace(lo, hi, _SCAN_POINTS) + p = np.asarray(dist.pdf(z), dtype=float) + p = np.where(np.isfinite(p), p, -np.inf) + pmax = float(p.max()) + if not np.isfinite(pmax) or pmax <= 0: + return float("nan"), pmax + # A flat top has no single argmax; take the middle of the plateau. + flat = np.flatnonzero(p >= pmax * (1.0 - 1e-12)) + if flat.size > 1: + return float(0.5 * (z[flat[0]] + z[flat[-1]])), pmax + best = int(flat[0]) + if best == 0 or best == z.size - 1: + return float(z[best]), pmax + refined = optimize.minimize_scalar( + lambda x: -float(dist.pdf(x)), + bounds=(z[best - 1], z[best + 1]), + method="bounded", + options={"xatol": (hi - lo) * 1e-12}, + ) + position = float(refined.x) + density = float(dist.pdf(position)) + return (position, density) if density >= pmax else (float(z[best]), pmax) + + def _cumulative(self, x): + """Integral of the profile up to ``x``, relative to the sample center.""" + return self._dist.cdf(np.asarray(x, dtype=float) + self.sample_center) + + def _density_at(self, x): + """Profile density at ``x``, relative to the sample center.""" + return self._dist.pdf(np.asarray(x, dtype=float) + self.sample_center) + + def profile_curve(self, n=512): + """Sample the distribution over its central range.""" + z = np.linspace(self._lo, self._hi, int(n)) + return z - self.sample_center, np.asarray(self._dist.pdf(z), dtype=float) + + @property + def support(self): + """``(z_min, z_max)`` of the plotted range, relative to the center.""" + return ( + float(self._lo - self.sample_center), + float(self._hi - self.sample_center), + ) + + @property + def fwhm(self): + """Full width at half maximum of the distribution, in meters.""" + z = np.linspace(self._lo, self._hi, _SCAN_POINTS) + return _half_maximum_width( + z, np.asarray(self._dist.pdf(z), dtype=float), self._pmax + ) + + def __repr__(self): + return ( + f"DistributionBeamProfile({self._dist!r}, " + f"fwhm={self.fwhm:g} m, center={self.center!r})" + ) + + +class _SmoothedTopHat: + """Top hat of full ``width`` convolved with a Gaussian of ``sigma``. + + The frozen-distribution interface :class:`DistributionBeamProfile` + needs, implemented in closed form. This is the beam a pair of defining + slits produces once the finite source size blurs the slit edges, so the + profile is flat in the middle with error-function flanks. + + :param float width: Full width of the top hat, in meters. + :param float sigma: Standard deviation of the edge blur, in meters. + """ + + def __init__(self, width, sigma): + width = float(width) + sigma = float(sigma) + if not width > 0: + raise ValueError(f"width must be positive, got {width:g}") + if not sigma > 0: + raise ValueError(f"edge sigma must be positive, got {sigma:g}") + self.width = width + self.sigma = sigma + self._a = -0.5 * width + self._b = 0.5 * width + + def pdf(self, x): + """Density: the difference of two shifted normal CDFs.""" + x = np.asarray(x, dtype=float) + return ( + special.ndtr((x - self._a) / self.sigma) + - special.ndtr((x - self._b) / self.sigma) + ) / self.width + + def cdf(self, x): + """Cumulative distribution, the antiderivative of :meth:`pdf`.""" + x = np.asarray(x, dtype=float) + + def term(edge): + u = (x - edge) / self.sigma + return (x - edge) * special.ndtr(u) + self.sigma * np.exp( + -0.5 * u**2 + ) / np.sqrt(2 * np.pi) + + return (term(self._a) - term(self._b)) / self.width + + def ppf(self, q): + """Quantile function, inverted numerically from :meth:`cdf`.""" + q = float(q) + if q <= 0.0 or q >= 1.0: + raise ValueError(f"quantile must be in (0, 1), got {q:g}") + span = 0.5 * self.width + 12.0 * self.sigma + return float(optimize.brentq(lambda x: self.cdf(x) - q, -span, span)) + + def mean(self): + """The profile is symmetric about zero.""" + return 0.0 + + def std(self): + """Quadrature sum of the top-hat and blur widths.""" + return float(np.sqrt(self.width**2 / 12.0 + self.sigma**2)) + + def __repr__(self): + return f"_SmoothedTopHat(width={self.width:g}, sigma={self.sigma:g})" + + +def gaussian_profile(fwhm, center="centroid", offset=0.0): + """Gaussian beam profile that supports centering and an offset. + + Numerically the same model as :class:`GaussianBeamProfile`, which + evaluates it in closed form but always sits centered on the sample. Use + this one when the sample is displaced from the beam center. + + :param float fwhm: Full width at half maximum, in meters. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + :raises ValueError: If ``fwhm`` is not positive. + """ + fwhm = float(fwhm) + if not fwhm > 0: + raise ValueError(f"fwhm must be positive, got {fwhm:g}") + return DistributionBeamProfile( + stats.norm(loc=0.0, scale=fwhm * _FWHM_TO_SIGMA), center=center, offset=offset + ) + + +def top_hat_profile(width, center="centroid", offset=0.0): + """Beam profile of uniform intensity over ``width``. + + The slit-limited beam. With this profile ``C_flux_on_sample`` is + ``L sin(alpha) / width`` until the sample intercepts the whole beam, + the linear correction of Gibaud, Vignaud & Sinha (1993), + *Acta Cryst.* A49, 642, equation (12), and ``C_illum_area`` stays at 1 + over the same range. + + :param float width: Full width of the beam, in meters. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + :raises ValueError: If ``width`` is not positive. + """ + width = float(width) + if not width > 0: + raise ValueError(f"width must be positive, got {width:g}") + return DistributionBeamProfile( + stats.uniform(loc=-0.5 * width, scale=width), center=center, offset=offset + ) + + +def trapezoid_profile(full_width, flat_width, center="centroid", offset=0.0): + """Beam profile of a trapezoid with symmetric flanks. + + Two slits of different apertures convolve to a trapezoid whose flat top + is the smaller aperture and whose base is the larger one; matched slits + give the triangle of :func:`triangular_profile`. + + :param float full_width: Width at the base, in meters. + :param float flat_width: Width of the flat top, in meters, between 0 and + ``full_width``. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + :raises ValueError: If the widths are not positive and ordered. + """ + full_width = float(full_width) + flat_width = float(flat_width) + if not full_width > 0: + raise ValueError(f"full_width must be positive, got {full_width:g}") + if not 0.0 <= flat_width <= full_width: + raise ValueError( + f"flat_width must be between 0 and full_width, got {flat_width:g} " + f"with full_width {full_width:g}" + ) + ramp = 0.5 * (1.0 - flat_width / full_width) + return DistributionBeamProfile( + stats.trapezoid(ramp, 1.0 - ramp, loc=-0.5 * full_width, scale=full_width), + center=center, + offset=offset, + ) + + +def triangular_profile(full_width, center="centroid", offset=0.0): + """Beam profile of a symmetric triangle, from two matched slits. + + :param float full_width: Width at the base, in meters. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + """ + return trapezoid_profile(full_width, 0.0, center=center, offset=offset) + + +def smoothed_top_hat_profile(width, edge_sigma, center="centroid", offset=0.0): + """Beam profile of a top hat with error-function flanks. + + A slit-defined beam whose edges are blurred by the finite source size: + flat in the middle, with flanks of standard deviation ``edge_sigma``. + + :param float width: Full width of the flat part before blurring, in + meters. + :param float edge_sigma: Standard deviation of the edge blur, in meters. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + """ + return DistributionBeamProfile( + _SmoothedTopHat(width, edge_sigma), center=center, offset=offset + ) + + +def generalized_normal_profile(fwhm, flatness, center="centroid", offset=0.0): + """Beam profile interpolating between a peaked, Gaussian and flat beam. + + The generalized normal density is proportional to + ``exp(-|z / scale| ** flatness)``: ``flatness = 1`` gives an exponential + cusp, ``flatness = 2`` is exactly Gaussian, and large values approach a + top hat. It is the one-parameter family for a focused beam that is + neither Gaussian nor flat. + + :param float fwhm: Full width at half maximum, in meters. + :param float flatness: Shape exponent, positive. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + :raises ValueError: If ``fwhm`` or ``flatness`` is not positive. + """ + fwhm = float(fwhm) + flatness = float(flatness) + if not fwhm > 0: + raise ValueError(f"fwhm must be positive, got {fwhm:g}") + if not flatness > 0: + raise ValueError(f"flatness must be positive, got {flatness:g}") + # exp(-(w/2 / scale)**flatness) = 1/2 at the half maximum. + scale = 0.5 * fwhm / np.log(2.0) ** (1.0 / flatness) + return DistributionBeamProfile( + stats.gennorm(flatness, loc=0.0, scale=scale), center=center, offset=offset + ) + + +def skew_normal_profile(fwhm, skew, center="centroid", offset=0.0): + """Asymmetric beam profile with a Gaussian core. + + The skew-normal density, scaled so that its full width at half maximum + is ``fwhm``. ``skew = 0`` is Gaussian; positive values put the tail + toward larger ``z``. + + :param float fwhm: Full width at half maximum, in meters. + :param float skew: Shape parameter; 0 is symmetric. + :param str center: Centering, see :class:`DistributionBeamProfile`. + :param float offset: Sample-center displacement, in meters. + :rtype: DistributionBeamProfile + :raises ValueError: If ``fwhm`` is not positive. + """ + fwhm = float(fwhm) + if not fwhm > 0: + raise ValueError(f"fwhm must be positive, got {fwhm:g}") + skew = float(skew) + # The skew normal has no closed-form FWHM; measure it once at unit + # scale and rescale, since the family is a location-scale family. + unit = stats.skewnorm(skew) + z = np.linspace(unit.ppf(_TAIL), unit.ppf(1.0 - _TAIL), _SCAN_POINTS) + p = unit.pdf(z) + unit_fwhm = _half_maximum_width(z, p, float(p.max())) + return DistributionBeamProfile( + stats.skewnorm(skew, loc=0.0, scale=fwhm / unit_fwhm), + center=center, + offset=offset, + ) + + +def profile_from_height_scan(z, intensity, monitor=None): + r"""Differentiate a sample height scan into a beam profile. + + In a height (``samz``) scan the sample edge cuts progressively further + into the beam, so the transmitted intensity is the beam profile + integrated over the part of the beam still passing the sample, + :math:`I(z) = I_0 \int_z^\infty p(z')\,\mathrm{d}z'`. The profile is + therefore the *negated* derivative of the measured curve, + + .. math:: p(z) \propto -\frac{\mathrm{d}I}{\mathrm{d}z} + + which also removes any constant transmission offset. ``z`` keeps its + direction, so the returned profile is the beam profile in the same + (upward) coordinate as the scan. + + Only the range over which the edge cuts into the beam is meaningful. A + scan that continues until the sample leaves the beam again has a + negative derivative there and must be sliced before use, otherwise the + result is a profile followed by its mirror image. + + :param z: Height positions of the scan, in meters, strictly monotonic. + :param intensity: Transmitted intensity at each ``z``. + :param monitor: Optional incident-beam monitor at each ``z``. When + given, ``intensity`` is divided by it before differentiation, which + removes storage-ring current drift over the scan. The mean monitor + value is multiplied back in, so the profile keeps the scale of the + raw counts. + :returns: ``(z, profile)``, the profile in arbitrary units and of the + same length as ``z``. + :rtype: tuple of numpy.ndarray + :raises ValueError: If the inputs have different lengths, if fewer than + two points are given, or if ``monitor`` contains zeros. + """ + z = np.asarray(z, dtype=float).ravel() + intensity = np.asarray(intensity, dtype=float).ravel() + if z.size != intensity.size: + raise ValueError( + "z and intensity must have the same length, got " + f"{z.size:d} and {intensity.size:d}" + ) + if z.size < 2: + raise ValueError("a height scan needs at least two points") + if monitor is not None: + monitor = np.asarray(monitor, dtype=float).ravel() + if monitor.size != z.size: + raise ValueError( + "monitor must have the same length as z, got " + f"{monitor.size:d} and {z.size:d}" + ) + if np.any(monitor == 0): + raise ValueError("monitor contains zeros") + intensity = intensity / monitor * float(np.mean(monitor)) + return z, -np.gradient(intensity, z) + + +def trim_to_illuminated_edge(z, profile): + """Restrict a differentiated height scan to its single cutting edge. + + A height scan long enough to move the sample fully through the beam + contains two edges: the sample cutting in, whose negated derivative is + the beam profile, and -- if the beam clears the sample again -- the + sample leaving, which appears as a negative excursion. Only the first is + a beam profile. + + The returned slice is the run of samples around the maximum over which + the profile stays positive, i.e. it is cut at the sign changes flanking + the peak. A profile that is positive everywhere is returned unchanged. + + :param z: Positions, strictly monotonic and increasing. + :param profile: Profile values at ``z``, as returned by + :func:`profile_from_height_scan`. + :returns: ``(z, profile)`` restricted to the edge. + :rtype: tuple of numpy.ndarray + :raises ValueError: If no positive sample exists at all, which means the + scan was differentiated with the wrong sign. + """ + z = np.asarray(z, dtype=float).ravel() + profile = np.asarray(profile, dtype=float).ravel() + if z.size != profile.size: + raise ValueError( + "z and profile must have the same length, got " + f"{z.size:d} and {profile.size:d}" + ) + if not np.any(profile > 0): + raise ValueError( + "the differentiated height scan has no positive values; it is " + "most likely differentiated with the wrong sign" + ) + peak = int(np.argmax(profile)) + nonpositive = np.flatnonzero(profile <= 0) + before = nonpositive[nonpositive < peak] + after = nonpositive[nonpositive > peak] + start = int(before[-1]) + 1 if before.size else 0 + stop = int(after[0]) if after.size else z.size + return z[start:stop], profile[start:stop] + + +def read_profile_file( + path, + z_scale=1e-3, + height_scan=False, + usecols=(0, 1), + monitor_col=None, + trim=True, +): + """Read a two-column text file into a beam profile. + + The file is read with :func:`numpy.loadtxt`, so ``#`` comments and any + common whitespace separator are accepted. + + :param str path: File to read. + :param float z_scale: Factor converting the file's position column to + meters. The default ``1e-3`` reads millimeters, the usual unit of a + diffractometer height scan; use ``1e-6`` for micrometers. + :param bool height_scan: If ``True``, the intensity column is a measured + height scan and is differentiated by + :func:`profile_from_height_scan`. If ``False`` (default), it already + is a beam profile. + :param usecols: ``(position, intensity)`` column indices. + :param monitor_col: Optional column index of a monitor counter, only + used together with ``height_scan``. + :param bool trim: Restrict a differentiated height scan to its cutting + edge with :func:`trim_to_illuminated_edge`. Ignored unless + ``height_scan`` is set. + :returns: ``(z, profile)`` with ``z`` in meters. + :rtype: tuple of numpy.ndarray + """ + cols = tuple(usecols) if monitor_col is None else tuple(usecols) + (monitor_col,) + data = np.loadtxt(path, usecols=cols, unpack=True, ndmin=2) + z = np.asarray(data[0], dtype=float) * float(z_scale) + intensity = np.asarray(data[1], dtype=float) + monitor = np.asarray(data[2], dtype=float) if monitor_col is not None else None + if not height_scan: + return z, intensity + if z.size > 1 and z[1] < z[0]: # keep the edge search on an increasing axis + z, intensity = z[::-1], intensity[::-1] + monitor = None if monitor is None else monitor[::-1] + z, profile = profile_from_height_scan(z, intensity, monitor) + if trim: + z, profile = trim_to_illuminated_edge(z, profile) + return z, profile diff --git a/orgui/datautils/xrayutils/corrections/detector.py b/orgui/datautils/xrayutils/corrections/detector.py new file mode 100644 index 0000000..6d5d686 --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/detector.py @@ -0,0 +1,89 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""Per-pixel correction factors of a detector image. + +The factors that depend on where a photon landed on the detector rather than +on the scan: the solid angle each pixel subtends and the polarization factor +at its scattering angle. Both are properties of the calibrated geometry, so +they are evaluated once and reused for every frame that shares it. + +They are returned as a **multiplicative** factor, the reciprocal of the +physical quantities, because that is how a corrected intensity is formed: + +.. math:: + + I_\mathrm{corr} = I \cdot \frac{1}{\Omega\,P} + +The rocking integration, the stationary integration and the reciprocal-space +reconstruction each built this array themselves; this is the one definition +they share. + +.. warning:: + + :meth:`~.DetectorCalibration.Detector2D_SXRD.polarizationArray` evaluates + the polarization at the **calibrated** detector position. That is correct + for a detector whose arm does not move -- every pixel already carries its + own scattering angle -- but not for a scan that drives the arm, where it + understates the correction badly: 3 % at a scattering angle of 10 + degrees, 10 % at 18 and 33 % at 30. + :meth:`~.DetectorCalibration.Detector2D_SXRD.polarizationAtPoints` + follows the arm and is what such a scan needs. This function reproduces + the historical, arm-blind behavior; see + ``doc/design/ctr_structure_factor_scale.md`` finding F5. +""" + +import numpy as np + +__all__ = ["pixel_factors"] + + +def pixel_factors(detector, solid_angle=False, polarization=False, shape=None): + r"""Per-pixel multiplicative correction factor of a detector image. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD` + with its calibrated geometry and polarization set. + :param bool solid_angle: Divide by the solid angle each pixel subtends. + :param bool polarization: Divide by the polarization factor. + :param shape: Detector shape; taken from the detector when omitted. + :returns: The factor for every pixel, or ``None`` when neither + correction is enabled -- which lets a caller skip the multiplication + entirely rather than multiply by an array of ones. + :rtype: numpy.ndarray or None + """ + factor = None + if solid_angle: + factor = 1.0 / np.asarray( + detector.solidAngleArray(shape) if shape is not None + else detector.solidAngleArray(), + dtype=np.float64, + ) + if polarization: + values = np.asarray( + detector.polarizationArray(shape) if shape is not None + else detector.polarizationArray(), + dtype=np.float64, + ) + factor = 1.0 / values if factor is None else factor / values + return factor diff --git a/orgui/datautils/xrayutils/corrections/geometry.py b/orgui/datautils/xrayutils/corrections/geometry.py new file mode 100644 index 0000000..4610612 --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/geometry.py @@ -0,0 +1,219 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""Geometrical correction factors of the z-axis diffractometer. + +orGUI applies the correction factors tabulated for the **z-axis geometry** in +Appendix A of the ANA/ROD manual (E. Vlieg, *ANA -- program for the analysis +of surface X-ray diffraction data*), which reproduces +E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532. The z-axis, 5-circle and +6-circle geometries are special cases of one another; only the z-axis column +is implemented here, and it is the one orGUI's angle convention matches. + +Angles follow :mod:`orgui.datautils.xrayutils.HKLVlieg`: + +``alpha`` + incidence angle of the beam on the surface, in radian. This is the ``mu`` + circle of the diffractometer, and the angle the footprint corrections of + :mod:`orgui.datautils.xrayutils.corrections.beamprofile` depend on. +``delta`` + in-plane detector angle, in radian. +``gamma`` + out-of-plane detector angle, in radian. In the z-axis geometry this is + the exit angle of the diffracted beam from the surface. + +The table entries used are, verbatim from the manual: + +========================================= ================================ +Lorentz factor rocking scan :math:`1/(\sin\delta\, + \cos\alpha\,\cos\gamma)` +Lorentz factor stationary mode :math:`1/\sin\gamma` +Lorentz factor reflectivity rocking scan :math:`1/\sin 2\alpha` +Rod interception (rocking scans only) :math:`\cos\gamma` +Area correction (ignoring footprint and :math:`1/\sin\delta` +sample size) +Beam profile and finite sample size calculated numerically, see + :mod:`~.beamprofile` +========================================= ================================ + +For rocking scans, the tabulated Lorentz factor and rod interception are +divided out of the integrated intensity: + +.. math:: F^2_{hkl} = \frac{I}{L \cdot C_\mathrm{rod}} + +The three Lorentz factors are alternatives, selected by how the intensity was +measured, not factors to be combined. Vlieg's stationary expression has no +rod-interception factor: + +* a rocking scan about the sample rotation (``th``/``omega``) uses the + rocking-scan factor, +* a rocking scan about the incidence angle (``mu``), which is how a + reflectivity curve is measured, uses the reflectivity factor, +* a scan with the sample stationary, integrated across the rod on an area + detector, uses the stationary factor alone. + +The area correction :math:`1/\sin\delta` is listed for completeness and is +**not** applied by orGUI; the numerically evaluated beam-profile and finite +sample-size corrections of :mod:`~.beamprofile` are used instead. +""" + +import numpy as np + +__all__ = [ + "AREA", + "REFLECTIVITY_ROCKING", + "ROCKING", + "STATIONARY", + "area_correction", + "lorentz_factor", + "lorentz_reflectivity_rocking_scan", + "lorentz_rocking_scan", + "lorentz_stationary", + "rod_interception", +] + +#: Lorentz factor of a rocking scan about the sample rotation. +ROCKING = "rocking" + +#: Lorentz factor of a rocking scan about the incidence angle. +REFLECTIVITY_ROCKING = "reflectivity_rocking" + +#: Lorentz factor of a scan measured with the sample stationary. +STATIONARY = "stationary" + +#: Identifier of the area correction, for provenance strings. +AREA = "area" + + +def lorentz_rocking_scan(delta, alpha, gamma): + r"""Lorentz factor of a rocking scan about the sample rotation. + + :math:`L = 1/(\sin\delta\,\cos\alpha\,\cos\gamma)`, the z-axis + "Lorentz factor rocking scan" entry. The absolute value is taken so that + integrated intensities stay positive whichever way the scan runs. + + :param delta: In-plane detector angle, in radian. + :param alpha: Incidence angle, in radian. + :param gamma: Out-of-plane detector angle, in radian. + :returns: The Lorentz factor, broadcast over the inputs. + :rtype: numpy.ndarray + """ + return np.abs(1.0 / (np.sin(delta) * np.cos(alpha) * np.cos(gamma))) + + +def lorentz_reflectivity_rocking_scan(alpha): + r"""Lorentz factor of a reflectivity scan rocked in the incidence angle. + + :math:`L = 1/\sin 2\alpha`, the z-axis "Lorentz factor reflectivity + rocking scan" entry. + + :param alpha: Incidence angle, in radian. + :returns: The Lorentz factor, broadcast over the input. + :rtype: numpy.ndarray + """ + return np.abs(1.0 / np.sin(2.0 * np.asarray(alpha, dtype=float))) + + +def lorentz_stationary(gamma): + r"""Lorentz factor of a measurement with the sample stationary. + + :math:`L = 1/\sin\gamma`, the z-axis "Lorentz factor stationary mode" + entry. In this geometry :math:`\gamma` is the exit angle of the + diffracted beam from the surface, so the factor diverges as the rod + approaches the surface plane, where a stationary measurement carries no + information about the rod profile. + + :param gamma: Out-of-plane detector angle, in radian. + :returns: The Lorentz factor, broadcast over the input. + :rtype: numpy.ndarray + """ + return np.abs(1.0 / np.sin(np.asarray(gamma, dtype=float))) + + +def rod_interception(gamma): + r"""Rocking-scan rod interception factor, :math:`\cos\gamma`. + + :param gamma: Out-of-plane detector angle, in radian. + :returns: The rod interception factor, broadcast over the input. + :rtype: numpy.ndarray + """ + return np.cos(np.asarray(gamma, dtype=float)) + + +def area_correction(delta): + r"""Area correction ignoring footprint and sample size, :math:`1/\sin\delta`. + + Listed in the manual for completeness. orGUI does not apply it: the + footprint corrections of :mod:`~.beamprofile` evaluate the beam profile + and finite sample size numerically instead, which is the row the manual + marks as "calculated numerically in ANA". + + :param delta: In-plane detector angle, in radian. + :returns: The area correction, broadcast over the input. + :rtype: numpy.ndarray + """ + return np.abs(1.0 / np.sin(np.asarray(delta, dtype=float))) + + +def lorentz_factor(mode, alpha=None, delta=None, gamma=None): + r"""Lorentz factor for the given measurement mode. + + Dispatches to the three alternatives of the z-axis table. They are + alternatives, not factors to be combined: which one applies is decided by + how the intensity was measured. + + :param str mode: :data:`ROCKING`, :data:`REFLECTIVITY_ROCKING` or + :data:`STATIONARY`. + :param alpha: Incidence angle in radian; required except for + :data:`STATIONARY`. + :param delta: In-plane detector angle in radian; required for + :data:`ROCKING`. + :param gamma: Out-of-plane detector angle in radian; required except for + :data:`REFLECTIVITY_ROCKING`. + :returns: The Lorentz factor, broadcast over the inputs. + :rtype: numpy.ndarray + :raises ValueError: If ``mode`` is unknown or a required angle is + missing. + """ + required = { + ROCKING: ("delta", "alpha", "gamma"), + REFLECTIVITY_ROCKING: ("alpha",), + STATIONARY: ("gamma",), + } + if mode not in required: + raise ValueError( + f"unknown Lorentz mode {mode!r}; expected one of " + f"{ROCKING!r}, {REFLECTIVITY_ROCKING!r} or {STATIONARY!r}" + ) + given = {"alpha": alpha, "delta": delta, "gamma": gamma} + missing = [name for name in required[mode] if given[name] is None] + if missing: + raise ValueError( + f"the {mode!r} Lorentz factor needs {', '.join(required[mode])}; " + f"missing {', '.join(missing)}" + ) + if mode == ROCKING: + return lorentz_rocking_scan(delta, alpha, gamma) + if mode == REFLECTIVITY_ROCKING: + return lorentz_reflectivity_rocking_scan(alpha) + return lorentz_stationary(gamma) diff --git a/orgui/datautils/xrayutils/corrections/normalization.py b/orgui/datautils/xrayutils/corrections/normalization.py new file mode 100644 index 0000000..5181bba --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/normalization.py @@ -0,0 +1,103 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""Counting time and incident-flux normalization. + +Both of Vlieg's integrated-intensity expressions carry :math:`\Phi_0 T`, so a +frame means nothing until it is divided by its counting time and by whatever +counter the incident flux is tracked with. Two measurements are only +comparable -- to each other, and to an absolute scale -- once both have been. + +This module is the policy: what a usable counter is, and what the divisor is. +It takes **values**, not a scan object. Finding the counters in a particular +beamline's scan is a data-format question and belongs to +:mod:`orgui.backend.scans` and the application layer, which is why no scan +attribute name appears here. +""" + +import numpy as np + +__all__ = [ + "broadcast_counter", + "normalization_divisor", +] + + +def broadcast_counter(value, size, name): + """Broadcast a scalar or per-frame counter to ``size`` values. + + :param value: A scalar, or one value per frame. + :param int size: Number of frames. + :param str name: Counter name, used in the error message. + :returns: An array of shape ``(size,)``. + :rtype: numpy.ndarray + :raises ValueError: If the counter has neither one value nor one value + per frame. + """ + array = np.atleast_1d(np.asarray(value, dtype=np.float64)).ravel() + if array.size == 1: + return np.full(int(size), array[0], dtype=np.float64) + if array.size != int(size): + raise ValueError( + f"Counter {name!r} has {array.size:d} values for {int(size):d} images" + ) + return array + + +def normalization_divisor(size, exposure_time=None, monitors=None): + """Per-frame divisor from the counting time and the monitor counters. + + :param int size: Number of frames. + :param exposure_time: Counting time of every frame, in seconds; a scalar + or one value per frame. ``None`` leaves the counting time out, which + is what a scan that does not report one gets: the reciprocal-space + reconstruction records the normalization as unavailable rather than + failing the job, and this follows it. + :param monitors: Mapping of counter name to its values, a scalar or one + value per frame. Applied in iteration order. + :returns: ``(divisor, applied)`` -- an array of shape ``(size,)`` and the + names of the normalizations that contributed, as ``"exposure"`` and + ``"monitor:"``. + :rtype: tuple + :raises ValueError: If a counter has a non-positive, zero or non-finite + value that would make the normalization undefined, or the wrong + number of values. + """ + divisor = np.ones(int(size), dtype=np.float64) + applied = [] + + if exposure_time is not None: + values = broadcast_counter(exposure_time, size, "exposure_time") + if np.any(values <= 0) or not np.all(np.isfinite(values)): + raise ValueError("Exposure time must be finite and positive") + divisor *= values + applied.append("exposure") + + for name, value in (monitors or {}).items(): + values = broadcast_counter(value, size, name) + if np.any(values == 0) or not np.all(np.isfinite(values)): + raise ValueError(f"Monitor {name} must be finite and nonzero") + divisor *= values + applied.append(f"monitor:{name}") + + return divisor, applied diff --git a/orgui/datautils/xrayutils/corrections/roi.py b/orgui/datautils/xrayutils/corrections/roi.py new file mode 100644 index 0000000..0f96cb7 --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/roi.py @@ -0,0 +1,93 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Reducing per-pixel corrections onto a summed region of interest. + +A region of interest is summed pixel by pixel, but its correction factors are +reported and applied per region. This module holds the bookkeeping that +bridges the two, and the container the applied factors are carried in. +""" + +import numpy as np + +__all__ = [ + "CorrectionFactors", + "roi_mean_correction", +] + + +class CorrectionFactors(dict): + """Per-image correction divisors, plus the names of what was applied. + + A plain dict of ``name -> array`` with two conveniences: :attr:`applied` + lists the corrections that actually contributed, and :meth:`divisor` + multiplies a chosen subset together. + """ + + def __init__(self, factors, applied): + super().__init__(factors) + #: Names of the corrections that contributed, in application order. + self.applied = tuple(applied) + + def divisor(self, *names): + """Product of the named factors, or 1.0 when none are present. + + :param names: Factor names to multiply. Missing names are skipped, + so a caller can ask for a correction that was not enabled. + :rtype: numpy.ndarray or float + """ + product = 1.0 + for name in names: + if name in self: + product = product * self[name] + return product + + +def roi_mean_correction(correction_sum, pixel_count): + """Mean per-pixel correction over one region of interest. + + The ROI summing accumulates the correction array over the same pixels it + sums the counts over, giving ``correction_sum`` and the number of valid + pixels ``pixel_count``. An ROI-summed intensity is corrected by the + *mean* of the correction across those pixels. + + The nominal ROI area must not enter here: the integrated intensity is + already rescaled from the valid pixels to the nominal ROI area when the + background is subtracted. Multiplying by the area a second time scaled + every corrected intensity by the size of its ROI, and because the + projected ROI size varies over the detector, two measurements of one rod + taken on different parts of it were scaled apart. + + :param correction_sum: Summed correction array over the ROI. + :param pixel_count: Number of valid pixels contributing to that sum. + :returns: The mean correction, and 0 where no pixel was valid. + :rtype: numpy.ndarray + """ + correction_sum = np.asarray(correction_sum, dtype=np.float64) + pixel_count = np.asarray(pixel_count, dtype=np.float64) + return np.divide( + correction_sum, + pixel_count, + out=np.zeros_like(correction_sum), + where=pixel_count > 0, + ) diff --git a/orgui/datautils/xrayutils/geometrycorrections.py b/orgui/datautils/xrayutils/geometrycorrections.py index c59b53c..47df40c 100644 --- a/orgui/datautils/xrayutils/geometrycorrections.py +++ b/orgui/datautils/xrayutils/geometrycorrections.py @@ -21,63 +21,28 @@ # THE SOFTWARE. # # ###########################################################################*/ -r"""Geometrical correction factors of the z-axis diffractometer. - -orGUI applies the correction factors tabulated for the **z-axis geometry** in -Appendix A of the ANA/ROD manual (E. Vlieg, *ANA -- program for the analysis -of surface X-ray diffraction data*), which reproduces -E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532. The z-axis, 5-circle and -6-circle geometries are special cases of one another; only the z-axis column -is implemented here, and it is the one orGUI's angle convention matches. - -Angles follow :mod:`orgui.datautils.xrayutils.HKLVlieg`: - -``alpha`` - incidence angle of the beam on the surface, in radian. This is the ``mu`` - circle of the diffractometer, and the angle the footprint corrections of - :mod:`orgui.datautils.xrayutils.beamprofile` depend on. -``delta`` - in-plane detector angle, in radian. -``gamma`` - out-of-plane detector angle, in radian. In the z-axis geometry this is - the exit angle of the diffracted beam from the surface. - -The table entries used are, verbatim from the manual: - -========================================= ================================ -Lorentz factor rocking scan :math:`1/(\sin\delta\, - \cos\alpha\,\cos\gamma)` -Lorentz factor stationary mode :math:`1/\sin\gamma` -Lorentz factor reflectivity rocking scan :math:`1/\sin 2\alpha` -Rod interception (rocking scans only) :math:`\cos\gamma` -Area correction (ignoring footprint and :math:`1/\sin\delta` -sample size) -Beam profile and finite sample size calculated numerically, see - :mod:`~.beamprofile` -========================================= ================================ - -For rocking scans, the tabulated Lorentz factor and rod interception are -divided out of the integrated intensity: - -.. math:: F^2_{hkl} = \frac{I}{L \cdot C_\mathrm{rod}} - -The three Lorentz factors are alternatives, selected by how the intensity was -measured, not factors to be combined. Vlieg's stationary expression has no -rod-interception factor: - -* a rocking scan about the sample rotation (``th``/``omega``) uses the - rocking-scan factor, -* a rocking scan about the incidence angle (``mu``), which is how a - reflectivity curve is measured, uses the reflectivity factor, -* a scan with the sample stationary, integrated across the rod on an area - detector, uses the stationary factor alone. - -The area correction :math:`1/\sin\delta` is listed for completeness and is -**not** applied by orGUI; the numerically evaluated beam-profile and finite -sample-size corrections of :mod:`~.beamprofile` are used instead. +"""Backwards-compatible alias of :mod:`orgui.datautils.xrayutils.corrections.geometry`. + +The z-axis geometrical corrections moved into the +:mod:`orgui.datautils.xrayutils.corrections` package, which collects every +factor between detector counts and a structure factor. This module was +released under its own name, so it stays importable and re-exports the same +objects. New code should import from +:mod:`orgui.datautils.xrayutils.corrections.geometry`. """ -import numpy as np +from .corrections.geometry import ( # noqa: F401 + AREA, + REFLECTIVITY_ROCKING, + ROCKING, + STATIONARY, + area_correction, + lorentz_factor, + lorentz_reflectivity_rocking_scan, + lorentz_rocking_scan, + lorentz_stationary, + rod_interception, +) __all__ = [ "AREA", @@ -91,129 +56,3 @@ "lorentz_stationary", "rod_interception", ] - -#: Lorentz factor of a rocking scan about the sample rotation. -ROCKING = "rocking" - -#: Lorentz factor of a rocking scan about the incidence angle. -REFLECTIVITY_ROCKING = "reflectivity_rocking" - -#: Lorentz factor of a scan measured with the sample stationary. -STATIONARY = "stationary" - -#: Identifier of the area correction, for provenance strings. -AREA = "area" - - -def lorentz_rocking_scan(delta, alpha, gamma): - r"""Lorentz factor of a rocking scan about the sample rotation. - - :math:`L = 1/(\sin\delta\,\cos\alpha\,\cos\gamma)`, the z-axis - "Lorentz factor rocking scan" entry. The absolute value is taken so that - integrated intensities stay positive whichever way the scan runs. - - :param delta: In-plane detector angle, in radian. - :param alpha: Incidence angle, in radian. - :param gamma: Out-of-plane detector angle, in radian. - :returns: The Lorentz factor, broadcast over the inputs. - :rtype: numpy.ndarray - """ - return np.abs(1.0 / (np.sin(delta) * np.cos(alpha) * np.cos(gamma))) - - -def lorentz_reflectivity_rocking_scan(alpha): - r"""Lorentz factor of a reflectivity scan rocked in the incidence angle. - - :math:`L = 1/\sin 2\alpha`, the z-axis "Lorentz factor reflectivity - rocking scan" entry. - - :param alpha: Incidence angle, in radian. - :returns: The Lorentz factor, broadcast over the input. - :rtype: numpy.ndarray - """ - return np.abs(1.0 / np.sin(2.0 * np.asarray(alpha, dtype=float))) - - -def lorentz_stationary(gamma): - r"""Lorentz factor of a measurement with the sample stationary. - - :math:`L = 1/\sin\gamma`, the z-axis "Lorentz factor stationary mode" - entry. In this geometry :math:`\gamma` is the exit angle of the - diffracted beam from the surface, so the factor diverges as the rod - approaches the surface plane, where a stationary measurement carries no - information about the rod profile. - - :param gamma: Out-of-plane detector angle, in radian. - :returns: The Lorentz factor, broadcast over the input. - :rtype: numpy.ndarray - """ - return np.abs(1.0 / np.sin(np.asarray(gamma, dtype=float))) - - -def rod_interception(gamma): - r"""Rocking-scan rod interception factor, :math:`\cos\gamma`. - - :param gamma: Out-of-plane detector angle, in radian. - :returns: The rod interception factor, broadcast over the input. - :rtype: numpy.ndarray - """ - return np.cos(np.asarray(gamma, dtype=float)) - - -def area_correction(delta): - r"""Area correction ignoring footprint and sample size, :math:`1/\sin\delta`. - - Listed in the manual for completeness. orGUI does not apply it: the - footprint corrections of :mod:`~.beamprofile` evaluate the beam profile - and finite sample size numerically instead, which is the row the manual - marks as "calculated numerically in ANA". - - :param delta: In-plane detector angle, in radian. - :returns: The area correction, broadcast over the input. - :rtype: numpy.ndarray - """ - return np.abs(1.0 / np.sin(np.asarray(delta, dtype=float))) - - -def lorentz_factor(mode, alpha=None, delta=None, gamma=None): - r"""Lorentz factor for the given measurement mode. - - Dispatches to the three alternatives of the z-axis table. They are - alternatives, not factors to be combined: which one applies is decided by - how the intensity was measured. - - :param str mode: :data:`ROCKING`, :data:`REFLECTIVITY_ROCKING` or - :data:`STATIONARY`. - :param alpha: Incidence angle in radian; required except for - :data:`STATIONARY`. - :param delta: In-plane detector angle in radian; required for - :data:`ROCKING`. - :param gamma: Out-of-plane detector angle in radian; required except for - :data:`REFLECTIVITY_ROCKING`. - :returns: The Lorentz factor, broadcast over the inputs. - :rtype: numpy.ndarray - :raises ValueError: If ``mode`` is unknown or a required angle is - missing. - """ - required = { - ROCKING: ("delta", "alpha", "gamma"), - REFLECTIVITY_ROCKING: ("alpha",), - STATIONARY: ("gamma",), - } - if mode not in required: - raise ValueError( - f"unknown Lorentz mode {mode!r}; expected one of " - f"{ROCKING!r}, {REFLECTIVITY_ROCKING!r} or {STATIONARY!r}" - ) - given = {"alpha": alpha, "delta": delta, "gamma": gamma} - missing = [name for name in required[mode] if given[name] is None] - if missing: - raise ValueError( - f"the {mode!r} Lorentz factor needs {', '.join(required[mode])}; " - f"missing {', '.join(missing)}" - ) - if mode == ROCKING: - return lorentz_rocking_scan(delta, alpha, gamma) - if mode == REFLECTIVITY_ROCKING: - return lorentz_reflectivity_rocking_scan(alpha) - return lorentz_stationary(gamma) diff --git a/orgui/datautils/xrayutils/test/test_beamprofile.py b/orgui/datautils/xrayutils/test/test_corrections_beamprofile.py similarity index 98% rename from orgui/datautils/xrayutils/test/test_beamprofile.py rename to orgui/datautils/xrayutils/test/test_corrections_beamprofile.py index 069b281..3fc06ba 100644 --- a/orgui/datautils/xrayutils/test/test_beamprofile.py +++ b/orgui/datautils/xrayutils/test/test_corrections_beamprofile.py @@ -1,14 +1,14 @@ """Regression tests for the incident-beam footprint corrections. These tests pin the two related quantities defined in -:mod:`orgui.datautils.xrayutils.beamprofile`: ``C_flux_on_sample`` is the +:mod:`orgui.datautils.xrayutils.corrections.beamprofile`: ``C_flux_on_sample`` is the diagnostic beam/sample overlap, and ``C_illum_area`` is the numerical active surface-area divisor that already contains that overlap. The central requirement is that the numerical -:class:`~orgui.datautils.xrayutils.beamprofile.MeasuredBeamProfile` +:class:`~orgui.datautils.xrayutils.corrections.beamprofile.MeasuredBeamProfile` evaluates the *same* definitions as the closed-form -:class:`~orgui.datautils.xrayutils.beamprofile.GaussianBeamProfile` orGUI +:class:`~orgui.datautils.xrayutils.corrections.beamprofile.GaussianBeamProfile` orGUI used before it existed: feeding a sampled Gaussian to the numerical path must reproduce the analytical corrections, so switching an existing Gaussian analysis to the numerical path changes nothing, and any difference @@ -20,7 +20,7 @@ import pytest from scipy import stats -from orgui.datautils.xrayutils.beamprofile import ( +from orgui.datautils.xrayutils.corrections.beamprofile import ( DistributionBeamProfile, GaussianBeamProfile, MeasuredBeamProfile, diff --git a/orgui/datautils/xrayutils/test/test_geometrycorrections.py b/orgui/datautils/xrayutils/test/test_corrections_geometry.py similarity index 96% rename from orgui/datautils/xrayutils/test/test_geometrycorrections.py rename to orgui/datautils/xrayutils/test/test_corrections_geometry.py index a711bc8..66781a5 100644 --- a/orgui/datautils/xrayutils/test/test_geometrycorrections.py +++ b/orgui/datautils/xrayutils/test/test_corrections_geometry.py @@ -3,14 +3,14 @@ These pin the entries of the z-axis column of Appendix A of the ANA/ROD manual against literal transcriptions of the formulas, and pin the two factors that orGUI's rocking-scan integration already applied before -:mod:`orgui.datautils.xrayutils.geometrycorrections` existed, so that +:mod:`orgui.datautils.xrayutils.corrections.geometry` existed, so that factoring them out did not change any result. """ import numpy as np import pytest -from orgui.datautils.xrayutils import geometrycorrections as gc +from orgui.datautils.xrayutils.corrections import geometry as gc #: Angles in radian, spanning grazing incidence to a normal scattering angle. ALPHA = np.deg2rad(np.array([0.2, 0.5, 1.0, 3.0])) diff --git a/orgui/datautils/xrayutils/test/test_corrections_package.py b/orgui/datautils/xrayutils/test/test_corrections_package.py new file mode 100644 index 0000000..4f1aedf --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_corrections_package.py @@ -0,0 +1,143 @@ +"""The corrections package: shared definitions and its released aliases. + +:mod:`orgui.datautils.xrayutils.corrections` exists so that the rocking +integration, the stationary integration and the reciprocal-space +reconstruction correct their data with the same code rather than three +copies. These tests pin the two properties that makes true: that the shared +functions really are what the callers use, and that the module paths released +before the package existed still work. +""" + +import importlib + +import numpy as np +import pytest + +from orgui.datautils.xrayutils import corrections +from orgui.datautils.xrayutils.corrections import ( + detector, + normalization, + roi, +) + + +class _Detector: + """Minimal stand-in for a calibrated detector.""" + + def __init__(self, shape=(4, 3)): + self._shape = shape + + def solidAngleArray(self): + return np.linspace(0.9, 1.0, int(np.prod(self._shape))).reshape(self._shape) + + def polarizationArray(self): + return np.linspace(0.8, 1.0, int(np.prod(self._shape))).reshape(self._shape) + + +def test_the_package_exposes_every_correction_module(): + """A maintainer looking for a correction factor finds them in one place.""" + assert set(corrections.__all__) == { + "beamprofile", + "detector", + "geometry", + "normalization", + "roi", + } + for name in corrections.__all__: + assert getattr(corrections, name) is not None + + +@pytest.mark.parametrize( + "legacy, moved", + [ + ( + "orgui.datautils.xrayutils.geometrycorrections", + "orgui.datautils.xrayutils.corrections.geometry", + ), + ( + "orgui.datautils.xrayutils.beamprofile", + "orgui.datautils.xrayutils.corrections.beamprofile", + ), + ], +) +def test_released_module_paths_still_import(legacy, moved): + """Both names were released, so both must keep working. + + Not just importable: the objects have to be the same ones, or a caller + holding the old path would be testing a different implementation from the + one the integration uses. + """ + old = importlib.import_module(legacy) + new = importlib.import_module(moved) + + assert old.__all__ == new.__all__ + for name in old.__all__: + assert getattr(old, name) is getattr(new, name) + + +def test_pixel_factors_is_the_reciprocal_of_the_enabled_arrays(): + """The one definition of the per-pixel divisors. + + ``None`` for nothing enabled is part of the contract: the reciprocal-space + reconstruction skips its multiplication entirely on that, rather than + walking a full detector of ones. + """ + det = _Detector() + + assert detector.pixel_factors(det) is None + np.testing.assert_allclose( + detector.pixel_factors(det, solid_angle=True), + 1.0 / det.solidAngleArray(), + rtol=1e-12, + ) + np.testing.assert_allclose( + detector.pixel_factors(det, polarization=True), + 1.0 / det.polarizationArray(), + rtol=1e-12, + ) + np.testing.assert_allclose( + detector.pixel_factors(det, solid_angle=True, polarization=True), + 1.0 / (det.solidAngleArray() * det.polarizationArray()), + rtol=1e-12, + ) + + +def test_normalization_takes_values_not_a_scan(): + """The physics layer never learns a beamline's counter names. + + Pulling counters off a scan object is the application's job; this module + only knows what a usable value is. + """ + divisor, applied = normalization.normalization_divisor( + 3, exposure_time=0.5, monitors={"mon": [100.0, 200.0, 400.0]} + ) + + np.testing.assert_allclose(divisor, 0.5 * np.array([100.0, 200.0, 400.0])) + assert applied == ["exposure", "monitor:mon"] + + none, applied = normalization.normalization_divisor(3) + np.testing.assert_allclose(none, np.ones(3)) + assert applied == [] + + with pytest.raises(ValueError, match="finite and positive"): + normalization.normalization_divisor(3, exposure_time=0.0) + with pytest.raises(ValueError, match="finite and nonzero"): + normalization.normalization_divisor(3, monitors={"mon": [1.0, 0.0, 1.0]}) + with pytest.raises(ValueError, match="has 2 values for 3 images"): + normalization.normalization_divisor(3, monitors={"mon": [1.0, 2.0]}) + + +def test_correction_factors_bundle_multiplies_the_named_subset(): + """The container the applied factors are carried and stored in.""" + factors = roi.CorrectionFactors( + {"C_norm": np.full(3, 2.0), "C_Lorentz": np.full(3, 4.0)}, + ["normalization", "lorentz"], + ) + + np.testing.assert_allclose(factors.divisor("C_norm"), np.full(3, 2.0)) + np.testing.assert_allclose( + factors.divisor("C_norm", "C_Lorentz"), np.full(3, 8.0) + ) + # A correction that was not enabled is skipped, not an error. + np.testing.assert_allclose(factors.divisor("C_illum_area"), 1.0) + assert factors.applied == ("normalization", "lorentz") diff --git a/orgui/reconstruction_job.py b/orgui/reconstruction_job.py index 58d2094..57ca811 100644 --- a/orgui/reconstruction_job.py +++ b/orgui/reconstruction_job.py @@ -25,6 +25,7 @@ from .app.database import FILTERS, config_data_from_json, config_data_to_json from .app.mask_config import create_pixel_repair_plan from .backend.scans import ScanReference +from .datautils.xrayutils.corrections import detector as detector_corrections from .datautils.xrayutils.reconstruction import ( _CHECKPOINT_BYTES_PER_ROW, _CheckpointRouter, @@ -1245,23 +1246,22 @@ def _correction_pipeline(config, scan, assets, provenance): if correction.use_mask and "mask" in assets else None ) - static_factor = None - if correction.use_solid_angle: - static_factor = 1.0 / np.asarray( - detector.solidAngleArray(), dtype=np.float64 - ) - provenance.setdefault("factor_uncertainty", {})[ - "solid_angle" - ] = "deterministic-no-uncertainty" - if correction.use_polarization: - polarization = np.asarray(detector.polarizationArray(), dtype=np.float64) - if static_factor is None: - static_factor = 1.0 / polarization - else: - static_factor /= polarization - provenance.setdefault("factor_uncertainty", {})[ - "polarization" - ] = "deterministic-no-uncertainty" + # The per-pixel factors are defined once, in the corrections package, + # and shared with the direct-space integrations. Only their application + # is special here: it is fused into the native pass below. + static_factor = detector_corrections.pixel_factors( + detector, + solid_angle=correction.use_solid_angle, + polarization=correction.use_polarization, + ) + for name, enabled in ( + ("solid_angle", correction.use_solid_angle), + ("polarization", correction.use_polarization), + ): + if enabled: + provenance.setdefault("factor_uncertainty", {})[ + name + ] = "deterministic-no-uncertainty" if static_factor is not None: static_factor = np.ascontiguousarray(static_factor, dtype=np.float64) static_factor_squared = np.square(static_factor) From 4193c80222cafdcf99a6450aab3ed07e7080de8d Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Wed, 9 Sep 2026 17:16:14 -0400 Subject: [PATCH 02/33] feat: reduce integrated intensities to |F_hkl|^2 on one scale --- orgui/app/integration_corrections.py | 20 +- orgui/app/peak1Dintegr.py | 25 +- orgui/app/test/test_scan_mode_equivalence.py | 328 ++++++++++ .../xrayutils/corrections/__init__.py | 29 +- .../xrayutils/corrections/activearea.py | 190 ++++++ .../xrayutils/corrections/measurement.py | 605 ++++++++++++++++++ .../test/test_corrections_activearea.py | 129 ++++ .../test/test_corrections_measurement.py | 346 ++++++++++ .../test/test_corrections_package.py | 51 ++ 9 files changed, 1698 insertions(+), 25 deletions(-) create mode 100644 orgui/app/test/test_scan_mode_equivalence.py create mode 100644 orgui/datautils/xrayutils/corrections/activearea.py create mode 100644 orgui/datautils/xrayutils/corrections/measurement.py create mode 100644 orgui/datautils/xrayutils/test/test_corrections_activearea.py create mode 100644 orgui/datautils/xrayutils/test/test_corrections_measurement.py diff --git a/orgui/app/integration_corrections.py b/orgui/app/integration_corrections.py index dcd51b2..bd9d708 100644 --- a/orgui/app/integration_corrections.py +++ b/orgui/app/integration_corrections.py @@ -43,8 +43,9 @@ ``C_illum_area``. It must not be divided out separately: the numerical active area already contains the same beam/sample overlap integral. Which angular factors a stationary measurement applies -- and that it has no -rod-interception factor, unlike a rocking scan -- follows the z-axis table of -:mod:`~orgui.datautils.xrayutils.corrections.geometry`. +rod-interception factor, unlike a rocking scan -- is decided by +:func:`~orgui.datautils.xrayutils.corrections.measurement.mode_components`, +the one place that mapping exists. The exposure and monitor normalization mirrors the reciprocal-space reconstruction (:mod:`orgui.reconstruction_job`), so a stationary integration @@ -56,7 +57,7 @@ import numpy as np -from ..datautils.xrayutils.corrections import geometry +from ..datautils.xrayutils.corrections import measurement from ..datautils.xrayutils.corrections.normalization import ( normalization_divisor as _divisor_from_counters, ) @@ -161,8 +162,9 @@ def stationary_correction_factors( :param delta: In-plane detector angle per image, in radian. :param gamma: Out-of-plane detector angle per image, in radian. :param bool use_lorentz: Add ``C_Lorentz`` -- the *stationary-mode* - factor :math:`1/\sin\gamma`, not the rocking-scan one. - Stationary integration has no rod-interception factor. + factor :math:`1/\sin\gamma`, not the rocking-scan one, as + :func:`~orgui.datautils.xrayutils.corrections.measurement.mode_components` + decides. Stationary integration has no rod-interception factor. :param bool use_footprint: Add the ``C_illum_area`` divisor and its diagnostic numerator ``C_flux_on_sample``. :param beam_profile: A @@ -204,9 +206,11 @@ def stationary_correction_factors( applied.append("footprint") if use_lorentz: - factors["C_Lorentz"] = np.broadcast_to( - geometry.lorentz_factor(geometry.STATIONARY, gamma=gamma), alpha.shape - ).copy() + components = measurement.mode_components( + measurement.STATIONARY, alpha=alpha, delta=delta, gamma=gamma + ) + for name, value in components.items(): + factors[name] = np.broadcast_to(value, alpha.shape).copy() applied.append("lorentz") return CorrectionFactors(factors, applied) diff --git a/orgui/app/peak1Dintegr.py b/orgui/app/peak1Dintegr.py index 61530c8..0a9ee6c 100644 --- a/orgui/app/peak1Dintegr.py +++ b/orgui/app/peak1Dintegr.py @@ -57,7 +57,10 @@ from .config_data import ConfigData from .. import resources from .. import logger_utils -from ..datautils.xrayutils.corrections import beamprofile, geometry +from ..datautils.xrayutils.corrections import beamprofile +from ..datautils.xrayutils.corrections import ( + measurement as measurement_corrections, +) import numpy as np from scipy import interpolate as interp @@ -1335,21 +1338,19 @@ def integrate(self): # A mu scan rocks the incidence angle, which is how a # reflectivity curve is measured; a th scan rocks the sample. # The two take different Lorentz factors from the z-axis table, - # and neither is the stationary-scan factor. + # and neither is the stationary-scan factor. Which factors each + # mode applies is decided in one place, by mode_components. if curves["axisname"] == "mu": - C_Lor = geometry.lorentz_factor( - geometry.REFLECTIVITY_ROCKING, alpha=alpha - ) + mode = measurement_corrections.REFLECTIVITY_ROCKING elif curves["axisname"] == "th": - C_Lor = geometry.lorentz_factor( - geometry.ROCKING, - alpha=alpha, - delta=delta, - gamma=gamma, - ) + mode = measurement_corrections.ROCKING else: raise NotImplementedError() - C_rod = geometry.rod_interception(gamma) + components = measurement_corrections.mode_components( + mode, alpha=alpha, delta=delta, gamma=gamma + ) + C_Lor = components["C_Lorentz"] + C_rod = components["C_rod"] else: C_Lor = 1.0 C_rod = 1.0 diff --git a/orgui/app/test/test_scan_mode_equivalence.py b/orgui/app/test/test_scan_mode_equivalence.py new file mode 100644 index 0000000..54bb086 --- /dev/null +++ b/orgui/app/test/test_scan_mode_equivalence.py @@ -0,0 +1,328 @@ +"""Rocking and stationary integration must give the same structure factor. + +`Issue #82 `_: once every scaling +factor and experimental correction is accounted for, a rocking scan and a +stationary area-detector measurement of the same reflection must yield the +same :math:`|F_{hkl}|^2`; only the resolution differs. + +These tests build simulated data for exactly that comparison. One rod with a +known :math:`|F_{hkl}|^2(l)` is measured twice -- as a series of rocking scans +and as a stationary *l* scan -- using +:func:`orgui.datautils.xrayutils.corrections.measurement.integrated_intensity` as +the forward model, i.e. Vlieg's equations 42 and 54 written out. The simulated +counts are then reduced twice: through +:mod:`orgui.datautils.xrayutils.corrections.measurement`, which must recover the +input, and through the two correction paths orGUI ships today +(:func:`orgui.app.peak1Dintegr._compute_rocking_integration` and +:mod:`orgui.app.integration_corrections`), whose disagreement is pinned to the +exact factor it is. + +Everything that is not under test is left out deliberately. The polarization +factor and the footprint correction are absent from the simulation because +both depend only on the incidence and detector angles: they are identical for +the two modes at the same reflection, cancel from their ratio, and are +divided out per pixel long before the code under test here. Background +subtraction and error propagation are covered by ``test_peak1Dintegr.py``; a +flat background is included only so that the real aggregation code path is +exercised. + +``doc/design/ctr_structure_factor_scale.md`` records the analysis. +""" + +import numpy as np +import pytest + +from orgui.app import integration_corrections as ic +from orgui.app.peak1Dintegr import _compute_rocking_integration +from orgui.datautils.xrayutils import HKLVlieg +from orgui.datautils.xrayutils.corrections import measurement as ii + +#: Fixed incidence angle of the simulated z-axis scans, in radian. +ALPHA_IN = np.deg2rad(0.6) + +#: Per-frame counting time and monitor of the two simulated measurements. +ROCKING_EXPOSURE, ROCKING_MONITOR = 0.4, 3.0 +STATIONARY_EXPOSURE, STATIONARY_MONITOR = 2.0, 7.0 + +#: Flat background under every simulated rocking frame, in counts. +BACKGROUND = 50.0 + +#: Illuminated area (square meter) and flux density (photons / s / m^2). +ACTIVE_AREA, FLUX_DENSITY = 5.73e-7, 1e16 + + +@pytest.fixture(scope="module") +def rod(): + """A (1, 0, l) rod of Pt(111) in the z-axis geometry at 17.7 keV. + + :returns: ``(l, alpha, delta, gamma, f2, wavelength, unitcell_area)`` with + the angles in radian, ``f2`` the true :math:`|F_{hkl}|^2` in electron + units squared, the wavelength in Angstrom and the unit-cell area in + square Angstrom. + """ + lattice = HKLVlieg.Lattice([2.7748, 2.7748, 6.7964], [90.0, 90.0, 120.0]) + ub = HKLVlieg.UBCalculator(lattice, 17.7) + ub.defaultU_GID() + angles = HKLVlieg.VliegAngles(ub) + + ell = np.linspace(0.4, 3.0, 12) + hkl = np.vstack([np.ones_like(ell), np.zeros_like(ell), ell]) + computed = angles.anglesZmode(hkl, ALPHA_IN, fixed="in") + alpha, delta, gamma = computed[:, 0], computed[:, 1], computed[:, 2] + + # A crystal-truncation-rod-like profile: sharp at the bulk Bragg + # positions, weak in between, so a scale error that varies along the rod + # is visible as a shape change and not only as an offset. + f2 = 100.0 / (np.sin(np.pi * ell / 2.0) ** 2 + 0.02) + return ell, alpha, delta, gamma, f2, ub.getLambda(), lattice.uc_area + + +def _simulate_stationary(rod): + """Counts of a stationary *l* scan of the rod. + + :returns: The per-frame background-subtracted counts, in the units + :meth:`orgui.app.orGUI.orGUI.integrateROI` hands to + :mod:`orgui.app.integration_corrections`. + """ + _, alpha, delta, gamma, f2, wavelength, uc_area = rod + intensity = ii.integrated_intensity( + f2, + ii.STATIONARY, + gamma=gamma, + wavelength=wavelength, + unitcell_area=uc_area, + active_area=ACTIVE_AREA, + flux_density=FLUX_DENSITY, + ) + return intensity * STATIONARY_EXPOSURE * STATIONARY_MONITOR + + +def _simulate_rocking(rod, acceptance, axis=None, width=0.06): + """Per-frame rocking curves of the rod. + + The rocking profile integrates to one over the rocking angle *in radian*, + so the trapezoidal integral of the returned curves over the axis in + degrees is the Vlieg integrated intensity times ``180/pi``. + + :param rod: The ``rod`` fixture. + :param acceptance: Out-of-plane acceptance of the region of interest per + rod point, in radian. + :param axis: Rocking axis in degrees; a default symmetric axis is used + when omitted. + :param float width: Standard deviation of the rocking profile, in degrees. + :returns: ``(axis, curves)`` with ``curves`` of shape ``(n_l, n_axis)``. + """ + _, alpha, delta, gamma, f2, wavelength, uc_area = rod + if axis is None: + axis = np.linspace(-1.0, 1.0, 801) + intensity = ii.integrated_intensity( + f2, + ii.ROCKING, + alpha=alpha, + delta=delta, + gamma=gamma, + detector_acceptance=acceptance, + wavelength=wavelength, + unitcell_area=uc_area, + active_area=ACTIVE_AREA, + flux_density=FLUX_DENSITY, + ) + profile = np.exp(-0.5 * (axis / width) ** 2) / (width * np.sqrt(2.0 * np.pi)) + profile = profile * np.rad2deg(1.0) # unit integral in radian + counts = intensity * ROCKING_EXPOSURE * ROCKING_MONITOR + return axis, counts[:, None] * profile[None, :] + BACKGROUND + + +def _roi_info(size): + """Signal and background windows, in degrees, for every rod point. + + The background window sits ten standard deviations out, where the + simulated profile has died away, so ``croibg`` is the signal alone. + """ + return { + "sig_1": {"from": np.full(size, -0.5), "to": np.full(size, 0.5)}, + "bg_1": {"from": np.full(size, -1.0), "to": np.full(size, -0.6)}, + } + + +def _orgui_rocking_f2(rod, acceptance): + """``F2_hkl`` as :mod:`orgui.app.peak1Dintegr` computes it today.""" + ell, alpha, delta, gamma, _, _, _ = rod + axis, curves = _simulate_rocking(rod, acceptance) + shape = curves.shape + lorentz = np.broadcast_to( + (1.0 / (np.sin(delta) * np.cos(alpha) * np.cos(gamma)))[:, None], shape + ) + rod_interception = np.broadcast_to(np.cos(gamma)[:, None], shape) + result = _compute_rocking_integration( + ell, + axis, + curves, + np.sqrt(np.abs(curves)), + _roi_info(ell.size), + {}, + True, + False, + C_Lor=lorentz, + C_rod=rod_interception, + ) + return result["F2_hkl"] + + +def _orgui_stationary_f2(rod): + """``F2_hkl`` as :mod:`orgui.app.integration_corrections` computes it.""" + ell, alpha, delta, gamma, _, _, _ = rod + counts = _simulate_stationary(rod) + factors = ic.stationary_correction_factors( + alpha, + delta, + gamma, + use_lorentz=True, + normalization=np.full( + ell.size, STATIONARY_EXPOSURE * STATIONARY_MONITOR + ), + ) + intensity, errors = ic.apply_stationary_corrections( + counts, np.sqrt(counts), factors + ) + return ic.structure_factor(intensity, errors, factors)[0] + + +def test_the_unified_reduction_recovers_one_structure_factor(rod): + """Both simulated measurements reduce to the input ``|F|^2``. + + This is what issue #82 asks for, and it holds with the acceptance of the + region of interest deliberately different at every point of the rod and + the two measurements taken with different counting times and monitors. + """ + ell, alpha, delta, gamma, f2, wavelength, uc_area = rod + acceptance = np.deg2rad(0.35) * np.linspace(0.7, 1.6, ell.size) + scale = dict( + wavelength=wavelength, + unitcell_area=uc_area, + active_area=ACTIVE_AREA, + flux_density=FLUX_DENSITY, + ) + + axis, curves = _simulate_rocking(rod, acceptance) + result = _compute_rocking_integration( + ell, + axis, + curves - BACKGROUND, + np.sqrt(np.abs(curves)), + _roi_info(ell.size), + {}, + False, + False, + ) + f2_rocking = ii.structure_factor_squared( + ii.normalized_intensity( + result["croibg"], + exposure_time=ROCKING_EXPOSURE, + monitor=ROCKING_MONITOR, + angle_unit="deg", + ), + ii.ROCKING, + alpha=alpha, + delta=delta, + gamma=gamma, + detector_acceptance=acceptance, + **scale, + ) + f2_stationary = ii.structure_factor_squared( + ii.normalized_intensity( + _simulate_stationary(rod), + exposure_time=STATIONARY_EXPOSURE, + monitor=STATIONARY_MONITOR, + ), + ii.STATIONARY, + gamma=gamma, + **scale, + ) + + np.testing.assert_allclose(f2_stationary, f2, rtol=1e-12) + np.testing.assert_allclose(f2_rocking, f2, rtol=1e-6) + np.testing.assert_allclose(f2_rocking, f2_stationary, rtol=1e-6) + + +def test_the_stationary_path_recovers_the_rod_up_to_one_constant(rod): + """orGUI's stationary path already has the right shape. + + Its Lorentz factor is the published one and its normalization divides by + the counting time and the monitor, so what it produces differs from the + true ``|F|^2`` by a single number -- the absolute scale of issue #15 -- + and not by anything that varies along the rod. + """ + f2 = rod[4] + + ratio = _orgui_stationary_f2(rod) / f2 + + np.testing.assert_allclose(ratio, ratio[0], rtol=1e-12) + + +def test_rocking_and_stationary_paths_differ_by_the_missing_normalizations(rod): + """The gap between the two paths, pinned to the factor it is. + + With the acceptance held constant the two paths differ by exactly + ``exposure * monitor * Delta_gamma_in_degrees``: the rocking path applies + neither the exposure/monitor normalization nor the out-of-plane + acceptance, and integrates the rocking angle in degrees rather than + radian. The degree-to-radian factor and the acceptance combine into the + acceptance expressed in degrees. + + This test characterizes today's behavior. It must be updated -- to a + plain equality -- when the rocking path adopts the unified reduction. + """ + ell = rod[0] + acceptance = np.full(ell.size, np.deg2rad(0.35)) + + ratio = _orgui_rocking_f2(rod, acceptance) / _orgui_stationary_f2(rod) + expected = ROCKING_EXPOSURE * ROCKING_MONITOR * np.rad2deg(acceptance) + + np.testing.assert_allclose(ratio, expected, rtol=1e-6) + + +def test_a_resized_region_of_interest_distorts_the_rocking_rod(rod): + """The missing acceptance is not merely an overall scale factor. + + :func:`orgui.app.ROIutils.calc_corrections` sizes regions of interest + from the projected sample size and the parallax at each detector + position, so their out-of-plane acceptance changes along a scan. Without + the ``1/Delta_gamma`` divisor that change is carried straight into + ``F2_hkl``, so the same rod measured with a resized region of interest + comes out with a different *shape*, not just a different scale. + """ + ell = rod[0] + fixed = np.full(ell.size, np.deg2rad(0.35)) + resized = np.deg2rad(0.35) * np.linspace(0.7, 1.6, ell.size) + + with_fixed = _orgui_rocking_f2(rod, fixed) + with_resized = _orgui_rocking_f2(rod, resized) + + carried = with_resized / with_fixed + shape_change = carried / carried[0] + np.testing.assert_allclose( + shape_change, np.linspace(0.7, 1.6, ell.size) / 0.7, rtol=1e-6 + ) + assert shape_change.max() / shape_change.min() > 2.0 + + +def test_the_stationary_path_assumes_a_slit_independent_active_area(rod): + """What orGUI's missing area correction implies about the setup. + + orGUI divides out the numerical beam-profile factor but not Vlieg's + :math:`C_\\mathrm{area} = 1/(\\sin\\delta\\cos(\\alpha-\\beta_ + \\mathrm{in}))`. That is right for an area detector with open post-sample + slits, where the illuminated footprint and not the slits defines the + active area -- the simulations above, which come out exact. It is wrong + for a slit-limited setup, where the active area varies with + :math:`\\delta` along the rod and the omission shows up as a + rod-dependent shape error. + """ + _, alpha, delta, gamma, f2, _, _ = rod + slit_limited = 1.0 / (np.sin(delta) * np.cos(alpha - alpha)) + + ratio = _orgui_stationary_f2(rod) * slit_limited / f2 + + spread = ratio.max() / ratio.min() - 1.0 + assert spread > 1e-3, "delta must vary enough along the rod to see this" diff --git a/orgui/datautils/xrayutils/corrections/__init__.py b/orgui/datautils/xrayutils/corrections/__init__.py index 4e6e6b6..f530ffd 100644 --- a/orgui/datautils/xrayutils/corrections/__init__.py +++ b/orgui/datautils/xrayutils/corrections/__init__.py @@ -23,11 +23,20 @@ # ###########################################################################*/ r"""Everything that turns detector counts into a structure factor. -One home for the correction factors of E. Vlieg, *J. Appl. Cryst.* **30** -(1997) 532, in the two-dimensional-detector form of J. Drnec *et al.*, -*J. Appl. Cryst.* **47** (2014) 365, so that a rocking scan, a stationary -area-detector measurement and a reciprocal-space reconstruction of the same -sample are corrected by the same code rather than by three copies of it. +One home for the correction factors of + +.. math:: + + I = \Phi_0 \frac{r_e^2 A \lambda^2}{A_u^2}\, + |F_{hkl}|^2 \, P \, \eta \, C_\mathrm{det} + +so that a rocking scan, a stationary area-detector measurement, a +reflectivity curve and a reciprocal-space reconstruction of the same sample +are corrected by the same code and land on the same scale. The measurement +equation is E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532, in the +two-dimensional-detector form of J. Drnec *et al.*, +*J. Appl. Cryst.* **47** (2014) 365; :mod:`~.measurement` documents it in +full. The split between the modules is by *what a factor depends on*, which is also what makes each of them testable on its own: @@ -38,12 +47,18 @@ :mod:`~.beamprofile` The vertical profile of the incident beam and its integrals over a finite sample. +:mod:`~.activearea` + The illuminated active surface area :math:`A`, in square meter, in both + the slit-limited and the beam-limited case. :mod:`~.detector` Per-pixel factors of a detector image: solid angle and polarization. :mod:`~.normalization` Counting time and monitor. :mod:`~.roi` Reducing per-pixel factors onto a summed region of interest. +:mod:`~.measurement` + Which of the above apply to which kind of scan, and the reduction to + :math:`|F_{hkl}|^2` and to absolute reflectivity. Everything here is physics: arrays and scalars in, arrays out. Nothing in this package reads a scan object, a configuration file or a GUI widget. @@ -57,17 +72,21 @@ """ from . import ( # noqa: F401 + activearea, beamprofile, detector, geometry, + measurement, normalization, roi, ) __all__ = [ + "activearea", "beamprofile", "detector", "geometry", + "measurement", "normalization", "roi", ] diff --git a/orgui/datautils/xrayutils/corrections/activearea.py b/orgui/datautils/xrayutils/corrections/activearea.py new file mode 100644 index 0000000..70664df --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/activearea.py @@ -0,0 +1,190 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""The illuminated active surface area :math:`A`, in square meter. + +:math:`A` is the surface area that actually contributes to the measured +counts. It multiplies the integrated intensity of every scan mode -- Vlieg +equation 42 for a rocking scan, 54 for a stationary one, 62 for reflectivity +-- so it sets the absolute scale of :math:`|F_{hkl}|^2` but cancels from the +ratio of two modes measured at the same incidence angle. + +Which of two limits applies is an experimental question, not a preference: + +**Slit limited.** Post-sample slits narrower than the footprint cut the +visible length along the surface, and the area is Vlieg equation 37, +:math:`A = s_1 s_2 / (\sin\delta\,\cos(\alpha-\beta_\mathrm{in}))`. It varies +with the in-plane detector angle and therefore along a rod. +:func:`slit_limited_area`. + +**Beam and sample limited.** With an area detector and open slits -- orGUI's +usual configuration -- the whole illuminated footprint is seen and the area +is set by the beam profile and the sample, with no :math:`\delta` dependence. +:func:`beam_limited_area`. + +Only the second is two-dimensional in the sense that matters: the beam's +*vertical* extent is what the projection onto the surface stretches, and it +is described in full by a +:class:`~orgui.datautils.xrayutils.corrections.beamprofile.BeamProfile`. The +horizontal extent is not projected and enters as a plain width, which is the +one number needed to turn the dimensionless factor a beam profile returns +into an absolute area: + +.. math:: + + A(\alpha) = w \, L \, C_\mathrm{illum}(\alpha, L) + +with :math:`w` the horizontal beam width, :math:`L` the sample length along +the beam and :math:`C_\mathrm{illum}` the profile's +:meth:`~.beamprofile.BeamProfile.illuminated_area_fraction`, which is already +the mean of :math:`p(z)/p_\mathrm{max}` over the projected sample footprint. + +.. note:: + + The familiar closed form :math:`A = w \min(L, h/\sin\alpha)` is exactly + this expression for a + :func:`~.beamprofile.top_hat_profile` of full width :math:`h`, and only + for that profile. A Gaussian of the same width differs from it by up to + 19 % at grazing incidence and by 6.5 % once the beam is fully on the + sample, so the closed form is not a general shortcut and this module does + not offer one. +""" + +import numpy as np + +from . import geometry + +__all__ = [ + "beam_limited_area", + "footprint_length", + "slit_limited_area", +] + + +def footprint_length(alpha, beam_height, sample_length): + r"""Illuminated length along the beam, in meter. + + :math:`\min(L, h/\sin\alpha)`, the geometric footprint of a beam of + uniform vertical size. It is the length :func:`beam_limited_area` would + use for a top-hat beam, and is kept as a separate function because it is + the quantity to report when describing an experiment, not because + :func:`beam_limited_area` needs it. + + :param alpha: Incidence angle, in radian. + :param float beam_height: Vertical beam size, in meter. + :param float sample_length: Sample length along the beam, in meter. + :returns: The footprint length in meter, broadcast over ``alpha``. + :rtype: numpy.ndarray + :raises ValueError: If a size is not positive. + """ + alpha = np.asarray(alpha, dtype=np.float64) + for name, value in ( + ("beam_height", beam_height), + ("sample_length", sample_length), + ): + if not float(value) > 0: + raise ValueError(f"{name} must be positive, in meter, got {value!r}") + sin_alpha = np.sin(alpha) + projected = np.divide( + float(beam_height), + sin_alpha, + out=np.full(sin_alpha.shape, np.inf), + where=sin_alpha > 0, + ) + return np.minimum(float(sample_length), projected) + + +def beam_limited_area(alpha, beam_width, sample_length, profile): + r"""Active area of an open-slit measurement, in square meter. + + :math:`A = w\,L\,C_\mathrm{illum}(\alpha, L)`. The vertical direction + comes from the beam profile, which already integrates :math:`p(z)` over + the projected sample and normalizes to the peak density; the horizontal + direction is the plain beam width, since it is not projected. + + :param alpha: Incidence angle, in radian. + :param float beam_width: Horizontal beam size, in meter. + :param float sample_length: Sample length along the beam, in meter. + :param profile: A + :class:`~.beamprofile.BeamProfile` describing the vertical beam + profile. + :returns: The active area in square meter, broadcast over ``alpha``. + :rtype: numpy.ndarray + :raises ValueError: If a size is not positive, or no profile is given. + """ + if profile is None: + raise ValueError( + "the beam-limited area needs a beam profile; a uniform beam of " + "full width h is beamprofile.top_hat_profile(h)" + ) + for name, value in ( + ("beam_width", beam_width), + ("sample_length", sample_length), + ): + if not float(value) > 0: + raise ValueError(f"{name} must be positive, in meter, got {value!r}") + fraction = profile.illuminated_area_fraction(alpha, float(sample_length)) + return float(beam_width) * float(sample_length) * np.asarray(fraction) + + +def slit_limited_area(delta, slit_width, slit_height, alpha=None, beta_in=None): + r"""Active area seen through post-sample slits, in square meter. + + Vlieg equation 37 with equation 38, + :math:`A = s_1 s_2 / (\sin\delta \cos(\alpha - \beta_\mathrm{in}))`. The + :math:`1/\sin\delta` is :func:`~.geometry.area_correction`, the row the + ANA/ROD z-axis table lists; the remaining cosine is one in the z-axis + mode, where the incidence angle is the angle to the surface + (:math:`\alpha = \beta_\mathrm{in}`), which is why the table omits it. + + :param delta: In-plane detector angle, in radian. + :param float slit_width: Beam size across the scattering plane + (Vlieg's :math:`s_1`), in meter. + :param float slit_height: Detector slit opening projected onto the + surface (Vlieg's :math:`s_2`), in meter. + :param alpha: Incidence angle, in radian. Only needed together with + ``beta_in``. + :param beta_in: Angle of the incident beam to the surface, in radian. + ``None`` means the z-axis mode, ``beta_in = alpha``, and the cosine + drops out. + :returns: The active area in square meter, broadcast over the inputs. + :rtype: numpy.ndarray + :raises ValueError: If a slit size is not positive, or ``beta_in`` is + given without ``alpha``. + """ + for name, value in ( + ("slit_width", slit_width), + ("slit_height", slit_height), + ): + if not float(value) > 0: + raise ValueError(f"{name} must be positive, in meter, got {value!r}") + tilt = 1.0 + if beta_in is not None: + if alpha is None: + raise ValueError("beta_in needs alpha to form alpha - beta_in") + tilt = np.cos( + np.asarray(alpha, dtype=np.float64) + - np.asarray(beta_in, dtype=np.float64) + ) + projected = geometry.area_correction(delta) / tilt + return float(slit_width) * float(slit_height) * projected diff --git a/orgui/datautils/xrayutils/corrections/measurement.py b/orgui/datautils/xrayutils/corrections/measurement.py new file mode 100644 index 0000000..c07628d --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/measurement.py @@ -0,0 +1,605 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""Integrated intensities and structure factors on one common scale. + +E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532 writes the integrated intensity +of a surface reflection as one prefactor times :math:`|F_{hkl}|^2` times a +product of correction factors. Rocking scans, stationary area-detector +measurements and reflectivity differ only in *which* correction factors +appear. Once every factor is divided out, the three modes land on the same +scale and can be refined as a single data set -- the point of Vlieg's section +5 and of section 3.4 of J. Drnec *et al.*, *J. Appl. Cryst.* **47** (2014) +365. + +This module implements that reduction as pure functions, in one place, so +that a rocking scan and a stationary scan of the same rod are guaranteed to +be normalized identically. It is deliberately independent of how the counts +were obtained: :mod:`orgui.app.peak1Dintegr` and :mod:`orgui.app.orGUI` +produce the integrated counts, this module turns them into +:math:`|F_{hkl}|^2`. + +The master equation +------------------- + +With :math:`I` the *normalized* integrated intensity of +:func:`normalized_intensity` -- counts per second per monitor unit, already +divided by the polarization factor :math:`P` -- + +.. math:: + + I = \Phi_0 \frac{r_e^2 A \lambda^2}{A_u^2}\, + |F_{hkl}|^2 \, \eta \, C_\mathrm{det} + +where + +``Phi_0`` + incident flux *density*, photons per second and square meter, so that + :math:`\Phi_0 A_0` is the total flux on the sample. +``r_e`` + classical electron radius, :data:`CLASSICAL_ELECTRON_RADIUS`, in meter. +``A`` + illuminated *active* surface area, in square meter. This is Vlieg's + :math:`A = A_0 C_\mathrm{area} C_\mathrm{beam}` (equation 41): the beam + cross-section corrected for the projection onto the surface and for the + beam profile over a finite sample. See :ref:`active-area` below. +``lambda`` + wavelength, in Angstrom. +``A_u`` + area of the surface unit cell, in square Angstrom + (:attr:`orgui.datautils.xrayutils.HKLVlieg.Lattice.uc_area`). +``eta`` + the angle-dependent factor of the measurement mode, + :func:`angular_factor`. +``C_det`` + in-plane detector acceptance (Vlieg equations 24, 27, 28); ``1`` when the + region of interest is wide enough to contain the whole in-plane peak + profile. Not modelled here -- pass a measured or fitted value. + +:math:`|F_{hkl}|^2` then comes out in electron units squared, the same scale +a :class:`~orgui.datautils.xrayutils.CTRcalc.SXRDCrystal` calculates in. + +The mode-dependent factor +------------------------- + +============================== ============================================ +Rocking scan (``th``/omega) :math:`\eta = L_\varphi\, C_\mathrm{rod}\, + \Delta\gamma` +Reflectivity rocking (``mu``) :math:`\eta = L_r\, C_\mathrm{rod}\, + \Delta\gamma` +Stationary area detector :math:`\eta = L_s` +Specular reflectivity :math:`\eta = L_s` with + :math:`\gamma = \alpha` +============================== ============================================ + +with :math:`L_\varphi`, :math:`L_r`, :math:`L_s` and :math:`C_\mathrm{rod}` +the z-axis entries implemented in :mod:`~.geometry`. This table is the single +place the choice is made: :func:`mode_components` names the factors a mode +applies, and both the rocking and the stationary integration ask it rather +than selecting a Lorentz factor of their own. + +Two factors of the rocking expression are easy to lose and are the reason a +rocking scan and a stationary scan of the same rod do not otherwise agree: + +* the rocking angle must be integrated in **radian**. Integrating in degrees + scales every rocking structure factor by :math:`180/\pi`. +* :math:`\Delta\gamma`, the **out-of-plane angular acceptance** of the + region of interest, in radian. A rocking scan intercepts a slice of rod + whose length is proportional to :math:`\Delta\gamma` (Vlieg equation 20), + so its integrated intensity is too; a stationary measurement intercepts the + whole rod cross-section and carries no such factor. Because orGUI sizes + regions of interest per detector position + (:func:`orgui.app.ROIutils.calc_corrections`), :math:`\Delta\gamma` is not + even constant within one scan. + +.. _active-area: + +The active area +--------------- + +:math:`A` is whatever surface area actually contributes to the measured +counts. Which of two limits applies is an experimental question, and +:mod:`~.activearea` builds both: :func:`~.activearea.slit_limited_area` when +post-sample slits cut the footprint, :func:`~.activearea.beam_limited_area` +for the open-slit area-detector case orGUI usually runs in. + +Either way :math:`A` is the same for a rocking and for a stationary +measurement at the same incidence angle, so it cancels from their ratio and +cannot be the reason the two disagree. It does set the absolute scale. +""" + +import numpy as np + +from . import geometry + +__all__ = [ + "CLASSICAL_ELECTRON_RADIUS", + "REFLECTIVITY_ROCKING", + "ROCKING", + "SPECULAR", + "STATIONARY", + "angular_factor", + "integrated_intensity", + "mode_components", + "normalized_intensity", + "reflectivity_from_structure_factor", + "scale_factor", + "structure_factor_from_reflectivity", + "structure_factor_squared", +] + +#: Classical electron radius in meter (CODATA 2018). +CLASSICAL_ELECTRON_RADIUS = 2.8179403262e-15 + +#: Rocking scan about the sample rotation; see :func:`angular_factor`. +ROCKING = geometry.ROCKING + +#: Rocking scan about the incidence angle, how a reflectivity curve is +#: measured; see :func:`angular_factor`. +REFLECTIVITY_ROCKING = geometry.REFLECTIVITY_ROCKING + +#: Stationary measurement on an area detector; see :func:`angular_factor`. +STATIONARY = geometry.STATIONARY + +#: Specular reflectivity: the stationary factor at ``gamma = alpha``. +SPECULAR = "specular" + +#: Modes whose integrated intensity is proportional to the out-of-plane +#: acceptance of the region of interest, because they intercept a slice of +#: rod rather than its whole cross-section. +_ROCKING_MODES = (ROCKING, REFLECTIVITY_ROCKING) + +_ANGSTROM = 1e-10 + + +def _reflectivity_prefactor(wavelength, unitcell_area): + r""":math:`r_e^2\lambda^2/A_u^2`, dimensionless. + + The same combination :func:`scale_factor` builds, without the flux and + the area, which is what Vlieg's reflectivity expression (equation 63) + needs. Kept separate so the reflectivity functions do not depend on + :func:`scale_factor`'s unit defaults being ``1``. + + :param wavelength: X-ray wavelength, in Angstrom. + :param unitcell_area: Surface unit-cell area, in square Angstrom. + :rtype: numpy.ndarray + :raises ValueError: If either argument is not positive. + """ + wavelength = np.asarray(wavelength, dtype=np.float64) + unitcell_area = np.asarray(unitcell_area, dtype=np.float64) + if np.any(wavelength <= 0): + raise ValueError("wavelength must be positive, in Angstrom") + if np.any(unitcell_area <= 0): + raise ValueError("unitcell_area must be positive, in square Angstrom") + lam = wavelength * _ANGSTROM + a_u = unitcell_area * _ANGSTROM**2 + return CLASSICAL_ELECTRON_RADIUS**2 * lam**2 / a_u**2 + + +def normalized_intensity(counts, exposure_time=1.0, monitor=1.0, angle_unit=None): + r"""Counts brought to per second, per monitor unit, per radian. + + Both Vlieg's rocking-scan expression (equation 42) and his stationary one + (equation 54) contain the incident flux and the counting time, so neither + an integrated rocking curve nor a stationary frame means anything until + it is divided by the counting time and by whatever monitor the flux is + tracked with. Doing it in one place is what lets the two modes share a + scale. + + :param counts: Background-subtracted integrated counts. For a stationary + measurement, the sum over the region of interest of one frame. For a + rocking scan, the integral of the per-frame counts over the rocking + angle, in counts times ``angle_unit``. + :param exposure_time: Counting time of a single frame, in seconds. A + rocking scan uses the *per-frame* time, not the sum over the scan: + the rocking angle is the integration variable, the time is not. + :param monitor: Monitor counter value the flux is normalized against. + ``1.0`` leaves the intensity in counts per second. + :param str angle_unit: ``None`` for a stationary sum, ``'rad'`` or + ``'deg'`` for a rocking-scan integral. ``'deg'`` converts to radian, + which is what :func:`angular_factor` and the published equations + assume. + :returns: The normalized intensity, broadcast over the inputs. + :rtype: numpy.ndarray + :raises ValueError: If ``angle_unit`` is not ``None``, ``'rad'`` or + ``'deg'``, or if a divisor is not finite and non-zero. + """ + counts = np.asarray(counts, dtype=np.float64) + exposure_time = np.asarray(exposure_time, dtype=np.float64) + monitor = np.asarray(monitor, dtype=np.float64) + if np.any(exposure_time <= 0) or not np.all(np.isfinite(exposure_time)): + raise ValueError("exposure_time must be finite and positive") + if np.any(monitor == 0) or not np.all(np.isfinite(monitor)): + raise ValueError("monitor must be finite and non-zero") + + if angle_unit is None: + scale = 1.0 + elif angle_unit == "rad": + scale = 1.0 + elif angle_unit == "deg": + scale = np.deg2rad(1.0) + else: + raise ValueError( + f"unknown angle_unit {angle_unit!r}; expected None, 'rad' or 'deg'" + ) + return counts * scale / (exposure_time * monitor) + + +def _resolve_specular(mode, alpha, gamma): + """Rewrite :data:`SPECULAR` as the stationary case at ``gamma = alpha``. + + :returns: ``(mode, gamma)`` with the specular mode replaced. + :rtype: tuple + :raises ValueError: If ``alpha`` is missing, or ``gamma`` contradicts the + specular condition. + """ + if mode != SPECULAR: + return mode, gamma + if alpha is None: + raise ValueError("the specular factor needs alpha") + if gamma is not None and not np.allclose(gamma, alpha): + raise ValueError( + "the specular factor is the stationary one at gamma = alpha; " + "use STATIONARY for a non-specular exit angle" + ) + return STATIONARY, alpha + + +def mode_components(mode, alpha=None, delta=None, gamma=None): + r"""Named angular factors a measurement mode applies. + + The single place the choice of correction factors is tied to the kind of + scan. Both the rocking and the stationary integration ask this rather + than selecting a Lorentz factor themselves, and they store the returned + names beside ``F2_hkl``, so the saved data records which mode produced + it. + + Returned keys: + + ``C_Lorentz`` + :math:`L_\varphi = 1/(\sin\delta\cos\alpha\cos\gamma)` for + :data:`ROCKING`, :math:`L_r = 1/\sin 2\alpha` for + :data:`REFLECTIVITY_ROCKING`, :math:`L_s = 1/\sin\gamma` for + :data:`STATIONARY` and :data:`SPECULAR`. + ``C_rod`` + :math:`\cos\gamma`, the rod interception, for the two rocking modes + only. A stationary measurement integrates across the whole rod and + has none (Vlieg 1997, before equation 54). + + :param str mode: :data:`ROCKING`, :data:`REFLECTIVITY_ROCKING`, + :data:`STATIONARY` or :data:`SPECULAR`. + :param alpha: Incidence angle, in radian. + :param delta: In-plane detector angle, in radian. + :param gamma: Out-of-plane detector angle -- the exit angle in the z-axis + geometry -- in radian. + :returns: Mapping of factor name to value, broadcast over the inputs. + :rtype: dict + :raises ValueError: If ``mode`` is unknown or a required angle is + missing. + """ + mode, gamma = _resolve_specular(mode, alpha, gamma) + if mode == ROCKING: + return { + "C_Lorentz": geometry.lorentz_factor( + geometry.ROCKING, alpha=alpha, delta=delta, gamma=gamma + ), + "C_rod": geometry.rod_interception(gamma), + } + if mode == REFLECTIVITY_ROCKING: + return { + "C_Lorentz": geometry.lorentz_factor( + geometry.REFLECTIVITY_ROCKING, alpha=alpha + ), + "C_rod": geometry.rod_interception(gamma), + } + if mode == STATIONARY: + return { + "C_Lorentz": geometry.lorentz_factor(geometry.STATIONARY, gamma=gamma) + } + raise ValueError( + f"unknown mode {mode!r}; expected one of {ROCKING!r}, " + f"{REFLECTIVITY_ROCKING!r}, {STATIONARY!r} or {SPECULAR!r}" + ) + + +def angular_factor( + mode, alpha=None, delta=None, gamma=None, detector_acceptance=None +): + r"""Angle-dependent factor :math:`\eta` of the integrated intensity. + + The product of the :func:`mode_components` of the mode and, for the two + rocking modes, the out-of-plane acceptance: :data:`ROCKING` gives + :math:`L_\varphi\,C_\mathrm{rod}\,\Delta\gamma` (Vlieg equations 16, 20 + and 23, Drnec equation 3), :data:`STATIONARY` gives + :math:`L_s = 1/\sin\gamma` (Vlieg equation 53, Drnec equation 5), and + :data:`SPECULAR` is the stationary factor at :math:`\gamma = \alpha`, + the case Vlieg treats in his section 3.2. + + :param str mode: :data:`ROCKING`, :data:`REFLECTIVITY_ROCKING`, + :data:`STATIONARY` or :data:`SPECULAR`. + :param alpha: Incidence angle, in radian. Required for the rocking modes + and for :data:`SPECULAR`. + :param delta: In-plane detector angle, in radian. Required for + :data:`ROCKING`. + :param gamma: Out-of-plane detector angle, in radian. Required for + :data:`ROCKING`, :data:`REFLECTIVITY_ROCKING` and + :data:`STATIONARY`. + :param detector_acceptance: Out-of-plane angular acceptance + :math:`\Delta\gamma` of the region of interest, in **radian**. + Required for the rocking modes, rejected otherwise: a stationary + measurement intercepts the whole rod and does not depend on it. + :returns: :math:`\eta`, broadcast over the inputs. + :rtype: numpy.ndarray + :raises ValueError: If ``mode`` is unknown, a required angle is missing, + or ``detector_acceptance`` is given for a mode that has none. + """ + resolved, _ = _resolve_specular(mode, alpha, gamma) + if resolved in _ROCKING_MODES: + if detector_acceptance is None: + raise ValueError( + "a rocking scan needs the out-of-plane acceptance of its " + "region of interest, in radian; its integrated intensity is " + "proportional to it (Vlieg 1997, equations 20 and 42)" + ) + acceptance = np.asarray(detector_acceptance, dtype=np.float64) + if np.any(acceptance <= 0) or not np.all(np.isfinite(acceptance)): + raise ValueError("detector_acceptance must be finite and positive") + elif detector_acceptance is not None: + raise ValueError( + "a stationary measurement intercepts the whole rod cross " + "section and has no detector-acceptance factor " + "(Vlieg 1997, equation 54)" + ) + else: + acceptance = 1.0 + + factor = acceptance + for value in mode_components( + mode, alpha=alpha, delta=delta, gamma=gamma + ).values(): + factor = factor * value + return factor + + +def scale_factor(wavelength, unitcell_area, active_area=1.0, flux_density=1.0): + r"""Mode-independent prefactor + :math:`\Phi_0 r_e^2 A \lambda^2 / A_u^2`, in 1/s. + + Leaving ``flux_density`` and ``active_area`` at ``1`` gives the relative + scale factor, which is all that is needed to put different scan modes of + one experiment on a *common* scale. Supplying the measured flux density + and the illuminated area additionally puts them on the *absolute* one, so + that :func:`structure_factor_squared` returns electron units. + + :param wavelength: X-ray wavelength, in Angstrom. + :param unitcell_area: Area :math:`A_u` of the surface unit cell, in + square Angstrom. + :param active_area: Illuminated active surface area :math:`A`, in square + meter; see :mod:`~.activearea`. + :param flux_density: Incident flux density :math:`\Phi_0`, in photons per + second and square meter, so that ``flux_density * A_0`` is the flux + on the sample. + :returns: The prefactor, in 1/s, broadcast over the inputs. + :rtype: numpy.ndarray + :raises ValueError: If the wavelength or the unit-cell area is not + positive. + """ + wavelength = np.asarray(wavelength, dtype=np.float64) + unitcell_area = np.asarray(unitcell_area, dtype=np.float64) + if np.any(wavelength <= 0): + raise ValueError("wavelength must be positive, in Angstrom") + if np.any(unitcell_area <= 0): + raise ValueError("unitcell_area must be positive, in square Angstrom") + lam = wavelength * _ANGSTROM + a_u = unitcell_area * _ANGSTROM**2 + return ( + np.asarray(flux_density, dtype=np.float64) + * np.asarray(active_area, dtype=np.float64) + * CLASSICAL_ELECTRON_RADIUS**2 + * lam**2 + / a_u**2 + ) + + +def structure_factor_squared( + intensity, + mode, + alpha=None, + delta=None, + gamma=None, + detector_acceptance=None, + wavelength=None, + unitcell_area=None, + active_area=1.0, + flux_density=1.0, + detector_efficiency=1.0, +): + r"""Turn a normalized integrated intensity into :math:`|F_{hkl}|^2`. + + Inverts the master equation of this module. ``intensity`` must already be + the output of :func:`normalized_intensity` and must already be divided by + the polarization factor :math:`P`; everything else is divided out here. + + :param intensity: Normalized integrated intensity, from + :func:`normalized_intensity`. + :param str mode: :data:`ROCKING`, :data:`STATIONARY` or :data:`SPECULAR`. + :param alpha: Incidence angle, in radian. + :param delta: In-plane detector angle, in radian. + :param gamma: Out-of-plane detector angle, in radian. + :param detector_acceptance: Out-of-plane acceptance :math:`\Delta\gamma` + of the region of interest, in radian; rocking scans only. + :param wavelength: X-ray wavelength, in Angstrom. + :param unitcell_area: Surface unit-cell area, in square Angstrom. + :param active_area: Illuminated active area, in square meter. + :param flux_density: Incident flux density, in photons per second and + square meter. + :param detector_efficiency: In-plane acceptance :math:`C_\mathrm{det}`, + or any further multiplicative correction of the *intensity*. + :returns: :math:`|F_{hkl}|^2`, in electron units squared when the flux + density and the active area are the measured ones, and on a common + but arbitrary scale otherwise. + :rtype: numpy.ndarray + :raises ValueError: If the mode or the angles are inconsistent, or if the + wavelength or unit-cell area is missing. + """ + if wavelength is None or unitcell_area is None: + raise ValueError( + "wavelength (Angstrom) and unitcell_area (square Angstrom) set the " + "scale and must both be given" + ) + eta = angular_factor( + mode, + alpha=alpha, + delta=delta, + gamma=gamma, + detector_acceptance=detector_acceptance, + ) + scale = scale_factor(wavelength, unitcell_area, active_area, flux_density) + return np.asarray(intensity, dtype=np.float64) / ( + scale * eta * np.asarray(detector_efficiency, dtype=np.float64) + ) + + +def integrated_intensity( + f2, + mode, + alpha=None, + delta=None, + gamma=None, + detector_acceptance=None, + wavelength=None, + unitcell_area=None, + active_area=1.0, + flux_density=1.0, + detector_efficiency=1.0, +): + r"""Forward model: the normalized intensity a given :math:`|F_{hkl}|^2` + produces. + + The exact inverse of :func:`structure_factor_squared`, kept as a public + function because it is how a simulated measurement -- and therefore a + regression test of the whole reduction -- is built. + + Arguments and units are those of :func:`structure_factor_squared`, with + ``f2`` in electron units squared. + + :returns: The normalized integrated intensity, in the units + :func:`normalized_intensity` produces. + :rtype: numpy.ndarray + """ + eta = angular_factor( + mode, + alpha=alpha, + delta=delta, + gamma=gamma, + detector_acceptance=detector_acceptance, + ) + scale = scale_factor(wavelength, unitcell_area, active_area, flux_density) + return ( + np.asarray(f2, dtype=np.float64) + * scale + * eta + * np.asarray(detector_efficiency, dtype=np.float64) + ) + + +def reflectivity_from_structure_factor( + f2, wavelength, unitcell_area, alpha, beta_out=None, polarization=1.0 +): + r"""Absolute reflectivity of a rod, from its structure factor. + + Vlieg equation 63, + + .. math:: + + R = \frac{r_e^2 \lambda^2 P_r}{A_u^2 \sin\alpha \sin\beta_\mathrm{out}} + |F_{hkl}|^2 , + + the fraction of the incident flux scattered into the rod. For the + specular rod :math:`\beta_\mathrm{out} = \alpha` and this is the familiar + :math:`1/\sin^2\alpha`: one power from the beam footprint + :math:`A_r = A_0/\sin\alpha`, one from the stationary Lorentz factor + :math:`1/\sin\beta_\mathrm{out}`. + + Nothing beyond the structure factor is needed, so an absolutely scaled + :math:`|F_{hkl}|^2` from any scan mode yields an absolute reflectivity + without a separate measurement. This is the kinematic result: it does not + hold near a bulk Bragg peak, and below the critical angle refraction and + multiple scattering are not described by it. + + Off the specular rod, :math:`R` is the fraction of the incident flux + scattered into that rod; it is a well-defined number for a truncation rod + integrated across its cross-section, but not for diffuse scattering, + where only a differential cross-section is meaningful. + + :param f2: :math:`|F_{hkl}|^2` in electron units squared, on an absolute + scale. + :param wavelength: X-ray wavelength, in Angstrom. + :param unitcell_area: Surface unit-cell area, in square Angstrom. + :param alpha: Incidence angle, in radian. + :param beta_out: Exit angle, in radian; ``None`` means the specular + condition ``beta_out = alpha``. + :param polarization: Polarization factor :math:`P_r`; for the specular + condition Vlieg equation 59 gives + ``p_h * cos(2 alpha)**2 + (1 - p_h)``. + :returns: The reflectivity, broadcast over the inputs. + :rtype: numpy.ndarray + :raises ValueError: If the wavelength or the unit-cell area is not + positive. + """ + alpha = np.asarray(alpha, dtype=np.float64) + beta_out = alpha if beta_out is None else np.asarray(beta_out, dtype=np.float64) + return ( + np.asarray(f2, dtype=np.float64) + * _reflectivity_prefactor(wavelength, unitcell_area) + * np.asarray(polarization, dtype=np.float64) + / (np.sin(alpha) * np.sin(beta_out)) + ) + + +def structure_factor_from_reflectivity( + reflectivity, wavelength, unitcell_area, alpha, beta_out=None, polarization=1.0 +): + r"""Inverse of :func:`reflectivity_from_structure_factor`. + + Puts a measured reflectivity curve on the same :math:`|F_{hkl}|^2` scale + as the crystal truncation rods, so that a reflectivity and a set of rods + can be refined together. + + Arguments and units are those of + :func:`reflectivity_from_structure_factor`. + + :returns: :math:`|F_{hkl}|^2` in electron units squared. + :rtype: numpy.ndarray + """ + alpha = np.asarray(alpha, dtype=np.float64) + beta_out = alpha if beta_out is None else np.asarray(beta_out, dtype=np.float64) + return ( + np.asarray(reflectivity, dtype=np.float64) + * np.sin(alpha) + * np.sin(beta_out) + / ( + _reflectivity_prefactor(wavelength, unitcell_area) + * np.asarray(polarization, dtype=np.float64) + ) + ) diff --git a/orgui/datautils/xrayutils/test/test_corrections_activearea.py b/orgui/datautils/xrayutils/test/test_corrections_activearea.py new file mode 100644 index 0000000..99b960a --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_corrections_activearea.py @@ -0,0 +1,129 @@ +"""Regression tests for the illuminated active surface area. + +:mod:`orgui.datautils.xrayutils.corrections.activearea` builds the area +:math:`A` that multiplies every integrated-intensity expression of E. Vlieg, +*J. Appl. Cryst.* **30** (1997) 532, in the two limits an experiment can be +in. See ``doc/design/ctr_structure_factor_scale.md`` finding F4. +""" + +import numpy as np +import pytest + +from orgui.datautils.xrayutils.corrections import activearea, geometry +from orgui.datautils.xrayutils.corrections.beamprofile import ( + gaussian_profile, + top_hat_profile, +) + +#: Horizontal beam size, vertical beam size and sample length, in meter. +W, H, L = 300e-6, 20e-6, 5e-3 + + +def test_beam_limited_area_reduces_to_the_closed_form_for_a_top_hat(): + """``w L C_illum`` is ``w min(L, h/sin alpha)`` for a uniform beam. + + This identity is why the module offers no separate closed form. It also + fixes the meaning of ``illuminated_area_fraction``: it is the mean of + ``p(z)/p_max`` over the projected sample, so multiplying by the sample + length and the beam width is all that turns it into an absolute area. + """ + alpha = np.deg2rad(np.array([0.05, 0.1, 0.23, 0.5, 1.0, 2.0, 5.0, 15.0])) + + from_profile = activearea.beam_limited_area(alpha, W, L, top_hat_profile(H)) + closed_form = W * activearea.footprint_length(alpha, H, L) + + np.testing.assert_allclose(from_profile, closed_form, rtol=1e-12) + + +def test_a_real_beam_is_not_the_closed_form(): + """A Gaussian of the same width differs enough to matter. + + The closed form is a top-hat statement, not a general one: taking it for + a focused beam is a percent-level error at high incidence and much worse + where the beam spills off the sample. + """ + alpha = np.deg2rad(np.array([0.05, 0.23, 2.0, 15.0])) + + gauss = activearea.beam_limited_area(alpha, W, L, gaussian_profile(H)) + top_hat = activearea.beam_limited_area(alpha, W, L, top_hat_profile(H)) + + ratio = gauss / top_hat + assert ratio.min() < 0.85, "grazing incidence must show the profile tails" + assert ratio.max() > 1.06, "a flooded sample must show the peak density" + + +def test_footprint_length_is_clipped_by_the_sample(): + """Below the flooding angle the beam sets the length, above it the sample.""" + flooding = np.arcsin(H / L) + alpha = np.array([flooding / 2.0, flooding * 2.0]) + + length = activearea.footprint_length(alpha, H, L) + + np.testing.assert_allclose(length[0], L, rtol=1e-12) + np.testing.assert_allclose(length[1], H / np.sin(alpha[1]), rtol=1e-12) + + +def test_slit_limited_area_is_the_z_axis_table_entry(): + """``s1 s2 / sin(delta)``, with the cosine that the z-axis mode drops. + + :func:`~.geometry.area_correction` is the tabulated row; this only turns + it into an area and reinstates ``cos(alpha - beta_in)`` for a geometry + where the incidence angle is not the angle to the surface. + """ + delta = np.deg2rad(np.array([5.0, 17.0, 40.0])) + s1, s2 = 200e-6, 1e-3 + + z_axis = activearea.slit_limited_area(delta, s1, s2) + + np.testing.assert_allclose( + z_axis, s1 * s2 * geometry.area_correction(delta), rtol=1e-12 + ) + # beta_in = alpha is the z-axis mode, so the cosine is one. + alpha = np.deg2rad(0.6) + np.testing.assert_allclose( + activearea.slit_limited_area(delta, s1, s2, alpha=alpha, beta_in=alpha), + z_axis, + rtol=1e-12, + ) + tilted = activearea.slit_limited_area( + delta, s1, s2, alpha=alpha, beta_in=np.deg2rad(10.0) + ) + np.testing.assert_allclose( + tilted, z_axis / np.cos(alpha - np.deg2rad(10.0)), rtol=1e-12 + ) + + +def test_the_two_limits_disagree_and_that_is_the_experimental_question(): + """They are alternatives, not factors, and only one can be right. + + The slit-limited area varies along a rod because it depends on delta; + the beam-limited one does not. Which applies is set by the slit + settings, and orGUI's open-slit configuration is the second. + """ + delta = np.deg2rad(np.array([16.77, 16.86, 16.95])) + alpha = np.deg2rad(np.full(delta.size, 0.6)) + + slits = activearea.slit_limited_area(delta, 200e-6, 1e-3) + beam = activearea.beam_limited_area(alpha, W, L, top_hat_profile(H)) + + assert slits.max() / slits.min() > 1.005 + np.testing.assert_allclose(beam, beam[0], rtol=1e-12) + + +def test_sizes_must_be_physical(): + """A zero or negative size is a units mistake, not a value.""" + alpha = np.deg2rad(1.0) + with pytest.raises(ValueError, match="beam_width must be positive"): + activearea.beam_limited_area(alpha, 0.0, L, top_hat_profile(H)) + with pytest.raises(ValueError, match="sample_length must be positive"): + activearea.beam_limited_area(alpha, W, -1.0, top_hat_profile(H)) + with pytest.raises(ValueError, match="needs a beam profile"): + activearea.beam_limited_area(alpha, W, L, None) + with pytest.raises(ValueError, match="beam_height must be positive"): + activearea.footprint_length(alpha, 0.0, L) + with pytest.raises(ValueError, match="slit_width must be positive"): + activearea.slit_limited_area(np.deg2rad(17.0), 0.0, 1e-3) + with pytest.raises(ValueError, match="beta_in needs alpha"): + activearea.slit_limited_area( + np.deg2rad(17.0), 200e-6, 1e-3, beta_in=np.deg2rad(1.0) + ) diff --git a/orgui/datautils/xrayutils/test/test_corrections_measurement.py b/orgui/datautils/xrayutils/test/test_corrections_measurement.py new file mode 100644 index 0000000..fcd0429 --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_corrections_measurement.py @@ -0,0 +1,346 @@ +"""Regression tests for the common structure-factor scale. + +:mod:`orgui.datautils.xrayutils.corrections.measurement` reduces an integrated +intensity to :math:`|F_{hkl}|^2` for any scan mode. Every expectation here is +written out independently from the published equations -- E. Vlieg, +*J. Appl. Cryst.* **30** (1997) 532 and J. Drnec *et al.*, +*J. Appl. Cryst.* **47** (2014) 365 -- rather than by calling the module back, +so that the tests pin the physics and not the implementation. + +See ``doc/design/ctr_structure_factor_scale.md`` for the analysis these tests +belong to, and `issue #15 `_ and +`issue #82 `_ for the requests. +""" + +import numpy as np +import pytest + +from orgui.datautils.xrayutils.corrections import measurement as ii + +#: A z-axis trajectory along a rod: fixed incidence, exit angle climbing. +ALPHA = np.deg2rad(np.full(6, 0.6)) +DELTA = np.deg2rad(np.array([16.77, 16.79, 16.83, 16.89, 16.93, 16.95])) +GAMMA = np.deg2rad(np.array([1.76, 3.53, 5.31, 8.88, 12.49, 17.38])) + +#: Out-of-plane acceptance of a region of interest, in radian. +DGAMMA = np.deg2rad(0.35) + +#: Pt(111) hexagonal surface cell at 17.7 keV. +WAVELENGTH = 0.70047 +UC_AREA = 2.7748 * 2.7748 * np.sin(np.deg2rad(120.0)) + + +def test_normalized_intensity_divides_by_time_and_monitor(): + """Both published expressions carry ``Phi_0 T``; both must come out.""" + got = ii.normalized_intensity( + np.array([100.0, 200.0]), exposure_time=0.5, monitor=4.0 + ) + + np.testing.assert_allclose(got, np.array([50.0, 100.0])) + + +def test_normalized_intensity_converts_the_rocking_angle_to_radian(): + """A rocking integral taken in degrees is 180/pi too large. + + :func:`~orgui.app.peak1Dintegr._compute_rocking_integration` integrates + over the rocking axis as stored, which is degrees. Vlieg's equation 42 + and Drnec's equation 2 integrate in radian. + """ + counts_deg = 573.0 + + in_deg = ii.normalized_intensity(counts_deg, angle_unit="deg") + in_rad = ii.normalized_intensity(counts_deg, angle_unit="rad") + + np.testing.assert_allclose(in_deg, np.deg2rad(counts_deg)) + np.testing.assert_allclose(in_rad / in_deg, np.rad2deg(1.0)) + + +def test_normalized_intensity_rejects_unusable_divisors(): + """A zero monitor or exposure must fail loudly, not scale by infinity.""" + with pytest.raises(ValueError, match="finite and positive"): + ii.normalized_intensity(1.0, exposure_time=0.0) + with pytest.raises(ValueError, match="finite and non-zero"): + ii.normalized_intensity(1.0, monitor=0.0) + with pytest.raises(ValueError, match="unknown angle_unit"): + ii.normalized_intensity(1.0, angle_unit="degrees") + + +def test_rocking_factor_is_the_published_z_axis_expression(): + """``L_phi * C_rod * Delta_gamma``, Vlieg equations 16, 20, 23 and 42.""" + expected = ( + 1.0 / (np.sin(DELTA) * np.cos(ALPHA) * np.cos(GAMMA)) # L_phi, eq (16) + * np.cos(GAMMA) # C_rod, eq (23) in the z-axis mode + * DGAMMA # rod length intercepted, eq (20) + ) + + got = ii.angular_factor( + ii.ROCKING, + alpha=ALPHA, + delta=DELTA, + gamma=GAMMA, + detector_acceptance=DGAMMA, + ) + + np.testing.assert_allclose(got, expected, rtol=1e-12) + + +def test_stationary_factor_is_the_published_z_axis_expression(): + """``L_s = 1/sin(gamma)``, Vlieg equation 53 and Drnec equation 5.""" + got = ii.angular_factor(ii.STATIONARY, gamma=GAMMA) + + np.testing.assert_allclose(got, 1.0 / np.sin(GAMMA), rtol=1e-12) + + +def test_rocking_intensity_is_linear_in_the_detector_acceptance(): + """Twice the out-of-plane acceptance intercepts twice as much rod. + + This is the factor that makes a rocking scan disagree with a stationary + measurement of the same rod, and it is not constant over a scan whenever + the region of interest is resized per detector position. + """ + single = ii.angular_factor( + ii.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA, detector_acceptance=DGAMMA + ) + double = ii.angular_factor( + ii.ROCKING, + alpha=ALPHA, + delta=DELTA, + gamma=GAMMA, + detector_acceptance=2.0 * DGAMMA, + ) + + np.testing.assert_allclose(double, 2.0 * single, rtol=1e-12) + + +def test_the_acceptance_belongs_to_rocking_scans_only(): + """Neither mode may silently accept the other's arguments.""" + with pytest.raises(ValueError, match="out-of-plane acceptance"): + ii.angular_factor(ii.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA) + with pytest.raises(ValueError, match="whole rod cross"): + ii.angular_factor(ii.STATIONARY, gamma=GAMMA, detector_acceptance=DGAMMA) + with pytest.raises(ValueError, match="unknown mode"): + ii.angular_factor("rsm", gamma=GAMMA) + + +def test_specular_is_the_stationary_factor_at_the_specular_condition(): + """Vlieg section 3.2: reflectivity is the stationary case, gamma = alpha.""" + alpha = np.deg2rad(np.array([0.4, 1.0, 2.5])) + + specular = ii.angular_factor(ii.SPECULAR, alpha=alpha) + + np.testing.assert_allclose(specular, 1.0 / np.sin(alpha), rtol=1e-12) + np.testing.assert_allclose( + specular, ii.angular_factor(ii.STATIONARY, gamma=alpha), rtol=1e-12 + ) + with pytest.raises(ValueError, match="non-specular exit angle"): + ii.angular_factor(ii.SPECULAR, alpha=alpha, gamma=alpha + 0.1) + + +def test_scale_factor_carries_its_documented_units(): + """``Phi_0 r_e^2 A lambda^2 / A_u^2``, with lengths mixed as documented. + + The wavelength and the unit-cell area are in Angstrom, the area and the + flux density in meter; getting that conversion wrong is a factor of + 1e20 and is exactly the kind of error the module exists to prevent. + """ + r_e = 2.8179403262e-15 + expected = ( + 1e16 * r_e**2 * 5.73e-7 * (WAVELENGTH * 1e-10) ** 2 / (UC_AREA * 1e-20) ** 2 + ) + + got = ii.scale_factor( + WAVELENGTH, UC_AREA, active_area=5.73e-7, flux_density=1e16 + ) + + np.testing.assert_allclose(got, expected, rtol=1e-12) + # The prefactor is a rate: counts per second for |F|^2 = 1. + assert 1e-4 < got < 1e2 + + +def test_scale_factor_rejects_nonphysical_lengths(): + """A negative wavelength or unit cell is a units mistake, not a value.""" + with pytest.raises(ValueError, match="wavelength must be positive"): + ii.scale_factor(-1.0, UC_AREA) + with pytest.raises(ValueError, match="unitcell_area must be positive"): + ii.scale_factor(WAVELENGTH, 0.0) + + +def test_structure_factor_round_trips_through_the_forward_model(): + """The reduction is the exact inverse of the forward model, per mode.""" + f2 = np.array([273.6, 148.3, 108.2, 98.0, 865.9, 5000.0]) + common = dict( + wavelength=WAVELENGTH, + unitcell_area=UC_AREA, + active_area=5.73e-7, + flux_density=1e16, + detector_efficiency=0.93, + ) + + for kwargs in ( + dict(mode=ii.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA, + detector_acceptance=DGAMMA), + dict(mode=ii.STATIONARY, gamma=GAMMA), + dict(mode=ii.SPECULAR, alpha=GAMMA), + ): + intensity = ii.integrated_intensity(f2, **kwargs, **common) + back = ii.structure_factor_squared(intensity, **kwargs, **common) + np.testing.assert_allclose(back, f2, rtol=1e-12) + + +def test_structure_factor_needs_the_scale_to_be_stated(): + """The wavelength and unit-cell area are not optional defaults.""" + with pytest.raises(ValueError, match="set the scale"): + ii.structure_factor_squared(1.0, ii.STATIONARY, gamma=GAMMA) + + +def test_the_two_modes_agree_on_one_structure_factor(): + """The point of issue #82: one rod, two scan modes, one ``|F|^2``. + + A rocking scan and a stationary measurement of the same reflection are + forward-simulated with completely different counting times, monitor + values and detector acceptances, then reduced. Only if every + mode-dependent factor is right do the two land on the same number. + """ + f2 = np.array([273.6, 148.3, 108.2, 98.0, 865.9, 5000.0]) + scale = dict( + wavelength=WAVELENGTH, + unitcell_area=UC_AREA, + active_area=5.73e-7, + flux_density=1e16, + ) + # Different acceptance at every point, as a resized region of interest + # gives; if it were not divided out this test could not pass. + acceptance = DGAMMA * np.linspace(0.7, 1.6, GAMMA.size) + + rocking_counts = ( + ii.integrated_intensity( + f2, ii.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA, + detector_acceptance=acceptance, **scale + ) + * 0.4 # per-frame counting time, s + * 3.0 # monitor + * np.rad2deg(1.0) # the rocking axis was integrated in degrees + ) + stationary_counts = ( + ii.integrated_intensity(f2, ii.STATIONARY, gamma=GAMMA, **scale) + * 2.0 # counting time, s + * 7.0 # monitor + ) + + f2_rocking = ii.structure_factor_squared( + ii.normalized_intensity( + rocking_counts, exposure_time=0.4, monitor=3.0, angle_unit="deg" + ), + ii.ROCKING, + alpha=ALPHA, + delta=DELTA, + gamma=GAMMA, + detector_acceptance=acceptance, + **scale, + ) + f2_stationary = ii.structure_factor_squared( + ii.normalized_intensity(stationary_counts, exposure_time=2.0, monitor=7.0), + ii.STATIONARY, + gamma=GAMMA, + **scale, + ) + + np.testing.assert_allclose(f2_rocking, f2, rtol=1e-12) + np.testing.assert_allclose(f2_stationary, f2, rtol=1e-12) + + +def test_mode_ratio_matches_vlieg_equation_65(): + """The published ratio of the two integrated intensities. + + Vlieg equation 65 for the z-axis mode, + ``I_s / I_phi = T omega_0 sin(delta) cos(beta_in) / (Delta_gamma sin(gamma))``. + Written here for intensities already divided by ``T`` and by the rotation + speed, the ``T omega_0`` drops out and what remains is a statement about + the two angular factors alone. + """ + rocking = ii.angular_factor( + ii.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA, detector_acceptance=DGAMMA + ) + stationary = ii.angular_factor(ii.STATIONARY, gamma=GAMMA) + + expected = np.sin(DELTA) * np.cos(ALPHA) / (np.sin(GAMMA) * DGAMMA) + + np.testing.assert_allclose(stationary / rocking, expected, rtol=1e-12) + + +def test_reflectivity_reproduces_the_fresnel_asymptote(): + """Vlieg equation 63 against the textbook far-field Fresnel limit. + + For a semi-infinite substrate far from a bulk Bragg peak the truncation + rod amplitude is ``|F| = rho_e A_u / q_z``, and the kinematic + reflectivity of such a substrate is ``(q_c / 2 q_z)^4`` with + ``q_c^2 = 16 pi r_e rho_e``. Any error in the wavelength, unit-cell area + or ``sin`` powers of equation 63 breaks this identity, so it validates + the absolute scale end to end and not just an algebraic rearrangement. + """ + r_e_ang = ii.CLASSICAL_ELECTRON_RADIUS / 1e-10 # Angstrom + rho_e = 5.16 # electrons per cubic Angstrom, close to Pt + alpha = np.deg2rad(np.array([0.5, 0.8, 1.2, 2.0, 3.0])) + q_z = 4.0 * np.pi * np.sin(alpha) / WAVELENGTH # 1/Angstrom + q_c = np.sqrt(16.0 * np.pi * r_e_ang * rho_e) + + f2 = (rho_e * UC_AREA / q_z) ** 2 + reflectivity = ii.reflectivity_from_structure_factor( + f2, WAVELENGTH, UC_AREA, alpha + ) + + np.testing.assert_allclose(reflectivity, (q_c / (2.0 * q_z)) ** 4, rtol=1e-12) + # Well above the critical angle, where the kinematic result is valid. + assert np.all(np.rad2deg(np.arcsin(q_c * WAVELENGTH / (4.0 * np.pi))) < 0.5) + + +def test_reflectivity_round_trips_and_generalizes_off_specular(): + """The inverse recovers ``|F|^2``, and a non-specular exit angle works.""" + alpha = np.deg2rad(0.6) + beta_out = np.deg2rad(np.array([0.6, 2.0, 8.0])) + f2 = np.array([1500.0, 900.0, 120.0]) + + reflectivity = ii.reflectivity_from_structure_factor( + f2, WAVELENGTH, UC_AREA, alpha, beta_out=beta_out, polarization=0.98 + ) + back = ii.structure_factor_from_reflectivity( + reflectivity, WAVELENGTH, UC_AREA, alpha, beta_out=beta_out, polarization=0.98 + ) + + np.testing.assert_allclose(back, f2, rtol=1e-12) + # At the specular condition the two sines collapse to the familiar + # 1/sin^2(alpha) of Vlieg equation 63. + specular = ii.reflectivity_from_structure_factor( + f2[0], WAVELENGTH, UC_AREA, alpha + ) + prefactor = ( + ii.CLASSICAL_ELECTRON_RADIUS**2 + * (WAVELENGTH * 1e-10) ** 2 + / (UC_AREA * 1e-20) ** 2 + ) + np.testing.assert_allclose( + specular, prefactor * f2[0] / np.sin(alpha) ** 2, rtol=1e-12 + ) + + +def test_footprint_area_cancels_between_the_modes(): + """The active area cannot be why two scan modes disagree. + + It enters both expressions identically, so it drops out of their ratio + even when it is wrong. That is why it is listed under the absolute scale + (issue #15) and not under the mode equivalence (issue #82). + """ + kwargs = dict(wavelength=WAVELENGTH, unitcell_area=UC_AREA, flux_density=1e16) + ratios = [] + for area in (1e-7, 5.73e-7, 2e-6): + rocking = ii.integrated_intensity( + 1.0, ii.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA, + detector_acceptance=DGAMMA, active_area=area, **kwargs + ) + stationary = ii.integrated_intensity( + 1.0, ii.STATIONARY, gamma=GAMMA, active_area=area, **kwargs + ) + ratios.append(rocking / stationary) + + for ratio in ratios[1:]: + np.testing.assert_allclose(ratio, ratios[0], rtol=1e-12) diff --git a/orgui/datautils/xrayutils/test/test_corrections_package.py b/orgui/datautils/xrayutils/test/test_corrections_package.py index 4f1aedf..bd940db 100644 --- a/orgui/datautils/xrayutils/test/test_corrections_package.py +++ b/orgui/datautils/xrayutils/test/test_corrections_package.py @@ -16,10 +16,16 @@ from orgui.datautils.xrayutils import corrections from orgui.datautils.xrayutils.corrections import ( detector, + geometry, + measurement, normalization, roi, ) +ALPHA = np.deg2rad(np.array([0.6, 0.6, 0.6])) +DELTA = np.deg2rad(np.array([16.77, 16.86, 16.95])) +GAMMA = np.deg2rad(np.array([1.76, 8.88, 17.38])) + class _Detector: """Minimal stand-in for a calibrated detector.""" @@ -37,9 +43,11 @@ def polarizationArray(self): def test_the_package_exposes_every_correction_module(): """A maintainer looking for a correction factor finds them in one place.""" assert set(corrections.__all__) == { + "activearea", "beamprofile", "detector", "geometry", + "measurement", "normalization", "roi", } @@ -75,6 +83,49 @@ def test_released_module_paths_still_import(legacy, moved): assert getattr(old, name) is getattr(new, name) +def test_mode_components_is_the_only_place_the_mode_chooses_factors(): + """Each mode's factors, checked against the z-axis primitives. + + :func:`~.measurement.mode_components` is what both integration paths ask. + The primitives it composes stay in :mod:`~.geometry`, so this also pins + that the dispatch adds no formula of its own. + """ + rocking = measurement.mode_components( + measurement.ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA + ) + np.testing.assert_allclose( + rocking["C_Lorentz"], + geometry.lorentz_rocking_scan(DELTA, ALPHA, GAMMA), + rtol=1e-12, + ) + np.testing.assert_allclose( + rocking["C_rod"], geometry.rod_interception(GAMMA), rtol=1e-12 + ) + + reflectivity = measurement.mode_components( + measurement.REFLECTIVITY_ROCKING, alpha=ALPHA, delta=DELTA, gamma=GAMMA + ) + np.testing.assert_allclose( + reflectivity["C_Lorentz"], + geometry.lorentz_reflectivity_rocking_scan(ALPHA), + rtol=1e-12, + ) + np.testing.assert_allclose( + reflectivity["C_rod"], geometry.rod_interception(GAMMA), rtol=1e-12 + ) + + stationary = measurement.mode_components(measurement.STATIONARY, gamma=GAMMA) + np.testing.assert_allclose( + stationary["C_Lorentz"], geometry.lorentz_stationary(GAMMA), rtol=1e-12 + ) + assert "C_rod" not in stationary, ( + "a stationary measurement integrates across the whole rod" + ) + + with pytest.raises(ValueError, match="unknown mode"): + measurement.mode_components("rsm", gamma=GAMMA) + + def test_pixel_factors_is_the_reciprocal_of_the_enabled_arrays(): """The one definition of the per-pixel divisors. From e25b8df958a00ef110a9af89fd55b2331402abb2 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Wed, 9 Sep 2026 17:16:19 -0400 Subject: [PATCH 03/33] docs: record the rocking/stationary structure-factor scale analysis --- CHANGELOG.md | 36 ++ doc/design/ctr_structure_factor_scale.md | 476 +++++++++++++++++++++++ doc/source/ctr_structure_factors.rst | 17 + doc/source/image_integration.rst | 46 +++ doc/source/release_notes.rst | 4 + 5 files changed, 579 insertions(+) create mode 100644 doc/design/ctr_structure_factor_scale.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f0c23..c956405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ This is the changelog for the software orGUI, written by Timo Fuchs Scientific and analysis additions: +- **All correction factors collected into one package.** Every factor between + detector counts and a structure factor now lives in + ``orgui.datautils.xrayutils.corrections``, split by what it depends on: + ``geometry`` (the z-axis Lorentz, rod-interception and area table), + ``beamprofile``, ``activearea``, ``detector`` (per-pixel solid angle and + polarization), ``normalization`` (counting time and monitor), ``roi``, and + ``measurement``. The rocking integration, the stationary integration and the + reciprocal-space reconstruction previously each carried their own copy of + several of these; they now share one definition, so they cannot drift onto + different scales. The package is physics only -- numbers in, numbers out -- + and reads no scan object, configuration or widget; ``orgui.app`` + ``integration_corrections`` is the adapter that supplies those. + ``orgui.datautils.xrayutils.geometrycorrections`` and + ``orgui.datautils.xrayutils.beamprofile`` keep working as aliases of the + moved modules. **No calculated value changes.** + +- **One structure-factor scale for rocking scans, stationary scans, and + reflectivity.** The new public module + ``orgui.datautils.xrayutils.corrections.measurement`` reduces an integrated + intensity to ``|F_hkl|^2`` for any scan mode, following E. Vlieg, + *J. Appl. Cryst.* 30 (1997) 532 and J. Drnec et al., + *J. Appl. Cryst.* 47 (2014) 365. It normalizes counts by exposure time and + monitor, converts a rocking integral from degrees to radians, applies the + mode-dependent angular factor (rocking scans additionally require the + out-of-plane acceptance of the region of interest, stationary measurements + reject it), and, given the incident flux density and the illuminated area, + puts the result on the absolute electron-unit scale. It also converts + between ``|F_hkl|^2`` and absolute reflectivity, so a reflectivity curve and + a set of truncation rods can be brought onto one scale. **This is a new API + only: no existing integration result changes.** The integration paths do + not use it yet, and rocking and stationary integration remain on different + scales, differing by exposure time times monitor times the acceptance in + degrees; the image-integration documentation now says so explicitly, and + ``doc/design/ctr_structure_factor_scale.md`` records the full analysis with + the measured size of every correction. + - **Unified CTR fit predictions, lifecycle, and statistics.** CTR optimizer predictions, residuals, likelihoods, and diagnostics now use one final analytically scaled result path. ``flat_prediction`` supports the common diff --git a/doc/design/ctr_structure_factor_scale.md b/doc/design/ctr_structure_factor_scale.md new file mode 100644 index 0000000..8076bfd --- /dev/null +++ b/doc/design/ctr_structure_factor_scale.md @@ -0,0 +1,476 @@ +# One structure-factor scale for rocking scans, stationary scans and reflectivity + +> **Status: analysis complete, reduction core and regression tests landed, GUI +> paths not yet switched over.** This is the review document for +> [issue #82](https://github.com/tifuchs/orGUI/issues/82) ("Regression tests +> and validation of equivalence of rocking and stationary scan integration") +> and for the physics half of +> [issue #15](https://github.com/tifuchs/orGUI/issues/15) ("Calculate +> quantitatively exact structure factors"). +> +> It records why a rocking scan and a stationary scan of the same rod do +> **not** currently produce the same `F2_hkl` in orGUI, quantifies each reason +> on simulated data, and states what is still needed for an absolute scale and +> for absolute reflectivity. Everything below was verified numerically against +> the code as of this branch, not by inspection alone. +> +> Landed with this document: +> `orgui/datautils/xrayutils/corrections/measurement.py` (the reduction, pure +> functions, additive - no existing number changes), +> `orgui/datautils/xrayutils/test/test_corrections_measurement.py` (18 tests +> against the published equations) and +> `orgui/app/test/test_scan_mode_equivalence.py` (5 tests that simulate one +> rod measured both ways and push it through both of orGUI's current paths). + +## 1. What the two papers require + +E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532 gives the integrated intensity +for each measurement mode of a six-circle/z-axis diffractometer. J. Drnec +*et al.*, *J. Appl. Cryst.* **47** (2014) 365 rewrites the same expressions +for a two-dimensional detector and states the equivalence claim of issue #82 +directly (their section 3.4 and Fig. 8, right: rocking scans at low *l* and a +stationary scan at high *l* overlap once both are reduced properly). + +**Rocking scan** (Vlieg eq. 42/43, Drnec eq. 3): + +``` +I_omega = Phi_0 T_omega (r_e^2 A lambda^2 / A_u^2) |F|^2 P L_phi C_rod C_det C_beam Delta_gamma +``` + +with `L_phi = 1/(sin(delta) cos(beta_in) cos(gamma))` (eq. 16), `C_rod` the rod +interception, which reduces to `cos(gamma)` in the z-axis mode (eq. 23), and +`Delta_gamma` the **out-of-plane angular acceptance of the detector aperture**. +`I_omega` is the integral of the per-frame counts over the rocking angle **in +radian**, divided by the per-frame counting time. + +**Stationary measurement** (Vlieg eq. 54, Drnec eq. 5): + +``` +I_s = Phi_0 T_s (r_e^2 A lambda^2 / A_u^2) |F|^2 P L_s C_det,s C_beam +``` + +with `L_s = 1/sin(beta_out)`, in the z-axis mode `1/sin(gamma)`. There is **no** +rod-interception factor and **no** `Delta_gamma`: a stationary measurement +intercepts the whole rod cross-section rather than a slice of it. + +**Specular reflectivity** (Vlieg section 3.2, eq. 62/63) is the stationary case +at `gamma = alpha` with a beam-limited active area, `A_r = A_0/sin(alpha)`: + +``` +R = I_s / (Phi_0 T A_0 C_beam) = r_e^2 lambda^2 P_r |F|^2 / (A_u^2 sin^2(alpha)) +``` + +One power of `1/sin(alpha)` is the footprint, the other is `L_s`. Nothing +beyond `|F|^2` is needed, so **an absolutely scaled `|F|^2` yields an absolute +reflectivity for free** - see section 6. + +Their ratio (Vlieg eq. 64/65) is the identity issue #82 is really about: + +``` +I_s / I_omega = T omega_0 sin(delta) cos(beta_in) / (Delta_gamma sin(gamma)) +``` + +## 2. What orGUI does today + +| | rocking (`peak1Dintegr`) | stationary (`orGUI.integrateROI` + `integration_corrections`) | reconstruction (`reconstruction_job`) | +|---|---|---|---| +| solid angle | per-pixel array, applied as ROI mean | per-pixel array, applied as ROI mean | per pixel | +| polarization | per-pixel array, applied as ROI mean | per-pixel array, applied as ROI mean | per pixel | +| exposure / monitor | **not applied** | `normalization_divisor` | applied | +| footprint `C_beam` | `beamprofile` `C_illum_area` | `beamprofile` `C_illum_area` | not applied | +| `C_area` (`1/sin(delta)`) | not applied (documented as deliberate) | not applied (documented as deliberate) | not applied | +| Lorentz | `1/(sin(delta) cos(alpha) cos(gamma))` | `1/sin(gamma)` | not applied (correct: the voxel binning absorbs it, Drnec section 4) | +| rod interception | `cos(gamma)` | none (correct) | none | +| `Delta_gamma` | **not applied** | not applicable (correct) | `Delta_l` not applied | +| rocking angle unit | **degrees** | n/a | n/a | +| `C_det` | not modelled | not modelled | not modelled | + +The two Lorentz factors, the rod interception and the absence of a stationary +`C_rod` are all correct and match the ANA/ROD z-axis table. The disagreement +comes from the three cells in bold. + +## 3. Findings + +Ranked by how badly they distort the saved numbers. + +### F1 - rocking scans carry no exposure-time or monitor normalization + +`orgui/app/integration_corrections.py:normalization_divisor` is wired into the +stationary path only (`orGUI.py:6361`). `peak1Dintegr.py` contains no +occurrence of `exposure`, `monitor` or `normaliz` at all. Both published +expressions carry `Phi_0 T`, so a rocking integral and a stationary frame are +not comparable until both are divided by counting time and monitor. + +Worse than the mode mismatch: within a *single* rocking data set, rocking scans +taken at different *l* with different counting times or a drifting ring current +are put on different scales. Nothing in the saved data records this. + +### F2 - the rocking integral is taken in degrees + +`_compute_rocking_integration` trapezoid-integrates `croibg` over `axis`, which +is the rocking motor position in degrees (`peak1Dintegr.py:235`-`243`). Vlieg +eq. 42 and Drnec eq. 2 integrate in radian. Factor `180/pi = 57.3`, constant, +so it is invisible inside one data set and only shows up when comparing to +another mode or to an absolute scale. + +### F3 - no out-of-plane detector acceptance `Delta_gamma` for rocking scans + +This is the physically interesting one. A rocking scan intercepts a slice of +rod of length `Delta_l = C_rod (V_u / lambda A_u) Delta_gamma` (Vlieg eq. 20), +so its integrated intensity is *proportional to the vertical angular size of +the region of interest*. A stationary measurement has no such factor. + +Before this branch, `Delta_gamma` appeared nowhere in orGUI: `grep -rn +"acceptance"` over `orgui/` returned nothing, and the integration paths still +do not compute it. In the point-detector world it was a fixed slit setting and +disappeared into the overall scale factor. On an area detector with ROIs sized +per detector position by `orgui/app/ROIutils.py:calc_corrections` (projected +sample size plus a parallax correction, so it changes with `delta` and +`gamma`), it is **not constant along a scan**, and the omission changes the +*shape* of the rod, not just its scale. + +`test_a_resized_region_of_interest_distorts_the_rocking_rod` simulates a rod +measured with the acceptance sweeping from 0.7x to 1.6x of nominal and shows +the distortion carried straight into `F2_hkl`: a factor 2.3 across the rod. + +### F1+F2+F3 combined - the measured gap + +`orgui/app/test/test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_differ_by_the_missing_normalizations` +simulates a Pt(111) `(1, 0, l)` rod at 17.7 keV from a known `|F|^2(l)`, using +Vlieg eq. 42 and 54 as the forward model, and pushes the result through both of +orGUI's current paths. The ratio of the two `F2_hkl` is, to seven digits, + +``` +F2_rocking / F2_stationary = T_omega * monitor_omega * Delta_gamma_in_degrees +``` + +(measured 0.5249999866 against a predicted 0.525). The `180/pi` of F2 and the +`Delta_gamma` in radian of F3 combine into the acceptance expressed in degrees; +the counting time and monitor of F1 sit in front. For a realistic setup - a +100-pixel-tall ROI at 1 m with 172 um pixels is `Delta_gamma ~ 1 degree`, with +a per-frame time of a few tenths of a second - the two modes land within a +factor of a few of each other, which is exactly the regime in which the +discrepancy looks like a plausible scale factor rather than a bug. + +### F4 - the active area is a dimensionless fraction, and `C_area` is skipped + +`geometrycorrections.area_correction` (`1/sin(delta)`) exists but is +deliberately not applied; the numerical `beamprofile` factors are used instead. +`BeamProfile.illuminated_area_fraction` returns +`C_flux_on_sample / (p_max * L sin(alpha))` - the beam profile integrated over +the projected sample, normalized to a uniform beam. It is a *fraction*, not an +area. + +This is **not** a mode-equivalence problem: the active area enters both +expressions identically and cancels from their ratio +(`test_footprint_area_cancels_between_the_modes`). It is a problem for +issue #15, and it hides an undocumented assumption: + +* With **open post-sample slits and an area detector** - orGUI's usual + configuration - the illuminated footprint defines the active area, it has no + `delta` dependence, and skipping `C_area` is right. + `test_the_stationary_path_recovers_the_rod_up_to_one_constant` confirms the + stationary path then recovers the rod shape to machine precision. +* With **slits narrower than the footprint**, + `C_area = 1/(sin(delta) cos(alpha - beta_in))` applies and varies along the + rod. On the simulated rod above, `delta` moves from 16.77 to 16.95 degrees + between `l = 0.4` and `l = 3.0`, a 1.1 % shape error - small here, much + larger between rods at different in-plane momentum transfer. + +Neither the assumption nor which case a given data set is in is recorded +anywhere in the output. + +### F5 - the polarization correction is evaluated at the calibrated arm position + +`orGUI.py:1590` and `orGUI.py:5752` build the per-pixel correction array once, +outside the frame loop, from `dc.polarizationArray()`. That is pyFAI's +detector-frame expression evaluated at the *calibrated* geometry. +`DetectorCalibration.polarizationAtPoints` exists precisely because that is +wrong for a moving detector arm, and says so in its own docstring; per-frame +arm angles are already resolved by `backend/scans.py:scan_arm_angles` and used +by `getROIloc` and the cursor readout. + +Measured at the detector centre for a specular scan whose arm follows +`gamma = 2 alpha`, horizontally polarized beam: + +| 2theta | P (calibrated position) | P (arm-following) | error | +|---|---|---|---| +| 1 deg | 1.00000 | 0.99970 | +0.03 % | +| 4 deg | 1.00000 | 0.99513 | +0.49 % | +| 10 deg | 1.00000 | 0.96985 | +3.11 % | +| 18 deg | 1.00000 | 0.90451 | +10.56 % | +| 30 deg | 1.00000 | 0.75000 | +33.33 % | + +For a **fixed** arm the static array is correct - each pixel already carries +its own scattering angle, and the apparent `alpha` dependence of the ANA +z-axis expression is only a change of frame. The bug is scoped to scans that +move the arm, which is exactly the reflectivity case of section 6. + +### F6 - solid-angle correction applied to an already-summed ROI + +For a ROI-summed intensity, the raw sum over pixels *is* the angular integral +`Phi_0 T Int Int (dsigma/dOmega) dgamma dpsi`; no solid-angle correction is +needed. orGUI multiplies the sum by the ROI-mean of `1/solidAngleArray` +(`integration_corrections.roi_mean_correction`), which introduces a factor +`Omega_ref / ` that varies over the detector - a few tenths of a +percent for a detector at 1 m, several percent for a close-in detector. + +The solid-angle array and an angle-derived `Delta_gamma` describe the same +geometry. Applying one without the other double-counts. When F3 is implemented, +decide the pair together: the clean choice is **raw ROI sum plus `Delta_gamma` +from `surfaceAnglesPoint` at the ROI edges**, with the solid-angle array +reserved for the per-pixel reconstruction path where it is genuinely required. + +### F7 - `C_det` is assumed to be 1 in both modes + +Neither path models the in-plane detector acceptance (Vlieg sections 2.4/2.5, +eq. 27/28). It is close to 1 for a wide ROI and a sharp in-plane profile, but +it is **not the same** for the two modes when the rod is broad: Drnec's Fig. 18 +shows direct stationary integration underestimating `|F|` at low *l* by up to a +factor of 2 relative to reciprocal-space integration, for exactly this reason. +This is the one remaining mechanism that can make the two modes disagree +*after* F1-F3 are fixed, and it is a modelling problem, not a normalization +one. + +## 4. What landed + +### 4.1 One home for the corrections + +Before this branch the answer to "which factors apply to this scan" existed in +four places, "what is the active area" in three, "what is the exposure and +monitor divisor" in three, and the five-line per-pixel solid-angle and +polarization block in three verbatim copies. Adding the reduction as one more +module would have made that five, four, four and three. + +Everything now lives in `orgui/datautils/xrayutils/corrections/`, split by +*what a factor depends on*, which is also what makes each piece testable on +its own: + +| module | depends on | +|---|---| +| `geometry.py` | diffractometer angles only (the ANA/ROD z-axis table) | +| `beamprofile.py` | the vertical profile of the incident beam | +| `activearea.py` | beam, sample and slits | +| `detector.py` | the calibrated detector geometry, per pixel | +| `normalization.py` | counting time and monitor values | +| `roi.py` | reducing per-pixel factors onto a summed region | +| `measurement.py` | which of the above a scan mode applies | + +The package is physics: numbers in, numbers out. Nothing in it reads a scan +object, a configuration or a widget. `geometrycorrections.py` and +`beamprofile.py` at the old level were released under those names and remain +as re-export aliases; `test_corrections_package.py` checks that the aliases +hand out the *same objects*, not merely importable ones. + +`orgui/app/integration_corrections.py` is now the adapter in the other +direction -- switch states and scan counter names in, arguments out -- and +that boundary is written into `orgui/app/AGENTS.md`. The callers were rewired: +`orGUI.py` builds both of its per-pixel arrays with +`corrections.detector.pixel_factors`, `reconstruction_job.py` uses the same +function while keeping its own native-fused application, and +`peak1Dintegr.py` asks `measurement.mode_components` for its Lorentz and +rod-interception factors instead of selecting them itself. + +### 4.2 The reduction + +`corrections/measurement.py` implements + +``` +I = Phi_0 (r_e^2 A lambda^2 / A_u^2) |F|^2 eta C_det +``` + +* `normalized_intensity(counts, exposure_time, monitor, angle_unit)` - F1 and + F2 in one place; `angle_unit="deg"` converts a rocking integral to radian. +* `mode_components(mode, ...)` - the named factors a mode applies, and the + single place that mapping exists. Both integration paths call it. +* `angular_factor(mode, ...)` - `eta`, the product of those and, for the + rocking modes, the acceptance. They require `detector_acceptance` and raise + without it (F3); `STATIONARY` *rejects* it. +* `scale_factor(wavelength, unitcell_area, active_area, flux_density)` - the + prefactor, in 1/s. With the defaults it is the relative scale that makes the + modes agree; with the measured flux and area it is the absolute one. +* `structure_factor_squared` / `integrated_intensity` - the reduction and its + exact inverse. The inverse is public because it is the forward model the + regression tests simulate with. +* `reflectivity_from_structure_factor` / `structure_factor_from_reflectivity` - + Vlieg eq. 63, generalized to a non-specular exit angle. + +Units are mixed on purpose and stated at every boundary: wavelength and +unit-cell area in Angstrom (repository convention, and what `Lattice.uc_area` +returns), areas and flux density in meter (what the config carries). Getting +that wrong is a factor of `1e20`, so +`test_scale_factor_carries_its_documented_units` pins it. + +### 4.3 The active area, and a function that should not exist + +An earlier draft of this work added +`active_area_footprint(alpha, w, h, L) = w * min(L, h/sin(alpha))`. It is +redundant. `BeamProfile.illuminated_area_fraction` is already the mean of +`p(z)/p_max` over the projected sample, so the absolute area is + +``` +A(alpha) = beam_width * sample_length * illuminated_area_fraction(alpha, L) +``` + +and the `min()` form is exactly this for a `top_hat_profile`, and only for +that profile -- verified to `3.3e-16`. Extending the existing one-dimensional +area machinery to a real area is therefore one multiplication by the beam +width, which is what `activearea.beam_limited_area` does. The closed form was +deleted rather than moved, and `test_corrections_activearea.py` pins both the +identity and the fact that a Gaussian of the same width departs from it by up +to 19 %, so it does not come back as a "simplification". + +`activearea.slit_limited_area` covers the other limit, built on the same +`geometry.area_correction` the z-axis table lists, so `1/sin(delta)` also has +one definition. + +**No existing number changed.** The move is behavior-preserving: the full +suite reports the same 80 failures (all from the unbuilt native extension) +before and after, with 782 passing against 770. + +### Test coverage + +`test_corrections_measurement.py` writes every expectation out from the papers +rather than calling the module back - the z-axis Lorentz factors, the linear +dependence on `Delta_gamma`, the mode ratio of eq. 65, the unit conversions, +and the round trip through the forward model. + +The strongest of them is `test_reflectivity_reproduces_the_fresnel_asymptote`: +for a semi-infinite substrate far from a bulk Bragg peak the truncation-rod +amplitude is `|F| = rho_e A_u / q_z`, and feeding that into eq. 63 must +reproduce the textbook `(q_c / 2 q_z)^4` with `q_c^2 = 16 pi r_e rho_e`. The +two are algebraically identical, so the test validates the absolute scale, the +Angstrom/meter conversions and both powers of `sin` end to end. It agrees to +`rtol = 1e-12`. + +`test_scan_mode_equivalence.py` simulates the real thing: a Pt(111) `(1, 0, l)` +rod, angles from `HKLVlieg.VliegAngles.anglesZmode`, measured as rocking scans +(different counting time, different monitor, acceptance varying 0.7x to 1.6x +along the rod, flat background, real `_compute_rocking_integration` +aggregation) and as a stationary *l* scan (different counting time and monitor, +real `integration_corrections` chain). Reduced through the new module, both +recover the input `|F|^2` - the stationary path exactly, the rocking one to +`1e-6`, limited only by the trapezoidal sampling of the rocking profile. + +`test_rocking_and_stationary_paths_differ_by_the_missing_normalizations` is a +**characterization** test: it asserts today's gap is exactly +`T * monitor * Delta_gamma_in_degrees`. It must be replaced by a plain equality +when the GUI paths adopt the reduction. Its docstring says so. + +## 5. What is needed to close issue #82 + +In order: + +1. **Normalize rocking scans.** Give `RockingPeakIntegrator.integrate` the same + `normalization_divisor` the stationary path uses. The per-frame counting + time, not the sum over the scan: the rocking angle is the integration + variable, the time is not. (F1) +2. **Integrate in radian**, or divide by `180/pi` at the end. (F2) +3. **Divide by `Delta_gamma`.** (F3) The input does not exist yet. The + estimator needs care and is the one piece of real design work left: + * The natural definition is the `gamma` span of the region of interest, + from `DetectorCalibration.surfaceAnglesPoint` at its edges, evaluated with + the per-frame arm angles from `scan_arm_angles`. + * A rectangular ROI in pixel coordinates is in general *rotated* with + respect to the `(gamma, delta)` axes. The corner-to-corner `gamma` span + then overestimates the acceptance at the `delta` of the rod. Decide + whether to take the span at fixed `delta` through the ROI centre or to + integrate the accepted `gamma` range over the in-plane profile. + * Settle F6 at the same time: raw ROI sum plus an angle-derived + `Delta_gamma`, or solid-angle-corrected sum plus a pixel-derived one, but + not a mix. +4. **Record the mode and its inputs in the saved data**, next to `F2_hkl`: the + acceptance, the normalization that was applied, and the active-area + assumption of F4. Without them a saved rod cannot be put on a common scale + after the fact. +5. **Then verify on real data.** The overlap region of a rocking scan and a + stationary scan on the same rod (Drnec Fig. 8, right) is the acceptance + test. Simulation cannot catch F7, an incorrect `Delta_gamma` definition, or + a beamline that reports counting time in the wrong place. + +## 6. What is needed to close issue #15, and reflectivity + +Issue #15 asks for +`I_sc = (Phi_0 T r_e^2 A_0 lambda^2 / A_u^2) |F|^2 P L_s C_area C_det C_beam` +with the constants put back. After section 5, what is still missing is only the +constants: + +* `lambda`, `A_u`, `r_e` - already available (`UBCalculator.getLambda`, + `Lattice.uc_area`, `measurement.CLASSICAL_ELECTRON_RADIUS`). +* `Phi_0` and `T` - user input. `T` is in the scan; `Phi_0` needs a flux + measurement and a field to put it in. +* `A` - `active_area_footprint` needs the beam width, beam height and sample + length. The footprint dialog already asks for the sample size and the beam + profile; it does not ask for the horizontal beam width, and `C_illum_area` is + a fraction rather than an area (F4). Multiplying the fraction by + `w * min(L, h/sin(alpha))` gives the absolute area. +* `C_det` - F7, unmodelled. + +**Reflectivity comes for free.** +`R = r_e^2 lambda^2 P_r |F|^2 / (A_u^2 sin(alpha) sin(beta_out))` needs nothing +beyond an absolutely scaled `|F|^2`, so specular reflectivity and the crystal +truncation rods can be refined together against one model. + +The data container is already in place for this. `CTRplotutil` gives every +`CTR` a frozen `MeasurementReduction` whose `quantity` is either +`"structure_factor"` or `"reflectivity"` (the dimensionless R), and a +`CTRScanGeometry` recording the z-mode scan rule (`fixed="in"/"out"/"eq"` plus +the fixed angle) - which is exactly `alpha` and `beta_out`. What is missing is +only the conversion, and that is what +`measurement.structure_factor_from_reflectivity` now supplies: given +`Lattice.uc_area` and the wavelength, a reflectivity rod can be turned into an +`|F|^2` rod and joined to the truncation rods. Today every fitting, scaling, +averaging and export path in `CTRplotutil` rejects reflectivity explicitly +(`CTRplotutil.py:1108`, `:1497`), which is the right default while no +conversion existed; the natural next step is a `CTR` method that performs it +rather than relaxing those guards. + +Three caveats, all now in the module docstrings: + +1. It is the **kinematic** result. It fails near a bulk Bragg peak and below + the critical angle, where refraction and multiple scattering take over - + which is where the interesting part of a reflectivity curve usually is. + Compare against the DWBA machinery already in `CTRdwba.py` there. +2. **A reflectivity scan moves the detector arm**, so F5 bites hardest here: + 10 % at `2theta = 18` degrees, 33 % at 30 degrees. Fix F5 before trusting an + absolute reflectivity. +3. **Off-specular**, `R` is the fraction of the incident flux scattered into + that rod. That is well defined for a truncation rod integrated across its + cross-section, but not for diffuse scattering, where only a differential + cross-section is meaningful. `reflectivity_from_structure_factor` takes a + `beta_out` for the first case and should not be used for the second. + +Also note the ANA "reflectivity rocking scan" Lorentz factor `1/sin(2 alpha)`, +which `peak1Dintegr` already selects for a `mu` scan: that is for rocking +*through* the specular ridge and is a different measurement from a stationary +specular scan. Both are legitimate; they are not interchangeable, and only the +stationary one is what `reflectivity_from_structure_factor` reduces. + +## 7. Open questions + +* **`Delta_gamma` for a rotated ROI** (section 5.3). The single piece of + physics not settled here. +* **The reciprocal-space reconstruction as a third route.** Drnec section 4 + shows that voxel binning absorbs the Lorentz factor, so a reconstructed map + needs `C_area`, `C_beam`, `P` and `Delta_l` but no Lorentz factor - which is + what `reconstruction_job.py` does, minus `Delta_l` and `C_beam`. Landing it + on the same absolute scale as the two direct-space modes is a third, larger + piece of work and was not analysed here. +* **`C_det` (F7)** is the only remaining mechanism that can break the mode + equivalence after F1-F3, and it is the one that cannot be validated on + simulated data. It needs the real-data comparison of section 5.5. +* **Error propagation through the new factors.** `measurement` reduces + intensities; the errors follow the same divisors, but a `Delta_gamma` + estimated from the geometry has an uncertainty of its own that nothing + currently tracks. + +## 8. Reproducing the numbers + +```powershell +pytest orgui/datautils/xrayutils/test/test_corrections_measurement.py +pytest orgui/app/test/test_scan_mode_equivalence.py +``` + +Note that `orgui/app/test/test_roi_sum_accel.py` fails in a checkout where the +native ROI extension has not been built ("ROI acceleration is disabled"); that +is unrelated to anything here. diff --git a/doc/source/ctr_structure_factors.rst b/doc/source/ctr_structure_factors.rst index 202bbd3..f623493 100644 --- a/doc/source/ctr_structure_factors.rst +++ b/doc/source/ctr_structure_factors.rst @@ -121,6 +121,23 @@ ANAROD F export, and symmetry averaging reject reflectivity explicitly. Plot panels select labels from the stored quantity and F/R datasets cannot share one axis. +Converting between the two quantities is a question of scale, not of data +handling: Vlieg (1997) equation (63) relates them by + +.. math:: + + R = \frac{r_e^2\lambda^2 P_r} + {A_u^2\,\sin\alpha\,\sin\beta_\mathrm{out}}\,|F_{hkl}|^2 , + +with :math:`A_u` the surface unit-cell area and +:math:`\alpha`, :math:`\beta_\mathrm{out}` the incidence and exit angles that +``CTRScanGeometry`` already records. +:func:`orgui.datautils.xrayutils.corrections.measurement.structure_factor_from_reflectivity` +and its inverse implement it, so an absolutely scaled reflectivity curve and a +set of truncation rods can be brought onto one scale before they are combined. +The relation is kinematical: it does not hold near a bulk Bragg peak, nor below +the critical angle, where the distorted-wave treatment of :doc:`dwba` applies. + CTR fit predictions and statistics ---------------------------------- diff --git a/doc/source/image_integration.rst b/doc/source/image_integration.rst index 35b7202..1f346f5 100644 --- a/doc/source/image_integration.rst +++ b/doc/source/image_integration.rst @@ -161,6 +161,52 @@ footprint and sample size". orGUI does not apply it: the footprint corrections below evaluate the beam profile and the finite sample size numerically instead, which is the row the manual marks as calculated numerically. +.. _comparing-scan-modes: + +Comparing Scan Modes +~~~~~~~~~~~~~~~~~~~~ + +``F2_hkl`` is proportional to :math:`|F_{hkl}|^2` **within** one integration +mode, but a rocking scan and a stationary scan of the same rod are not +currently on the same scale, and the two must not be plotted or fitted +together without rescaling. + +Vlieg's rocking-scan expression (equation 42) contains three factors that the +stationary expression (equation 54) does not, and that orGUI's rocking +integration does not divide out: + +* the **counting time and monitor**. ``Normalize integrated intensities`` + applies to stationary integration only; a rocking integration is not + normalized. +* the **unit of the rocking angle**. The rocking curve is integrated over the + motor position in degrees, while the published expressions integrate in + radian, a factor :math:`180/\pi`. +* the **out-of-plane angular acceptance** :math:`\Delta\gamma` of the region + of interest. A rocking scan intercepts a slice of rod whose length is + proportional to :math:`\Delta\gamma`, so its integrated intensity is too; a + stationary measurement intercepts the whole rod cross-section and has no + such factor. Because ROIs are sized per detector position, this factor is + not even constant along one rocking data set, so it changes the *shape* of + a rod and not only its scale. + +Together, + +.. math:: + + \frac{F^2_{hkl,\mathrm{rocking}}}{F^2_{hkl,\mathrm{stationary}}} + = T\;M\;\Delta\gamma[^\circ] + +with :math:`T` the per-frame counting time, :math:`M` the monitor value and +:math:`\Delta\gamma` the acceptance in degrees. + +:mod:`orgui.datautils.xrayutils.corrections.measurement` implements the full +reduction, for rocking scans, stationary scans and reflectivity, and is the +supported way to put integrated intensities from different modes on one +scale --- and, given the incident flux and the illuminated area, on the +absolute scale of :math:`|F_{hkl}|^2` in electron units. The integration +paths do not use it yet. + + Exposure and Monitor Normalization ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/source/release_notes.rst b/doc/source/release_notes.rst index 5040fcd..6b3f8fe 100644 --- a/doc/source/release_notes.rst +++ b/doc/source/release_notes.rst @@ -9,6 +9,10 @@ Unreleased (2026-07-19) Scientific and analysis additions: +- **All correction factors collected into one package.** Every factor between detector counts and a structure factor now lives in ``orgui.datautils.xrayutils.corrections``, split by what it depends on: ``geometry`` (the z-axis Lorentz, rod-interception and area table), ``beamprofile``, ``activearea``, ``detector`` (per-pixel solid angle and polarization), ``normalization`` (counting time and monitor), ``roi``, and ``measurement``. The rocking integration, the stationary integration and the reciprocal-space reconstruction previously each carried their own copy of several of these; they now share one definition, so they cannot drift onto different scales. The package is physics only -- numbers in, numbers out -- and reads no scan object, configuration or widget; ``orgui.app`` ``integration_corrections`` is the adapter that supplies those. ``orgui.datautils.xrayutils.geometrycorrections`` and ``orgui.datautils.xrayutils.beamprofile`` keep working as aliases of the moved modules. **No calculated value changes.** + +- **One structure-factor scale for rocking scans, stationary scans, and reflectivity.** The new public module ``orgui.datautils.xrayutils.corrections.measurement`` reduces an integrated intensity to ``|F_hkl|^2`` for any scan mode, following E. Vlieg, *J. Appl. Cryst.* 30 (1997) 532 and J. Drnec et al., *J. Appl. Cryst.* 47 (2014) 365. It normalizes counts by exposure time and monitor, converts a rocking integral from degrees to radians, applies the mode-dependent angular factor (rocking scans additionally require the out-of-plane acceptance of the region of interest, stationary measurements reject it), and, given the incident flux density and the illuminated area, puts the result on the absolute electron-unit scale. It also converts between ``|F_hkl|^2`` and absolute reflectivity, so a reflectivity curve and a set of truncation rods can be brought onto one scale. **This is a new API only: no existing integration result changes.** The integration paths do not use it yet, and rocking and stationary integration remain on different scales, differing by exposure time times monitor times the acceptance in degrees; the image-integration documentation now says so explicitly, and ``doc/design/ctr_structure_factor_scale.md`` records the full analysis with the measured size of every correction. + - **Unified CTR fit predictions, lifecycle, and statistics.** CTR optimizer predictions, residuals, likelihoods, and diagnostics now use one final analytically scaled result path. ``flat_prediction`` supports the common F/R result contract, while ``flat_Fcalc``, ``Rfactor``, and the new ``Rfactor_R`` enforce quantity-specific diagnostics. ``calculated_CTRs`` now always exposes the latest successful final predictions, independently of resolution broadening. Evaluation requires ``prepareFit()`` after parameter layout changes, supported fixed-model changes refresh automatically, and a failed evaluation cannot leave a stale public result. Fit statistics count eliminated analytical scales in their degrees of freedom, report covariance on the same reduced-chi-square scale as parameter errors, and clear unavailable errors throughout the fitted crystal instead of retaining an older estimate. - **Preserved signed CTR intensities during amplitude conversion.** ``CTR.convertToF`` now maps every finite intensity through ``sign(I) * sqrt(abs(I))`` instead of discarding negative measurements. Its symmetric uncertainty is half the transformed input interval, remains finite at zero, and uses a stable evaluation at high signal-to-noise. Conversion remains in place, preserves aligned metadata, applies the signed mapping to auxiliary intensity counters, and rejects reflectivity-tagged data and invalid uncertainties explicitly. From 6959191d8baea660f755b1c8490b41fc9ffa1917 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Wed, 9 Sep 2026 18:27:01 -0400 Subject: [PATCH 04/33] feat: estimate the out-of-plane detector acceptance --- CHANGELOG.md | 11 + doc/design/ctr_structure_factor_scale.md | 46 ++- doc/source/release_notes.rst | 2 + orgui/datautils/xrayutils/AGENTS.md | 10 +- .../xrayutils/corrections/__init__.py | 5 + .../xrayutils/corrections/acceptance.py | 255 +++++++++++++++ .../test/test_corrections_acceptance.py | 296 ++++++++++++++++++ .../test/test_corrections_package.py | 1 + 8 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 orgui/datautils/xrayutils/corrections/acceptance.py create mode 100644 orgui/datautils/xrayutils/test/test_corrections_acceptance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c956405..e9a4bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,17 @@ Scientific and analysis additions: ``orgui.datautils.xrayutils.beamprofile`` keep working as aliases of the moved modules. **No calculated value changes.** +- **Out-of-plane detector acceptance.** + ``orgui.datautils.xrayutils.corrections.acceptance`` estimates + ``Delta_gamma``, the angular height of a region of interest as seen from the + sample, which a rocking-scan integrated intensity is proportional to (Vlieg + equations 20 and 42) and which orGUI previously did not compute at all. + Measured edge to edge, at the region's centre column where the rod crosses + the aperture, and vectorized over a scan because orGUI resizes regions per + detector position. ``gamma_range`` reports the span over the whole region as + a check on rolled-detector geometries, and ``pixel_acceptance`` the one-row + case. **A new API only: the integration paths do not call it yet.** + - **One structure-factor scale for rocking scans, stationary scans, and reflectivity.** The new public module ``orgui.datautils.xrayutils.corrections.measurement`` reduces an integrated diff --git a/doc/design/ctr_structure_factor_scale.md b/doc/design/ctr_structure_factor_scale.md index 8076bfd..e6ab161 100644 --- a/doc/design/ctr_structure_factor_scale.md +++ b/doc/design/ctr_structure_factor_scale.md @@ -121,8 +121,9 @@ so its integrated intensity is *proportional to the vertical angular size of the region of interest*. A stationary measurement has no such factor. Before this branch, `Delta_gamma` appeared nowhere in orGUI: `grep -rn -"acceptance"` over `orgui/` returned nothing, and the integration paths still -do not compute it. In the point-detector world it was a fixed slit setting and +"acceptance"` over `orgui/` returned nothing. It is now estimated by +`corrections/acceptance.py` (section 4.4), though the integration paths do +not call it yet. In the point-detector world it was a fixed slit setting and disappeared into the overall scale factor. On an area detector with ROIs sized per detector position by `orgui/app/ROIutils.py:calc_corrections` (projected sample size plus a parallax correction, so it changes with `delta` and @@ -357,6 +358,47 @@ recover the input `|F|^2` - the stationary path exactly, the rocking one to `T * monitor * Delta_gamma_in_degrees`. It must be replaced by a plain equality when the GUI paths adopt the reduction. Its docstring says so. +### 4.4 The Delta_gamma estimator + +`corrections/acceptance.py` supplies the factor F3 says is missing. It reads +the exit angle at the top and bottom **edges** of a region of interest with +`Detector2D_SXRD.surfaceAnglesPoint` and returns the span: + +* `out_of_plane_acceptance(detector, row, column, row_size, alpha, ...)` - + `Delta_gamma` in radian, evaluated at the region's centre column, which is + where the rod crosses the aperture. Vectorized over frames, since orGUI + resizes regions along a scan. +* `gamma_range(...)` - the span over the whole rectangle. It equals the + centre-column value when iso-gamma lines run along the detector rows, and + exceeds it on a rolled detector, so their ratio is a cheap check on whether + the estimate can be trusted. +* `pixel_acceptance(...)` - the one-row case; the resolution limit, and the + natural unit to quote an acceptance in. + +Edges, not pixel centres: a region of `n` rows accepts `n` pixels' worth of +angle, not `n - 1`. Using centres would understate every rocking acceptance +by one pixel - 1 % on a 100-row region, 10 % on a 10-row one. + +Two things fell out of writing the tests that were not obvious beforehand: + +* **pyFAI requires both pixel-coordinate arrays to have the same size.** A + scalar column with a per-frame `row_size` - exactly what a rocking + integration passes - failed inside the extension with + `assert pos2.size == size`. The estimator broadcasts the two coordinates + before the call. +* **`Delta_gamma` is nearly invariant under a detector-arm rotation.** Driving + the gamma arm shifts the exit angle at the region centre by the full arm + angle but leaves the span unchanged to nine digits, because the rotation is + about the axis gamma is measured around; a 40-degree delta-arm rotation + moves it by one part in `1e5`. This is the opposite of the polarization + factor of F5, where ignoring the arm is a 10 % error at 18 degrees. An + acceptance computed without arm bookkeeping is still usable. + +Measured against a calibrated 172 um detector at 1 m, a 60-row region accepts +0.5913 degrees, matching `n * pixel / dist` to 2e-3, and shrinks by a few per +mille towards the detector edge - the obliquity that makes `Delta_gamma` vary +along a scan in the first place. + ## 5. What is needed to close issue #82 In order: diff --git a/doc/source/release_notes.rst b/doc/source/release_notes.rst index 6b3f8fe..f1cf643 100644 --- a/doc/source/release_notes.rst +++ b/doc/source/release_notes.rst @@ -11,6 +11,8 @@ Scientific and analysis additions: - **All correction factors collected into one package.** Every factor between detector counts and a structure factor now lives in ``orgui.datautils.xrayutils.corrections``, split by what it depends on: ``geometry`` (the z-axis Lorentz, rod-interception and area table), ``beamprofile``, ``activearea``, ``detector`` (per-pixel solid angle and polarization), ``normalization`` (counting time and monitor), ``roi``, and ``measurement``. The rocking integration, the stationary integration and the reciprocal-space reconstruction previously each carried their own copy of several of these; they now share one definition, so they cannot drift onto different scales. The package is physics only -- numbers in, numbers out -- and reads no scan object, configuration or widget; ``orgui.app`` ``integration_corrections`` is the adapter that supplies those. ``orgui.datautils.xrayutils.geometrycorrections`` and ``orgui.datautils.xrayutils.beamprofile`` keep working as aliases of the moved modules. **No calculated value changes.** +- **Out-of-plane detector acceptance.** ``orgui.datautils.xrayutils.corrections.acceptance`` estimates ``Delta_gamma``, the angular height of a region of interest as seen from the sample, which a rocking-scan integrated intensity is proportional to (Vlieg equations 20 and 42) and which orGUI previously did not compute at all. Measured edge to edge, at the region's centre column where the rod crosses the aperture, and vectorized over a scan because orGUI resizes regions per detector position. ``gamma_range`` reports the span over the whole region as a check on rolled-detector geometries, and ``pixel_acceptance`` the one-row case. **A new API only: the integration paths do not call it yet.** + - **One structure-factor scale for rocking scans, stationary scans, and reflectivity.** The new public module ``orgui.datautils.xrayutils.corrections.measurement`` reduces an integrated intensity to ``|F_hkl|^2`` for any scan mode, following E. Vlieg, *J. Appl. Cryst.* 30 (1997) 532 and J. Drnec et al., *J. Appl. Cryst.* 47 (2014) 365. It normalizes counts by exposure time and monitor, converts a rocking integral from degrees to radians, applies the mode-dependent angular factor (rocking scans additionally require the out-of-plane acceptance of the region of interest, stationary measurements reject it), and, given the incident flux density and the illuminated area, puts the result on the absolute electron-unit scale. It also converts between ``|F_hkl|^2`` and absolute reflectivity, so a reflectivity curve and a set of truncation rods can be brought onto one scale. **This is a new API only: no existing integration result changes.** The integration paths do not use it yet, and rocking and stationary integration remain on different scales, differing by exposure time times monitor times the acceptance in degrees; the image-integration documentation now says so explicitly, and ``doc/design/ctr_structure_factor_scale.md`` records the full analysis with the measured size of every correction. - **Unified CTR fit predictions, lifecycle, and statistics.** CTR optimizer predictions, residuals, likelihoods, and diagnostics now use one final analytically scaled result path. ``flat_prediction`` supports the common F/R result contract, while ``flat_Fcalc``, ``Rfactor``, and the new ``Rfactor_R`` enforce quantity-specific diagnostics. ``calculated_CTRs`` now always exposes the latest successful final predictions, independently of resolution broadening. Evaluation requires ``prepareFit()`` after parameter layout changes, supported fixed-model changes refresh automatically, and a failed evaluation cannot leave a stale public result. Fit statistics count eliminated analytical scales in their degrees of freedom, report covariance on the same reduced-chi-square scale as parameter errors, and clear unavailable errors throughout the fitted crystal instead of retaining an older estimate. diff --git a/orgui/datautils/xrayutils/AGENTS.md b/orgui/datautils/xrayutils/AGENTS.md index 317c00b..d60cb6a 100644 --- a/orgui/datautils/xrayutils/AGENTS.md +++ b/orgui/datautils/xrayutils/AGENTS.md @@ -24,10 +24,12 @@ This directory contains the highest-risk scientific code: this file. - `corrections/`: every factor between detector counts and a structure factor -- `geometry.py` (z-axis Lorentz/rod-interception/area table), - `beamprofile.py`, `activearea.py`, `detector.py` (per-pixel solid angle and - polarization), `normalization.py` (counting time and monitor), `roi.py`, - and `measurement.py` (which factors each scan mode applies, and the - reduction to `|F_hkl|^2` and absolute reflectivity). The rocking + `beamprofile.py`, `activearea.py`, `acceptance.py` (the out-of-plane + `Delta_gamma` a rocking scan is proportional to), `detector.py` (per-pixel + solid angle and polarization), `normalization.py` (counting time and + monitor), `roi.py`, and `measurement.py` (which factors each scan mode + applies, and the reduction to `|F_hkl|^2` and absolute reflectivity). The + rocking integration, the stationary integration and the reconstruction all correct their data through this package, so a factor must be defined here once rather than per caller. `geometrycorrections.py` and `beamprofile.py` at diff --git a/orgui/datautils/xrayutils/corrections/__init__.py b/orgui/datautils/xrayutils/corrections/__init__.py index f530ffd..03ff0cd 100644 --- a/orgui/datautils/xrayutils/corrections/__init__.py +++ b/orgui/datautils/xrayutils/corrections/__init__.py @@ -50,6 +50,9 @@ :mod:`~.activearea` The illuminated active surface area :math:`A`, in square meter, in both the slit-limited and the beam-limited case. +:mod:`~.acceptance` + How much of a rod a region of interest accepts: the out-of-plane + :math:`\Delta\gamma` a rocking scan is proportional to. :mod:`~.detector` Per-pixel factors of a detector image: solid angle and polarization. :mod:`~.normalization` @@ -72,6 +75,7 @@ """ from . import ( # noqa: F401 + acceptance, activearea, beamprofile, detector, @@ -82,6 +86,7 @@ ) __all__ = [ + "acceptance", "activearea", "beamprofile", "detector", diff --git a/orgui/datautils/xrayutils/corrections/acceptance.py b/orgui/datautils/xrayutils/corrections/acceptance.py new file mode 100644 index 0000000..95c23a9 --- /dev/null +++ b/orgui/datautils/xrayutils/corrections/acceptance.py @@ -0,0 +1,255 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +r"""How much of a diffraction rod a region of interest accepts. + +A rocking scan does not measure a whole rod. It intercepts a slice of one, +and the slice is as long as the detector aperture is wide in the out-of-plane +direction: :math:`\Delta l = C_\mathrm{rod}\,(V_u / \lambda A_u)\,\Delta\gamma` +(E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532, equation 20). Its integrated +intensity is therefore **proportional to** :math:`\Delta\gamma`, which is why +:func:`~.measurement.angular_factor` refuses to reduce a rocking scan without +one. A stationary measurement integrates across the whole rod cross-section +and has no such factor. + +With a point detector :math:`\Delta\gamma` was a slit setting: fixed for a +whole experiment, and absorbed into the overall scale factor nobody had to +know. On an area detector it is a property of the *region of interest*, and +orGUI sizes regions of interest per detector position +(:func:`orgui.app.ROIutils.calc_corrections` scales them with the projected +sample size and a parallax correction). It is therefore not constant along a +scan, and leaving it out changes the shape of a rod, not only its scale. + +What this module computes is the angular height of the region of interest as +seen from the sample, in the surface frame, from the calibrated detector +geometry. It is a geometric estimate: it assumes the region is centered on +the rod and that the rod runs along the region's vertical direction. Both +hold for the regions orGUI places from a calculated reflection position, and +:func:`gamma_range` measures how well by reporting the span over the whole +region. + +The in-plane counterpart, Vlieg's :math:`C_\mathrm{det}` (his section 2.4), +is the other half of the detector acceptance and is not implemented yet; a +region wide enough to contain the whole in-plane peak profile has +:math:`C_\mathrm{det} = 1`, which is what the reduction assumes. + +.. note:: + + :math:`\Delta\gamma` is almost invariant under a detector-arm rotation. + Driving the :math:`\gamma` arm shifts the exit angle at the region center + by the full arm angle, but leaves the span that region subtends unchanged + to nine digits, because the rotation is about the axis :math:`\gamma` is + measured around; a :math:`\delta`-arm rotation of 40 degrees moves it by + one part in :math:`10^5`. So an acceptance computed without the arm is + still usable, which is the opposite of the polarization factor, where + ignoring the arm is a 10 % error at a scattering angle of 18 degrees (see + :mod:`~.detector`). Pass the arm anyway where it is known -- it costs + nothing -- but a missing arm is not a reason to distrust an acceptance. +""" + +import numpy as np + +__all__ = [ + "gamma_range", + "out_of_plane_acceptance", + "pixel_acceptance", +] + + +def _surface_gamma(detector, row, column, alpha, gamma_arm=None, delta_arm=None): + """Surface-frame exit angle at the given detector points, in radian. + + Isolates the one call into + :meth:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD.surfaceAnglesPoint`, + whose first argument is pyFAI dimension 1 -- the detector **row**, not a + horizontal coordinate -- so the ordering is stated once rather than at + every call site. + + The two coordinates are broadcast against each other first. pyFAI asserts + that they have the same size, so a scan of per-frame region heights at + one fixed column -- which is what a rocking integration passes -- would + otherwise fail inside the extension with ``pos2.size == size``. + """ + row, column = np.broadcast_arrays( + np.asarray(row, dtype=np.float64), + np.asarray(column, dtype=np.float64), + ) + gamma, _delta = detector.surfaceAnglesPoint( + np.ascontiguousarray(row), + np.ascontiguousarray(column), + alpha, + gamma_arm, + delta_arm, + ) + return np.asarray(gamma, dtype=np.float64) + + +def out_of_plane_acceptance( + detector, + row, + column, + row_size, + alpha, + gamma_arm=None, + delta_arm=None, +): + r"""Out-of-plane angular acceptance :math:`\Delta\gamma`, in radian. + + The exit-angle span between the top and bottom **edges** of a region of + interest, evaluated at its center column. Edges, not pixel centers: a + region of ``row_size`` rows accepts photons from ``row - row_size/2`` to + ``row + row_size/2``, and using the centers would understate the + acceptance by one pixel. + + The center column is the right place to measure it because the region is + centered on the rod, and it is the range of :math:`\gamma` over which + *the rod* crosses the aperture that sets the intercepted rod length. + Where the detector is rotated enough that the rod does not run along the + region's columns, this underestimates the span; :func:`gamma_range` + reports the span over the whole region so the two can be compared. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`, + or anything with the same ``surfaceAnglesPoint`` contract. + :param row: Center row of the region, in pixels along pyFAI dimension 1. + Scalar or one value per frame. + :param column: Center column of the region, in pixels along pyFAI + dimension 2. Scalar or one value per frame. + :param row_size: Height of the region, in pixels. Scalar or one value per + frame -- orGUI resizes regions along a scan, so this is normally an + array. + :param alpha: Incidence angle of the frame, in radian. + :param gamma_arm: Detector arm position, in radian; ``None`` is the + calibrated position. Give both arm angles or neither. + :param delta_arm: Detector arm position, in radian; ``None`` is the + calibrated position. + :returns: :math:`\Delta\gamma` in radian, broadcast over the inputs and + always positive. + :rtype: numpy.ndarray + :raises ValueError: If a region height is not positive, or exactly one + arm angle is given. + """ + row = np.asarray(row, dtype=np.float64) + column = np.asarray(column, dtype=np.float64) + row_size = np.asarray(row_size, dtype=np.float64) + if np.any(row_size <= 0) or not np.all(np.isfinite(row_size)): + raise ValueError( + "the region of interest must have a positive height in pixels; " + f"got {row_size!r}" + ) + half = row_size / 2.0 + lower = _surface_gamma( + detector, row - half, column, alpha, gamma_arm, delta_arm + ) + upper = _surface_gamma( + detector, row + half, column, alpha, gamma_arm, delta_arm + ) + return np.abs(upper - lower) + + +def gamma_range( + detector, + row, + column, + row_size, + column_size, + alpha, + gamma_arm=None, + delta_arm=None, +): + r"""Exit-angle span over a whole rectangular region, in radian. + + The corner-to-corner :math:`\gamma` extent, as opposed to the + center-column extent :func:`out_of_plane_acceptance` returns. The two + agree when lines of constant :math:`\gamma` run along the detector rows; + they separate when the detector is rotated about the beam, and the ratio + is a cheap diagnostic for whether the acceptance estimate can be trusted. + + Evaluated at the four corners: :math:`\gamma` is monotonic in both pixel + directions over a region small enough to hold one reflection, so the + extremes are corners. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`. + :param row: Center row of the region, in pixels. + :param column: Center column of the region, in pixels. + :param row_size: Height of the region, in pixels. + :param column_size: Width of the region, in pixels. + :param alpha: Incidence angle of the frame, in radian. + :param gamma_arm: Detector arm position, in radian. + :param delta_arm: Detector arm position, in radian. + :returns: The full span, broadcast over the inputs and always positive. + :rtype: numpy.ndarray + :raises ValueError: If a region size is not positive. + """ + row = np.asarray(row, dtype=np.float64) + column = np.asarray(column, dtype=np.float64) + row_size = np.asarray(row_size, dtype=np.float64) + column_size = np.asarray(column_size, dtype=np.float64) + for name, value in (("height", row_size), ("width", column_size)): + if np.any(value <= 0) or not np.all(np.isfinite(value)): + raise ValueError( + f"the region of interest must have a positive {name} in " + f"pixels; got {value!r}" + ) + half_row = row_size / 2.0 + half_column = column_size / 2.0 + corners = [ + _surface_gamma( + detector, + row + dr * half_row, + column + dc * half_column, + alpha, + gamma_arm, + delta_arm, + ) + for dr in (-1.0, 1.0) + for dc in (-1.0, 1.0) + ] + stacked = np.stack(np.broadcast_arrays(*corners)) + return np.max(stacked, axis=0) - np.min(stacked, axis=0) + + +def pixel_acceptance(detector, row, column, alpha, gamma_arm=None, delta_arm=None): + r"""Exit-angle height of a single pixel, in radian. + + The :math:`\gamma` subtended by one pixel row at the given position: the + resolution limit of :func:`out_of_plane_acceptance`, and the natural unit + to express a region's acceptance in. A region of ``n`` rows on a flat + detector viewed near its normal accepts about ``n`` times this; the ratio + departs from ``n`` exactly where the detector is oblique, which is the + effect that makes the acceptance vary along a scan. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`. + :param row: Row of the pixel, in pixels. + :param column: Column of the pixel, in pixels. + :param alpha: Incidence angle of the frame, in radian. + :param gamma_arm: Detector arm position, in radian. + :param delta_arm: Detector arm position, in radian. + :returns: The angular height of that pixel, broadcast over the inputs. + :rtype: numpy.ndarray + """ + return out_of_plane_acceptance( + detector, row, column, 1.0, alpha, gamma_arm, delta_arm + ) diff --git a/orgui/datautils/xrayutils/test/test_corrections_acceptance.py b/orgui/datautils/xrayutils/test/test_corrections_acceptance.py new file mode 100644 index 0000000..c8cf5c9 --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_corrections_acceptance.py @@ -0,0 +1,296 @@ +"""Regression tests for the out-of-plane detector acceptance. + +:mod:`orgui.datautils.xrayutils.corrections.acceptance` estimates the +:math:`\\Delta\\gamma` that a rocking-scan integrated intensity is +proportional to (E. Vlieg, *J. Appl. Cryst.* **30** (1997) 532, equations 20 +and 42). See ``doc/design/ctr_structure_factor_scale.md`` finding F3. +""" + +import numpy as np +import pytest + +pyFAI = pytest.importorskip("pyFAI") + +from orgui.datautils.xrayutils import DetectorCalibration # noqa: E402 +from orgui.datautils.xrayutils.corrections import acceptance # noqa: E402 + +#: Pixel size and distance of the calibrated test detector, in meter. +PIXEL, DIST = 172e-6, 1.0 +SHAPE = (619, 487) + + +class _LinearDetector: + """A detector whose exit angle is an exact linear function of the row. + + Lets every expectation below be written in closed form, independently of + the pyFAI geometry, so a failure points at this module rather than at the + detector calibration. + """ + + #: Radian per row, and per column to make the two directions separable. + PER_ROW = 1e-4 + PER_COLUMN = 3e-5 + + def surfaceAnglesPoint(self, x, y, alpha_i, gamma_arm=None, delta_arm=None): + # First argument is pyFAI dimension 1, the row. + arm = 0.0 if gamma_arm is None else gamma_arm + gamma = self.PER_ROW * np.asarray(x) + self.PER_COLUMN * np.asarray(y) + return gamma + arm - np.asarray(alpha_i), np.zeros_like(gamma) + + +def _calibrated_detector(rot3=0.0): + """A real, calibrated area detector one meter from the sample.""" + det = DetectorCalibration.Detector2D_SXRD() + det.detector = pyFAI.detectors.Detector( + pixel1=PIXEL, pixel2=PIXEL, max_shape=SHAPE + ) + det.poni1 = SHAPE[0] * PIXEL / 2.0 + det.poni2 = SHAPE[1] * PIXEL / 2.0 + det.rot1 = det.rot2 = 0.0 + det.rot3 = rot3 + det.dist = DIST + det.set_energy(17.7) + det.setAzimuthalReference(np.deg2rad(90.0)) + det.setPolarization(0.0, 1.0) + det.reset() + det._cached_array = {} + return det + + +def test_acceptance_is_measured_edge_to_edge_not_centre_to_centre(): + """A region of ``n`` rows accepts ``n`` pixels' worth, not ``n - 1``. + + The aperture is the outer pixel boundaries. Measuring between the centres + of the first and last row would understate every rocking acceptance by + one pixel, which is a 1 % error on a 100-row region and 10 % on a 10-row + one. + """ + det = _LinearDetector() + + for rows in (1.0, 4.0, 64.0): + got = acceptance.out_of_plane_acceptance(det, 300.0, 240.0, rows, 0.0) + np.testing.assert_allclose(got, rows * det.PER_ROW, rtol=1e-12) + + +def test_acceptance_is_evaluated_at_the_centre_column(): + """Where the region sits in-plane must not change its rod acceptance. + + The rod crosses the aperture at the region's centre column, so the column + the acceptance is read at is the centre one. On a detector whose gamma + also varies along a row, reading it anywhere else would leak the in-plane + position into an out-of-plane quantity. + """ + det = _LinearDetector() + + at_left = acceptance.out_of_plane_acceptance(det, 300.0, 10.0, 40.0, 0.0) + at_right = acceptance.out_of_plane_acceptance(det, 300.0, 470.0, 40.0, 0.0) + + np.testing.assert_allclose(at_left, at_right, rtol=1e-12) + np.testing.assert_allclose(at_left, 40.0 * det.PER_ROW, rtol=1e-12) + + +def test_acceptance_is_positive_and_vectorized_over_a_scan(): + """orGUI resizes regions along a scan, so this is normally an array. + + Both the centre and the size vary per frame, and the result must follow + both without a Python loop and without picking up a sign from the + direction the rows run in. + """ + det = _LinearDetector() + row = np.array([100.0, 300.0, 500.0]) + size = np.array([20.0, 45.0, 80.0]) + + got = acceptance.out_of_plane_acceptance(det, row, 240.0, size, 0.0) + + assert got.shape == (3,) + assert np.all(got > 0) + np.testing.assert_allclose(got, size * det.PER_ROW, rtol=1e-12) + + +def test_a_zero_or_negative_region_is_rejected(): + """An empty region accepts no rod, and silently returning 0 would divide.""" + det = _LinearDetector() + + with pytest.raises(ValueError, match="positive height in pixels"): + acceptance.out_of_plane_acceptance(det, 300.0, 240.0, 0.0, 0.0) + with pytest.raises(ValueError, match="positive height in pixels"): + acceptance.out_of_plane_acceptance(det, 300.0, 240.0, -5.0, 0.0) + + +def test_pixel_acceptance_is_the_one_row_case(): + """The resolution limit, and the unit a region's acceptance is read in.""" + det = _LinearDetector() + + one = acceptance.pixel_acceptance(det, 300.0, 240.0, 0.0) + many = acceptance.out_of_plane_acceptance(det, 300.0, 240.0, 32.0, 0.0) + + np.testing.assert_allclose(one, det.PER_ROW, rtol=1e-12) + np.testing.assert_allclose(many / one, 32.0, rtol=1e-12) + + +def test_gamma_range_matches_the_column_span_when_rows_are_iso_gamma(): + """With no roll about the beam, the two estimates agree. + + ``gamma_range`` covers the whole rectangle; ``out_of_plane_acceptance`` + only its centre column. They coincide exactly when lines of constant + gamma run along the detector rows, which is what makes their ratio a + usable diagnostic. + """ + det = _calibrated_detector(rot3=0.0) + row, column, height, width = 300.0, 240.0, 60.0, 40.0 + alpha = np.deg2rad(0.6) + + column_span = acceptance.out_of_plane_acceptance( + det, row, column, height, alpha + ) + full_span = acceptance.gamma_range( + det, row, column, height, width, alpha + ) + + np.testing.assert_allclose(full_span, column_span, rtol=1e-4) + + +def test_gamma_range_exceeds_the_column_span_on_a_rolled_detector(): + """Rolling the detector tilts the iso-gamma lines off the rows. + + Then the corners reach further in gamma than the centre column does, and + the centre-column estimate is the one to trust for a rod-centred region + while the ratio warns that the geometry is no longer aligned. + """ + det = _calibrated_detector(rot3=np.deg2rad(20.0)) + row, column, height, width = 300.0, 240.0, 60.0, 120.0 + alpha = np.deg2rad(0.6) + + column_span = acceptance.out_of_plane_acceptance( + det, row, column, height, alpha + ) + full_span = acceptance.gamma_range( + det, row, column, height, width, alpha + ) + + assert full_span > column_span * 1.2 + + +def test_acceptance_scales_with_region_height_on_a_real_detector(): + """A calibrated detector one meter away, checked against small-angle optics. + + Near the beam centre a flat detector subtends ``pixel / dist`` per row to + first order, so a region of ``n`` rows accepts about ``n * pixel / dist``. + This pins the units -- radian, not degrees or pixels -- against a number + written down from the geometry rather than from the code. + """ + det = _calibrated_detector() + alpha = np.deg2rad(0.6) + heights = np.array([10.0, 50.0, 200.0]) + + got = acceptance.out_of_plane_acceptance( + det, SHAPE[0] / 2.0, SHAPE[1] / 2.0, heights, alpha + ) + + np.testing.assert_allclose(got, heights * PIXEL / DIST, rtol=2e-3) + # Linear in the height to the same order. + np.testing.assert_allclose(got / heights, got[0] / heights[0], rtol=2e-3) + + +def test_acceptance_shrinks_where_the_detector_is_oblique(): + """The reason Delta_gamma is not constant along a scan. + + Away from the detector normal the same number of rows subtends less + angle. A region that keeps its pixel size therefore accepts a shorter + piece of rod as the reflection moves up the detector, and a rocking scan + that ignores this reports a rod whose shape follows the detector. + """ + det = _calibrated_detector() + alpha = np.deg2rad(0.6) + centre = acceptance.out_of_plane_acceptance( + det, SHAPE[0] / 2.0, SHAPE[1] / 2.0, 60.0, alpha + ) + edge = acceptance.out_of_plane_acceptance(det, 40.0, SHAPE[1] / 2.0, 60.0, alpha) + + assert edge < centre + assert 0.95 < edge / centre < 0.999 + + +def test_a_moving_arm_barely_changes_the_acceptance(): + """Delta_gamma survives a moving arm; the polarization factor does not. + + Driving the gamma arm moves the region to a completely different exit + angle -- gamma at the region center shifts by the full arm angle -- but + the *span* the region subtends is unchanged, because that rotation is + about the very axis gamma is measured around. A delta-arm rotation and an + off-center column break the invariance only in the fifth digit. + + This is worth pinning because it is the opposite of the polarization + factor, where evaluating at the calibrated position instead of the real + one is a 10 % error at a scattering angle of 18 degrees and 33 % at 30 + (see finding F5). An acceptance estimated with the arm left out is fine; + a polarization is not. + """ + det = _calibrated_detector() + alpha = np.deg2rad(0.6) + row, column = SHAPE[0] / 2.0, SHAPE[1] / 2.0 + arm = np.deg2rad(25.0) + + at_home = det.surfaceAnglesPoint(np.array([row]), np.array([column]), alpha)[0] + at_arm = det.surfaceAnglesPoint( + np.array([row]), np.array([column]), alpha, arm, 0.0 + )[0] + np.testing.assert_allclose(at_arm - at_home, arm, atol=1e-9) + + home = acceptance.out_of_plane_acceptance(det, row, column, 60.0, alpha) + moved = acceptance.out_of_plane_acceptance( + det, row, column, 60.0, alpha, gamma_arm=arm, delta_arm=0.0 + ) + np.testing.assert_allclose(moved, home, rtol=1e-9) + + tilted = acceptance.out_of_plane_acceptance( + det, row, column, 60.0, alpha, gamma_arm=0.0, delta_arm=np.deg2rad(40.0) + ) + np.testing.assert_allclose(tilted, home, rtol=1e-4) + assert not np.isclose(tilted, home, rtol=1e-9) + + +def test_one_arm_angle_alone_is_rejected(): + """The calibration reference is a rotation, not two independent offsets.""" + det = _calibrated_detector() + + with pytest.raises(ValueError, match="both gamma_arm and delta_arm"): + acceptance.out_of_plane_acceptance( + det, + SHAPE[0] / 2.0, + SHAPE[1] / 2.0, + 60.0, + np.deg2rad(0.6), + gamma_arm=np.deg2rad(25.0), + ) + + +def test_the_acceptance_is_what_the_reduction_asks_for(): + """It plugs straight into the rocking angular factor. + + ``angular_factor`` refuses a rocking scan without an acceptance and + demands radian; this is the function that supplies it, so the two are + checked together rather than each against its own convention. + """ + from orgui.datautils.xrayutils.corrections import measurement + + det = _calibrated_detector() + alpha = np.deg2rad(0.6) + delta, gamma = np.deg2rad(16.8), np.deg2rad(5.3) + + d_gamma = acceptance.out_of_plane_acceptance( + det, SHAPE[0] / 2.0, SHAPE[1] / 2.0, 60.0, alpha + ) + eta = measurement.angular_factor( + measurement.ROCKING, + alpha=alpha, + delta=delta, + gamma=gamma, + detector_acceptance=d_gamma, + ) + + components = measurement.mode_components( + measurement.ROCKING, alpha=alpha, delta=delta, gamma=gamma + ) + expected = components["C_Lorentz"] * components["C_rod"] * d_gamma + np.testing.assert_allclose(eta, expected, rtol=1e-12) diff --git a/orgui/datautils/xrayutils/test/test_corrections_package.py b/orgui/datautils/xrayutils/test/test_corrections_package.py index bd940db..8116ed4 100644 --- a/orgui/datautils/xrayutils/test/test_corrections_package.py +++ b/orgui/datautils/xrayutils/test/test_corrections_package.py @@ -43,6 +43,7 @@ def polarizationArray(self): def test_the_package_exposes_every_correction_module(): """A maintainer looking for a correction factor finds them in one place.""" assert set(corrections.__all__) == { + "acceptance", "activearea", "beamprofile", "detector", From ecb3bb593fa841f75d8faf19c2eeb453ac4c87a5 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Thu, 10 Sep 2026 10:56:03 -0400 Subject: [PATCH 05/33] docs: settle F6 and add the structure-factor physics reference --- doc/design/ctr_structure_factor_handover.md | 271 +++++++ doc/design/ctr_structure_factor_scale.md | 159 +++- doc/physics/.gitignore | 10 + doc/physics/ctr_structure_factor_physics.tex | 745 ++++++++++++++++++ .../test/test_corrections_acceptance.py | 53 ++ 5 files changed, 1208 insertions(+), 30 deletions(-) create mode 100644 doc/design/ctr_structure_factor_handover.md create mode 100644 doc/physics/.gitignore create mode 100644 doc/physics/ctr_structure_factor_physics.tex diff --git a/doc/design/ctr_structure_factor_handover.md b/doc/design/ctr_structure_factor_handover.md new file mode 100644 index 0000000..bdde295 --- /dev/null +++ b/doc/design/ctr_structure_factor_handover.md @@ -0,0 +1,271 @@ +# CTR structure-factor scale: implementation status and handover + +> **Status as of 2026-09-09.** Branch `claude/ctr-structure-factor-9633bc`, +> four commits ahead of `master`, nothing pushed. +> +> The physics analysis is complete and quantified, the reduction and the +> acceptance estimator exist and are tested, and the correction factors have +> one home. **No saved number has changed yet**: the GUI integration paths do +> not call any of the new reduction. Issues +> [#82](https://github.com/tifuchs/orGUI/issues/82) and +> [#15](https://github.com/tifuchs/orGUI/issues/15) are therefore *analysed and +> equipped* but not closed. +> +> This document is the handover: what exists, how to run it, what to do next, +> and which of my predictions turned out wrong. The physics itself is in +> [`ctr_structure_factor_scale.md`](ctr_structure_factor_scale.md) — read that +> first for *why*; this one is *where things stand*. + +## 1. The one-paragraph summary + +A rocking scan and a stationary scan of the same rod do not currently produce +the same `F2_hkl` in orGUI. The ratio is exactly +`T_omega * monitor_omega * Delta_gamma_in_degrees`, measured to seven digits on +simulated data. Three things are missing from the rocking path: exposure/monitor +normalization, integration in radian rather than degrees, and division by the +out-of-plane detector acceptance. All three now have working implementations in +`orgui/datautils/xrayutils/corrections/`; none of them is wired in. A fourth, +F6, affects **both** modes: a ROI sum is already a complete angular integral, +so the solid-angle correction has to stop being applied to it, or the two +modes stay apart by `<1/Omega~>` even after the rocking path is fixed. + +## 2. What is on the branch + +| commit | what it did | +|---|---| +| `777d887` | `refactor: collect correction factors into one corrections package` | +| `4193c80` | `feat: reduce integrated intensities to \|F_hkl\|^2 on one scale` | +| `e25b8df` | `docs: record the rocking/stationary structure-factor scale analysis` | +| `6959191` | `feat: estimate the out-of-plane detector acceptance` | + +The first three were split out of one working tree at the end, which required +*staged versions* of four files: `peak1Dintegr.py`, `integration_corrections.py`, +`corrections/__init__.py` and `test_corrections_package.py` all reference +`measurement`, which only exists from `4193c80`. In `777d887` they keep the old +inline Lorentz selection with only the import path updated. Each commit was +verified green on its own, not just the tip. + +### 2.1 The package + +``` +orgui/datautils/xrayutils/corrections/ + geometry.py 219 z-axis Lorentz x3, rod interception, area factor + beamprofile.py 1049 beam profile shapes and their integrals + activearea.py 190 active area in m^2, slit- and beam-limited + acceptance.py 255 Delta_gamma, gamma_range, pixel_acceptance + detector.py 89 per-pixel solid angle and polarization + normalization.py 103 counting time and monitor, from values + roi.py 93 CorrectionFactors, roi_mean_correction + measurement.py 605 mode dispatch, master equation, reflectivity +``` + +`orgui/datautils/xrayutils/geometrycorrections.py` and `beamprofile.py` remain +as re-export aliases because both were released under those names. +`test_corrections_package.py` asserts the aliases hand out the *same objects*, +not merely that they import. + +### 2.2 What is wired, and what is not + +| caller | uses the package for | still does its own thing | +|---|---|---| +| `orGUI.integrateROI` (stationary) | `pixel_factors`, `mode_components`, `normalization_divisor`, `C_illum_area` | — | +| `orGUI.rocking_integrate` | `pixel_factors` | — | +| `peak1Dintegr.integrate` (rocking) | `mode_components` | **no normalization, degrees, no `Delta_gamma`** | +| `reconstruction_job` | `pixel_factors` | own native-fused application, own normalization loop | + +Nothing calls `measurement.structure_factor_squared`, +`measurement.normalized_intensity`, `measurement.angular_factor`, +`activearea.*` or `acceptance.*` outside the tests. That is the gap to close. + +## 3. Getting a green test run + +**A fresh checkout reports ~80 failures.** They are all the unbuilt native +extension, not the branch. With the extensions built the suite is +**1120 passed, 3 skipped, 0 failed** (the 3 skips are a missing `arviz`). +Building also *unlocks* roughly 230 tests that are not collected at all +without it, so the unbuilt number is not simply "the green ones". + +The environment on this machine is Python 3.14.7 (miniforge) with a working +orGUI already installed in `miniforge3\Lib\site-packages`. **Do not +`pip install -e .`** — it rebinds `import orgui` for that interpreter and the +repository owner wants the installed copy left alone. Build out of tree and +stage instead: + +```powershell +# build dir must be SHORT: a path under the session scratchpad overruns +# MAX_PATH and meson dies in check_clock_skew with a FileNotFoundError +$vc = 'C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat' +$mf = 'C:\Users\timof\miniforge3' +$pre = "$mf;$mf\Scripts;$mf\Library\bin" +cmd /v:on /c "`"$vc`" >nul 2>&1 && set PATH=$pre;!PATH! && meson setup C:\Users\timof\obuild --buildtype=release" +cmd /v:on /c "`"$vc`" >nul 2>&1 && set PATH=$pre;!PATH! && meson compile -C C:\Users\timof\obuild" +cmd /v:on /c "`"$vc`" >nul 2>&1 && set PATH=$pre;!PATH! && meson install -C C:\Users\timof\obuild --destdir C:\Users\timof\ostage" + +cd C:\Users\timof\ostage\Lib\site-packages +python -m pytest orgui/app/test orgui/datautils/xrayutils/test -q +``` + +Four traps, each of which cost an attempt: + +1. `cl` is not on PATH; `vcvars64.bat` is required. It prints a harmless + `'vswhere.exe' is not recognized` line and works anyway. +2. **`%PATH%` expands at parse time.** `vcvars && set PATH=;%PATH%` + silently discards everything vcvars added, and meson then reports + `Unknown compiler(s)`. Hence `cmd /v:on` and `!PATH!`. +3. meson and python are not on cmd's PATH even though they are on the bash + shell's. The interpreter is in the miniforge root, the entry points in + `Scripts`. +4. MAX_PATH, as above. + +Do **not** copy the built `.pyd` into `orgui/` to make the worktree importable. +`install_subdir('orgui', ...)` ships whatever is in the source tree, so a stray +extension is then packaged over every future build — a trap this repository has +already been bitten by once. + +`meson install --destdir` cannot write outside the staging tree, so the +installed copy is provably untouched; verify by checking that +`corrections/` is *absent* from `site-packages\orgui\datautils\xrayutils`, +not by comparing mtimes. + +## 4. Rules this work established + +Two of these came from the repository owner during the session and are now +also in the `AGENTS.md` files. They are the constraints a follow-up must keep. + +* **`datautils` holds self-consistent physics modules. No UI or UI state may + leak in.** No widget, no configuration object, and no *scan object* in + `orgui/datautils/`. This is why `corrections/normalization.py` takes + exposure and monitor *values* while `app/integration_corrections.py` keeps + the `scan`-attribute lookup, and why the `use_lorentz` / `use_footprint` + switches stayed on the app side. +* **One definition per factor.** Before this branch the mode→factor mapping + lived in four places, the active area in three, the exposure/monitor divisor + in three, and the per-pixel array in three verbatim copies. When adding a + correction, put the physics in `corrections/` and call it; do not compute a + factor inline in `orGUI.py` or `peak1Dintegr.py`. +* **Behaviour-preserving moves are verified against a failure-count baseline, + not by inspection.** The refactor was checked at 80 failed / 782 passed + before and after. + +## 5. The next commit, in detail + +Wiring the rocking path, plus the F6 solid-angle removal, which also touches +the stationary path. This **changes saved numbers in both modes**, so it is +`feat(phys)!` with a `BREAKING CHANGE:` footer per the repository's commit +convention. + +Steps 1-3 are `peak1Dintegr.RockingPeakIntegrator.integrate`; step 4 is that +plus `orGUI.integrateROI`: + +1. Build the exposure/monitor divisor the way the stationary path does, via + `integration_corrections.normalization_divisor(scan, ...)`. Use the + **per-frame** counting time, not the sum over the scan: the rocking angle is + the integration variable, the time is not. +2. Convert the trapezoid integral from degrees to radian — + `measurement.normalized_intensity(..., angle_unit="deg")` does both this and + step 1. +3. Divide by `acceptance.out_of_plane_acceptance(detector, row, column, + row_size, alpha, gamma_arm, delta_arm)`. The ROI centre and size per `s` + point are already in the database group `integration/`; the arm angles come + from `orgui.backend.scans.scan_arm_angles`. +4. Stop applying the solid-angle correction to ROI sums — **in both modes**, + so this touches `orGUI.integrateROI` as well. F6 is resolved this way (see + the physics doc): a ROI sum is already a complete angular integral, and + leaving the correction in means the modes still differ by `<1/Omega~>` — + 0.7 % at 1 m, 7 % at 0.3 m — because a rocking scan's solid-angle factor + cancels against its acceptance and a stationary scan's has nothing to + cancel against. In practice: drop the `solid_angle=` argument at + `orGUI.py:1595` and `:5765`, then the `useSolidAngleBox` widget, the + `solidAngle` key in `get_integration_options` and the `SOLA` badge. + `set_integration_options` ignores keys it has no branch for, so old + configuration files still load. The reconstruction path has its own switch + (`reconstruction_job.py:1254`) and must keep it. +5. Store the acceptance, the applied normalization and the active-area + assumption next to `F2_hkl`. Without them a saved rod cannot be put on a + common scale after the fact. + +Then `orgui/app/test/test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_differ_by_the_missing_normalizations` +**must flip** from a characterization test to a plain equality. Its docstring +says so. It currently asserts the gap *is* `T * monitor * Delta_gamma_deg`; if +it starts failing after a wiring change, that is the change working. + +**F6 is settled** (physics doc, F6): ROI-summed integration stops applying the +solid-angle correction, in both modes. Two things fell out of measuring it +that matter for step 3: `Delta_gamma` does **not** become unnecessary — it is a +size factor, the solid angle an obliquity factor, and they overlap only in the +obliquity — and a constant nominal `n * pixel / dist` is **not** a valid +"pixel-derived" acceptance, being wrong by `1/cos(theta)` (1.2 % at 9 degrees). +`out_of_plane_acceptance` is still what the rocking path divides by. + +## 6. After that + +* **Absolute scale (#15)** needs two user inputs that have no config field yet: + the incident flux density `Phi_0`, and the horizontal beam width for + `activearea.beam_limited_area`. The footprint dialog already asks for the + sample size and the beam profile. Everything else — `lambda`, `A_u`, `r_e` — + is available. +* **Reflectivity comes for free** once `|F|^2` is absolute; + `measurement.reflectivity_from_structure_factor` is the conversion. But fix + **F5** first: a reflectivity scan drives the detector arm, and the + polarization is still evaluated at the calibrated position, which is a 10 % + error at `2theta = 18` degrees and 33 % at 30. +* **`C_det` (F7)** is the only mechanism that can still break mode equivalence + after the wiring, and it cannot be validated on simulated data. It needs the + real-data overlap comparison. + +## 7. Predictions that turned out wrong + +Recorded because each cost time and none was obvious in advance. + +* **`active_area_footprint` should live next to the existing area code.** It + should not exist at all. `w * min(L, h/sin(alpha))` is exactly + `w * L * illuminated_area_fraction(alpha, L)` for a top-hat profile and only + for that profile — verified to `3.3e-16`. A Gaussian of the same width + departs by up to 19 %. The function was deleted, not moved. +* **"The refactor will remove ~100 lines from `orGUI.py`."** It is 27 added, + 10 removed — net *longer*, because the explanatory comment is worth more + than the ten lines saved. Real length reduction there means extracting the + `integrateROI` / `rocking_integrate` driver bodies, which is separate work. +* **"A moving detector arm changes `Delta_gamma`."** It does not. Driving the + gamma arm shifts the exit angle at the ROI centre by the full arm angle but + leaves the *span* identical to nine digits, because the rotation is about the + axis gamma is measured around. A 40-degree delta-arm rotation moves it by one + part in `1e5`. This is the opposite of the polarization factor, and it means + an acceptance computed without arm bookkeeping is still usable. +* **"The run-to-run test-count drift is flakiness."** It was the interpreter + changing under the session when Python 3.14 was installed mid-work. +* **`if C_arr is None: C_arr = np.ones(...)` in `orGUI.py` is dead code.** It + is load-bearing: the branch that rebuilds `C_arr` sits inside `if HAS_ACCEL:`, + so the NumPy-only path would be handed `None`. It is commented as such now. +* **pyFAI accepts mismatched coordinate array shapes.** It asserts + `pos2.size == size`, so a scalar column with a per-frame region height — what + the rocking path will pass — dies inside the extension. + `acceptance._surface_gamma` broadcasts before the call. + +## 8. Deliberately not done + +* **The reconstruction's correction pass was not unified.** It is fused into + `apply_correction_factors` with a bit-for-bit native/NumPy contract and + variance propagation. It shares the *definition* (`pixel_factors`) but keeps + its own streaming application; that was the right boundary. +* **F5 was not fixed while moving the code.** `pixel_factors` reproduces the + historical arm-blind behaviour exactly. Changing it is a numerical fix that + belongs in its own `phys` commit, not smuggled into a refactor. +* **`meson.build` was not changed.** Its `exclude_directories: ['__pycache__']` + only excludes the top-level directory, so 72 stale `cpython-312.pyc` files + are sitting in the installed copy. Inert under 3.14, and the repository owner + had previously declined a `meson.build` change for the related stale-`.pyd` + problem, so it was left alone and raised separately. + +## 9. Reproducing the key numbers + +```powershell +pytest orgui/datautils/xrayutils/test/test_corrections_measurement.py +pytest orgui/datautils/xrayutils/test/test_corrections_acceptance.py +pytest orgui/datautils/xrayutils/test/test_corrections_activearea.py +pytest orgui/datautils/xrayutils/test/test_corrections_package.py +pytest orgui/app/test/test_scan_mode_equivalence.py +``` + +These five do not need the native extension. Everything else in the suite may, +so use section 3 before concluding anything from a failure. diff --git a/doc/design/ctr_structure_factor_scale.md b/doc/design/ctr_structure_factor_scale.md index e6ab161..3b3cb7a 100644 --- a/doc/design/ctr_structure_factor_scale.md +++ b/doc/design/ctr_structure_factor_scale.md @@ -217,10 +217,77 @@ needed. orGUI multiplies the sum by the ROI-mean of `1/solidAngleArray` percent for a detector at 1 m, several percent for a close-in detector. The solid-angle array and an angle-derived `Delta_gamma` describe the same -geometry. Applying one without the other double-counts. When F3 is implemented, -decide the pair together: the clean choice is **raw ROI sum plus `Delta_gamma` -from `surfaceAnglesPoint` at the ROI edges**, with the solid-angle array -reserved for the per-pixel reconstruction path where it is genuinely required. +geometry. Applying one without the other double-counts, so the pair has to be +decided together with F3. + +**Resolved: ROI-summed integration does not apply a solid-angle correction at +all, in either mode.** The array stays where it is genuinely required, the +per-pixel reconstruction path, which forms a differential cross-section rather +than a sum over an aperture - and which has its own independent switch +(`reconstruction_job.py:1254`), so this does not touch it. + +Three steps to that conclusion: + +1. **A stationary ROI sum is a complete integral.** With the reflection inside + the region - open post-sample slits and a region sized from the projected + sample, orGUI's usual configuration - every photon is counted once, already + weighted by the solid angle of the pixel that caught it. There is nothing + to correct. +2. **Closing the slits would not make it the right correction either.** A + truncated integral is Vlieg's `C_det` (F7), an aperture-against-profile + model. A per-pixel obliquity weight does not repair a cut-off tail, so the + conclusion does not rest on the open-slit assumption holding. +3. **A rocking sum is a complete integral too**, over a `Delta_gamma` slice of + the rod. The slice is what `Delta_gamma` accounts for; there is still no + per-pixel weighting to undo. + +Leaving the correction in and compensating for it in the reduction was +considered and rejected. It gives the identical `|F|^2` - what is divided out +is exactly the scalar that was multiplied in - but it needs one more argument +on the reduction, and it puts the stored intensity column and `F2_hkl` on +different scales. What it cannot do is stay in *uncompensated*, because the +two modes do not carry it symmetrically: for a rocking scan the +omega-integrated counts in pixel row *j* go as that row's gamma height +`dgamma_j`, so a corrected sum pairs with `Sum_j dgamma_j / Omega~_j` and the +factor cancels against the acceptance, while a stationary sum has no +`Delta_gamma` to cancel against and the factor survives into the saved number. +The two modes would still disagree by `<1/Omega~>` - 0.7 % at 1 m, 7 % for a +detector at 0.3 m - after F1-F3 were fixed. + +Measured on a calibrated 172 um detector with a 60-row region, where +`Omega~ = solidAngleArray` is `cos^3` of the incidence angle on the detector +face to `1e-9`, and `nominal = n * pixel / dist`: + +| dist | theta | `Delta_gamma_edge`/nominal | exact/nominal | exact/`Delta_gamma_edge` | `1/` | +|---|---|---|---|---|---| +| 1.0 m | 2.7 deg | 0.99774 | 1.00112 | 1.003383 | 1.003383 | +| 0.3 m | 5.6 deg | 0.99039 | 1.00476 | 1.014512 | 1.014509 | +| 0.3 m | 9.0 deg | 0.97549 | 1.01236 | 1.037795 | 1.037788 | + +The last two columns agreeing to `5e-6` is the cancellation. In closed form +`Delta_gamma_edge = nominal cos^2(theta)` and `Omega~ = cos^3(theta)`, so the +partner of a corrected sum is `nominal / cos(theta)`. + +Two consequences that survive the decision, because they are what would have +gone wrong under the other one: + +* **`Delta_gamma` does not go away.** It is a *size* factor - how many rows of + rod the region accepted - while the solid angle is an *obliquity* factor. + They overlap only in the obliquity. `acceptance.out_of_plane_acceptance` is + still what the rocking path divides by. +* **A constant nominal `n * pixel / dist` is not the "pixel-derived" + acceptance.** It is wrong by `1/cos(theta)`: 0.1 % at 1 m, 1.2 % at 9 + degrees. `pixel_acceptance` times the row count is the same angle-derived + quantity as `out_of_plane_acceptance`, not a cheaper alternative to it. + +On the user-facing side, `useSolidAngleBox` (`QScanSelector.py:748`) drives +nothing else: its only consumers are the two direct-space integration paths +(`orGUI.py:1595` in `rocking_integrate`, `:5765` in `integrateROI`). Dropping +the widget, the `solidAngle` key from `get_integration_options` and the `SOLA` +badge is therefore the whole change. Old configuration files stay loadable +without a shim - `set_integration_options` is an `if`/`elif` chain over the +keys that are present, with no `else`, so a stored `solidAngle` entry is +ignored rather than an error. ### F7 - `C_det` is assumed to be 1 in both modes @@ -408,24 +475,31 @@ In order: time, not the sum over the scan: the rocking angle is the integration variable, the time is not. (F1) 2. **Integrate in radian**, or divide by `180/pi` at the end. (F2) -3. **Divide by `Delta_gamma`.** (F3) The input does not exist yet. The - estimator needs care and is the one piece of real design work left: - * The natural definition is the `gamma` span of the region of interest, - from `DetectorCalibration.surfaceAnglesPoint` at its edges, evaluated with - the per-frame arm angles from `scan_arm_angles`. - * A rectangular ROI in pixel coordinates is in general *rotated* with - respect to the `(gamma, delta)` axes. The corner-to-corner `gamma` span - then overestimates the acceptance at the `delta` of the rod. Decide - whether to take the span at fixed `delta` through the ROI centre or to - integrate the accepted `gamma` range over the in-plane profile. - * Settle F6 at the same time: raw ROI sum plus an angle-derived - `Delta_gamma`, or solid-angle-corrected sum plus a pixel-derived one, but - not a mix. -4. **Record the mode and its inputs in the saved data**, next to `F2_hkl`: the +3. **Divide by `Delta_gamma`.** (F3) The estimator now exists — section 4.4, + `corrections/acceptance.py` — so this is a wiring step rather than a design + one: + * `out_of_plane_acceptance` takes the span at the ROI centre column, which + is the resolution of the rotated-ROI question raised when this section was + written: the corner-to-corner span overestimates the acceptance at the + `delta` of the rod, so it is reported separately by `gamma_range` as a + diagnostic rather than used. + * Pass the per-frame arm angles from `scan_arm_angles` where they are known. + They turn out to matter far less than expected (section 4.4), but they + cost nothing. + * Stop applying the solid-angle correction first (step 4): `Delta_gamma` + and the solid-angle correction each carry the same obliquity, and + leaving both in double-counts it. +4. **Stop applying the solid-angle correction to ROI sums, in both modes.** + (F6) A ROI sum is already a complete angular integral. This also touches + `orGUI.integrateROI`, and it changes stationary saved numbers by + `<1/Omega~>` - 0.7 % at 1 m, 7 % at 0.3 m - so it belongs in the same + breaking commit as the rocking wiring, not a later one. The reconstruction + path keeps its own solid-angle switch and is unaffected. +5. **Record the mode and its inputs in the saved data**, next to `F2_hkl`: the acceptance, the normalization that was applied, and the active-area assumption of F4. Without them a saved rod cannot be put on a common scale after the fact. -5. **Then verify on real data.** The overlap region of a rocking scan and a +6. **Then verify on real data.** The overlap region of a rocking scan and a stationary scan on the same rod (Drnec Fig. 8, right) is the acceptance test. Simulation cannot catch F7, an incorrect `Delta_gamma` definition, or a beamline that reports counting time in the wrong place. @@ -441,11 +515,13 @@ constants: `Lattice.uc_area`, `measurement.CLASSICAL_ELECTRON_RADIUS`). * `Phi_0` and `T` - user input. `T` is in the scan; `Phi_0` needs a flux measurement and a field to put it in. -* `A` - `active_area_footprint` needs the beam width, beam height and sample - length. The footprint dialog already asks for the sample size and the beam - profile; it does not ask for the horizontal beam width, and `C_illum_area` is - a fraction rather than an area (F4). Multiplying the fraction by - `w * min(L, h/sin(alpha))` gives the absolute area. +* `A` - `activearea.beam_limited_area(alpha, beam_width, sample_length, + profile)` returns it in square meter, as `w * L * C_illum_area(alpha, L)` + (section 4.3; `C_illum_area` alone is a fraction, not an area - F4). Of its + inputs only the **horizontal beam width** is missing: the footprint dialog + already asks for the sample size and the beam profile, and the profile + carries the vertical direction. `activearea.slit_limited_area` covers the + narrow-slit case instead. * `C_det` - F7, unmodelled. **Reflectivity comes for free.** @@ -490,8 +566,18 @@ stationary one is what `reflectivity_from_structure_factor` reduces. ## 7. Open questions -* **`Delta_gamma` for a rotated ROI** (section 5.3). The single piece of - physics not settled here. +Nothing here now blocks the wiring. The two that used to head this list are +settled: `Delta_gamma` for a rotated ROI in section 4.4 (the centre-column +span, with the corner-to-corner span kept as a diagnostic), and the F6 +solid-angle double count in F6 itself (ROI-summed integration stops applying +it, in both modes). + +* **The stored intensity column changes too, not only `F2_hkl`.** Dropping + the solid-angle correction moves it by `<1/Omega~>`, so external + post-processing scripts reading the intensity column - not just those + reading `F2_hkl` - see the break. It is the same factor either way; there + is no variant of F6 that leaves the intensity column alone and still puts + the modes on one scale. * **The reciprocal-space reconstruction as a third route.** Drnec section 4 shows that voxel binning absorbs the Lorentz factor, so a reconstructed map needs `C_area`, `C_beam`, `P` and `Delta_l` but no Lorentz factor - which is @@ -500,7 +586,7 @@ stationary one is what `reflectivity_from_structure_factor` reduces. piece of work and was not analysed here. * **`C_det` (F7)** is the only remaining mechanism that can break the mode equivalence after F1-F3, and it is the one that cannot be validated on - simulated data. It needs the real-data comparison of section 5.5. + simulated data. It needs the real-data comparison of section 5, step 6. * **Error propagation through the new factors.** `measurement` reduces intensities; the errors follow the same divisors, but a `Delta_gamma` estimated from the geometry has an uncertainty of its own that nothing @@ -510,9 +596,22 @@ stationary one is what `reflectivity_from_structure_factor` reduces. ```powershell pytest orgui/datautils/xrayutils/test/test_corrections_measurement.py +pytest orgui/datautils/xrayutils/test/test_corrections_acceptance.py pytest orgui/app/test/test_scan_mode_equivalence.py ``` -Note that `orgui/app/test/test_roi_sum_accel.py` fails in a checkout where the -native ROI extension has not been built ("ROI acceleration is disabled"); that -is unrelated to anything here. +The F6 cancellation and the table in it are +`test_corrections_acceptance.py::test_the_solid_angle_correction_and_the_acceptance_carry_one_obliquity`. + +None of these needs the native extension. Much of the rest of the suite does, +and a checkout without it reports around 80 failures that have nothing to do +with this work — `ctr_structure_factor_handover.md` section 3 has the +out-of-tree build recipe that turns those into a clean +1120 passed / 3 skipped run. + +## 9. Where the implementation stands + +This document is the physics. For the state of the branch — what is wired and +what is not, the next commit in detail, the build recipe, and the predictions +made here that turned out wrong — see +[`ctr_structure_factor_handover.md`](ctr_structure_factor_handover.md). diff --git a/doc/physics/.gitignore b/doc/physics/.gitignore new file mode 100644 index 0000000..2237298 --- /dev/null +++ b/doc/physics/.gitignore @@ -0,0 +1,10 @@ +# LaTeX build artifacts. Drop the *.pdf line if the built document should be +# shipped in the repository for readers without a TeX installation. +*.aux +*.log +*.out +*.toc +*.synctex.gz +*.fls +*.fdb_latexmk +*.pdf diff --git a/doc/physics/ctr_structure_factor_physics.tex b/doc/physics/ctr_structure_factor_physics.tex new file mode 100644 index 0000000..d615f22 --- /dev/null +++ b/doc/physics/ctr_structure_factor_physics.tex @@ -0,0 +1,745 @@ +% Compile with: lualatex ctr_structure_factor_physics.tex (twice, for refs) +\documentclass[11pt,a4paper]{article} + +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage[margin=27mm]{geometry} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{booktabs} +\usepackage{array} +\usepackage{enumitem} +\usepackage{xcolor} +\usepackage[colorlinks=true,linkcolor=blue!45!black,urlcolor=blue!45!black, + citecolor=blue!45!black]{hyperref} + +\newcommand{\Fhkl}{\left|F_{hkl}\right|^{2}} +\newcommand{\dgam}{\Delta\gamma} +\newcommand{\dOm}{\Delta\Omega} +\newcommand{\Ored}{\widetilde{\Omega}} +\newcommand{\Crod}{C_{\mathrm{rod}}} +\newcommand{\Cdet}{C_{\mathrm{det}}} +\newcommand{\Carea}{C_{\mathrm{area}}} +\newcommand{\Cbeam}{C_{\mathrm{beam}}} +\newcommand{\Cillum}{C_{\mathrm{illum}}} +\newcommand{\bin}{\beta_{\mathrm{in}}} +\newcommand{\bout}{\beta_{\mathrm{out}}} +\newcommand{\re}{r_{e}} +\newcommand{\Au}{A_{u}} +% Long dotted module paths are unbreakable in \texttt; let them break after +% a dot, and give TeX a little slack rather than an overfull line. +\newcommand{\code}[1]{\texttt{\small #1}} +\newcommand{\dt}{.\allowbreak} +\setlength{\emergencystretch}{3em} + +\newcommand{\unit}[1]{\;\mathrm{#1}} +\newcommand{\Ang}{\text{\AA}} + +\title{Structure factors from area-detector surface diffraction data\\ + \large The normalizations and integration intervals used by orGUI} +\author{orGUI --- \code{orgui\dt datautils\dt xrayutils\dt corrections}} +\date{\today} + +\begin{document} +\maketitle + +\begin{abstract} +\noindent +This document states, in one place, how orGUI turns detector counts into +$\Fhkl$: every normalization that is divided out, every integration interval +that is summed or integrated over, and the unit each quantity carries at the +boundary where it changes. It covers the two direct-space measurement modes +--- rocking scans and stationary area-detector scans --- and the conversion to +and from specular reflectivity. The reciprocal-space reconstruction is a third +route with a different set of factors and is only contrasted here, not +derived. + +The physics is that of E.~Vlieg, \emph{J.~Appl.~Cryst.} \textbf{30} (1997) 532, +in the two-dimensional-detector form of J.~Drnec \emph{et al.}, +\emph{J.~Appl.~Cryst.} \textbf{47} (2014) 365. What this document adds is the +mapping from those expressions onto what the code actually sums, and the +resolution of two couplings that the published expressions do not address +because they do not arise with a point detector: the interaction between the +detector solid-angle correction and the out-of-plane acceptance, and the +choice of integration variable and its interval. +\end{abstract} + +\tableofcontents + +\section{Conventions} + +\subsection{Geometry and angles} + +orGUI works in the \textbf{z-axis geometry}, with angles as defined in +\code{orgui\dt datautils\dt xrayutils\dt HKLVlieg}. It is essential to +separate the \emph{motor positions} of the diffractometer from the +\emph{scattering angles} of an individual detector pixel, because only the +latter enter any correction factor: + +\begin{center} +\begin{tabular}{@{}llll@{}} +\toprule +Symbol & Meaning & Kind & Varies with \\ +\midrule +$\alpha$ & incidence angle on the surface & circle (\code{mu}) + & frame \\ +$\omega$ & sample rotation about the surface normal & circle (\code{th}) + & frame \\ +$\gamma_{\mathrm{arm}}$, $\delta_{\mathrm{arm}}$ + & detector arm position & circles & frame \\ +\addlinespace +$\gamma_p$, $\delta_p$ & laboratory-frame angles of a pixel & derived + & pixel, arm \\ +$\gamma$, $\delta$ & \textbf{surface-frame} angles of a pixel & derived + & pixel, arm, $\alpha$ \\ +\bottomrule +\end{tabular} +\end{center} + +\noindent +$\gamma$ and $\delta$ are \textbf{not} diffractometer circles and are +\textbf{not} the arm angles. They are per-pixel quantities, computed from +where a pixel sits \emph{after} the detector has been rotated to the arm +position of that frame. Within one frame they vary across the detector face; +between frames they change both because the arm moved and because $\alpha$ +changed. In this geometry $\gamma$ is the exit angle of the diffracted beam +from the surface, so $\bout = \gamma$ and $\bin = \alpha$. + +All angles are in \textbf{radian} everywhere inside +\code{orgui\dt datautils\dt xrayutils\dt corrections}; degrees appear only at +the user interface and in raw motor positions read from a scan. + +\subsection{From a pixel to its scattering angles} +\label{sec:pixelangles} + +The conversion runs in three steps, all in +\code{DetectorCalibration\dt Detector2D\_SXRD}. + +\paragraph{1. The arm position becomes a detector geometry.} The arm angles +are given as the true laboratory-frame angles +$(\gamma_{\mathrm{arm}}, \delta_{\mathrm{arm}})$ the arm reference direction +points at. The rotation from the \emph{calibrated} arm position to the +requested one, +\begin{equation} + R_{\mathrm{rel}} = R_{\mathrm{arm}}(\gamma_{\mathrm{arm}}, + \delta_{\mathrm{arm}})\, + R_{\mathrm{arm}}^{\mathrm{ref}\,\top} , +\end{equation} +is folded into the pyFAI \emph{home} geometry +$[\,d, \mathrm{poni}_1, \mathrm{poni}_2, \mathrm{rot}_1, \mathrm{rot}_2, +\mathrm{rot}_3\,]$ by \code{paramAtArm}. A moving arm is therefore represented +as a \emph{modified pyFAI geometry}, not as an angular offset added after the +fact --- which is what makes the parallax and obliquity of the following step +come out right at every arm position. + +Two conventions here are easy to get wrong. Passing \code{None} for both arm +angles means \emph{at the calibrated arm position}, which is not the same as +passing $0.0$ unless the calibration was taken with the motors reading zero. +And exactly one of the two is rejected rather than defaulted, because the +calibration reference is a rotation and not a pair of independent offsets. + +\paragraph{2. Pixel to laboratory frame.} On that geometry, the pixel position +gives the scattering angle $2\theta$ and the azimuth $\chi$ --- with parallax +applied, and $\chi$ offset by the azimuthal reference --- and from those the +laboratory-frame angles $(\gamma_p, \delta_p)$ of the pixel +(\code{primBeamPoints}). + +\paragraph{3. Laboratory to surface frame.} Tilting into the surface frame of +a sample at incidence angle $\alpha$ (\code{surfaceAnglesPoint}): +\begin{align} + \gamma &= \arcsin\bigl(\cos\alpha \, \sin\gamma_p + - \sin\alpha \, \cos\delta_p \, \cos\gamma_p \bigr) , + \label{eq:gamma}\\[2pt] + \delta &= \arcsin\!\left( + \frac{\sin\delta_p \, \cos\gamma_p}{\cos\gamma} \right) . + \label{eq:delta} +\end{align} +At $\alpha = 0$ these collapse to $\gamma = \gamma_p$ and +$\delta = \delta_p$, as they must: at zero incidence the surface frame is the +laboratory frame. + +\paragraph{Which angles the corrections use.} Every factor in this document +that names $\gamma$ or $\delta$ means the surface-frame angles of +Eqs.~\eqref{eq:gamma} and~\eqref{eq:delta}, evaluated \textbf{at the +reflection position} --- the centre of the region of interest --- for the arm +position and $\alpha$ of that frame. The integration paths read them from the +stored per-reflection angles, not from arm motors. Two places in this document +depend on the distinction directly: + +\begin{itemize}[leftmargin=1.4em] +\item $\dgam$ (Section~\ref{sec:acceptance}) is a \emph{difference} of + Eq.~\eqref{eq:gamma} between two pixel rows, which is only meaningful + because $\gamma$ varies across the detector face within one frame. +\item The polarization factor (Section~\ref{sec:notmodelled}) is the one + correction still evaluated on the \emph{home} geometry rather than at the + frame's arm position, which is why it is wrong for a scan that drives the + arm. +\end{itemize} + +\subsection{Units at the boundaries} + +Units are deliberately mixed, following the repository convention that +crystallographic quantities are in \AA ngstrom and instrument quantities in +meter. Getting this wrong is a factor of $10^{20}$ in the scale factor, so it +is stated at every function boundary and pinned by +\code{test\_scale\_factor\_carries\_its\_documented\_units}. + +\begin{center} +\begin{tabular}{@{}lll@{}} +\toprule +Quantity & Symbol & Unit \\ +\midrule +Wavelength & $\lambda$ & \AA ngstrom \\ +Surface unit-cell area & $\Au$ & \AA ngstrom squared \\ +Classical electron radius & $\re$ & meter ($2.8179403262\times10^{-15}$) \\ +Active illuminated area & $A$ & square meter \\ +Incident flux density & $\Phi_0$ & photons per second and square meter \\ +Counting time & $T$ & second \\ +All angles & & radian \\ +Structure factor squared & $\Fhkl$ & electron units squared \\ +\bottomrule +\end{tabular} +\end{center} + +\noindent +$\Phi_0$ is a flux \emph{density}: $\Phi_0 A_0$ is the total flux on the +sample, with $A_0$ the beam cross-section. + +\section{What one detector frame contains} +\label{sec:frame} + +A pixel $i$ of one frame of counting time $T$ accumulates +\begin{equation} + N_i = \Phi_0\, T \left(\frac{d\sigma}{d\Omega}\right)_{\!i} \dOm_i , + \label{eq:pixel} +\end{equation} +where $\dOm_i$ is the solid angle that pixel subtends at the sample. For a +flat detector at distance $d$ with square pixels of size $p$, seen at an +incidence angle $\theta$ on the detector face, +\begin{equation} + \dOm = \frac{p^{2}}{d^{2}}\cos^{3}\theta , + \qquad + \delta\gamma = \frac{p}{d}\cos^{2}\theta , + \qquad + \delta\psi = \frac{p}{d}\cos\theta , + \label{eq:pixelgeom} +\end{equation} +with $\delta\gamma$ the angular height of the pixel in the direction it is +tilted away from the normal and $\delta\psi$ the angular width perpendicular +to it; their product is $\dOm$ as it must be. pyFAI's +\code{solidAngleArray} returns the \emph{normalized} solid angle +\begin{equation} + \Ored = \dOm \Big/ \frac{p^{2}}{d^{2}} = \cos^{3}\theta , +\end{equation} +which equals $1$ at the point of normal incidence and which orGUI verifies +against $\cos^{3}\theta$ to $10^{-9}$ in +\code{test\_corrections\_acceptance.py}. + +Equation~\eqref{eq:pixel} is the origin of the central result of +Section~\ref{sec:solidangle}: a \emph{sum} of $N_i$ over a region already +carries each pixel's own $\dOm_i$, and is therefore already an angular +integral. Only a quantity that is meant to be a differential cross-section at +a point needs $\dOm_i$ divided out. + +\section{Integration intervals} +\label{sec:intervals} + +This section fixes exactly which counts enter the integrated intensity, for +each mode. Everything here is a statement about \emph{domains}; the divisors +follow in Section~\ref{sec:normalizations}. + +\subsection{The region of interest, and its background} + +Both direct-space modes reduce a frame to a single number by summing a +rectangular signal region and subtracting a background estimated from +separate background regions. + +\paragraph{Stationary mode} (\code{orGUI.integrateROI}). With $S$ the signal +region, $B$ the union of the background regions, $n_S$ and $n_B$ the numbers +of \emph{valid} (unmasked, present) pixels in each, and $n_S^{\mathrm{nom}}$ +the nominal region size in pixels, +\begin{equation} + N \;=\; \Biggl(\underbrace{\sum_{i\in S} N_i}_{\text{signal sum}} + \;-\; \frac{n_S}{n_B}\underbrace{\sum_{i\in B} N_i}_{\text{background sum}} + \Biggr)\, + \frac{n_S^{\mathrm{nom}}}{n_S} . + \label{eq:stationary_roi} +\end{equation} +The factor $n_S/n_B$ puts the background on a per-pixel basis and scales it to +the signal region. The trailing $n_S^{\mathrm{nom}}/n_S$ rescales from the +pixels that were actually valid to the nominal region area, so that masked +pixels and detector gaps do not reduce the integrated intensity. + +That last rescaling is the reason the nominal region area must \emph{not} +appear again in any correction factor: the ROI-mean bookkeeping of +\code{corrections.roi.roi\_mean\_correction} divides the summed correction by +the valid-pixel count and never by the nominal area. Multiplying by the area a +second time would scale every intensity by the size of its own region, and +because orGUI resizes regions with detector position +(\code{ROIutils.calc\_corrections}), two measurements of one rod taken on +different parts of the detector would be scaled apart. + +\paragraph{Rocking mode} (\code{peak1Dintegr}). The same per-frame reduction is +applied first, giving a curve $N(\omega_k)$ over the frames of the scan, and +that curve is then integrated over the rocking angle. + +\subsection{The rocking integral} +\label{sec:rockinginterval} + +For each output point the integration interval is a closed interval +$[\omega_{\mathrm{from}}, \omega_{\mathrm{to}}]$, snapped to the nearest +sampled motor positions and ordered so that the result is positive: +\begin{equation} + \mathcal{I} \;=\; \int_{\omega_{\mathrm{from}}}^{\omega_{\mathrm{to}}} + N(\omega)\, d\omega + \;\longrightarrow\; + \sum_{k=k_{\mathrm{from}}}^{k_{\mathrm{to}}-1} + \tfrac{1}{2}\bigl(N_k + N_{k+1}\bigr)\,(\omega_{k+1}-\omega_k) , +\end{equation} +a trapezoidal rule over a slice that \textbf{includes both endpoints} +(\code{slice(idx\_from, idx\_to + 1)}), so that the integrated samples, the +error weights and the reported interval width all describe the same domain. +The interval width recorded alongside the result is +$\Delta\omega = |\omega_{k_{\mathrm{to}}} - \omega_{k_{\mathrm{from}}}|$. + +Background regions are integrated over their own intervals and then scaled to +the signal interval by the ratio of interval widths, +$\Delta\omega_S/\sum_{B}\Delta\omega_B$, before being subtracted. The result +is deliberately \emph{not} divided by $\Delta\omega_S$: it is an integral, not +a mean, because that is what Vlieg's expression requires. + +\paragraph{The integration variable must be in radian.} The published +expressions integrate $d\omega$ in radian. The motor axis is in degrees, so +the integral as computed is larger by $180/\pi = 57.3$. This is a constant, so +it is invisible within one data set and only appears when a rocking scan is +compared with a stationary scan, or with an absolute scale. + +\subsection{The out-of-plane acceptance $\dgam$} +\label{sec:acceptance} + +A rocking scan does not measure a whole rod. It intercepts a slice whose +length is (Vlieg eq.~20) +\begin{equation} + \Delta l = \Crod \, \frac{V_u}{\lambda \Au}\, \dgam , +\end{equation} +so its integrated intensity is proportional to $\dgam$, the out-of-plane +angular acceptance of the region of interest. A stationary measurement +intercepts the whole rod cross-section and carries no such factor. + +With a point detector $\dgam$ was a slit setting: fixed for an experiment and +absorbed into an overall scale factor. On an area detector it is a property of +the region, and orGUI sizes regions per detector position, so it varies +\emph{along a scan} and changes the shape of a rod rather than only its scale. + +\code{corrections.acceptance} evaluates it as the exit-angle span between the +top and bottom \textbf{edges} of the region, at its centre column: +\begin{equation} + \dgam = \Bigl|\, + \gamma\bigl(r + \tfrac{n}{2},\, c\bigr) - + \gamma\bigl(r - \tfrac{n}{2},\, c\bigr)\Bigr| , + \label{eq:dgamma} +\end{equation} +for a region of $n$ rows centred on pixel $(r,c)$, with $\gamma(\cdot)$ the +surface-frame exit angle of Eq.~\eqref{eq:gamma} evaluated at that pixel and +at the frame's arm position and $\alpha$. Two choices in +Eq.~\eqref{eq:dgamma} are deliberate: + +\begin{itemize}[leftmargin=1.4em] +\item \textbf{Edges, not pixel centres.} A region of $n$ rows accepts $n$ + pixels' worth of angle, not $n-1$. Measuring between the centres of the + first and last row understates every acceptance by one pixel: $1\,\%$ on a + 100-row region, $10\,\%$ on a 10-row one. +\item \textbf{The centre column.} The region is centred on the rod, and it is + the range of $\gamma$ over which \emph{the rod} crosses the aperture that + sets the intercepted length. The corner-to-corner span over the whole + rectangle is computed separately by \code{gamma\_range}; the two agree when + lines of constant $\gamma$ run along the detector rows and separate on a + detector rolled about the beam, so their ratio is a cheap diagnostic for + whether the estimate can be trusted. +\end{itemize} + +\paragraph{The detector arm barely matters --- for the span.} Driving the +$\gamma$ arm shifts $\gamma$ at the region centre by the full arm angle, so +the arm position matters a great deal for \emph{where on the rod} the region +sits. It leaves the \emph{span} of Eq.~\eqref{eq:dgamma} unchanged to nine +digits, because that rotation is about the very axis $\gamma$ is measured +around; a $40^{\circ}$ $\delta$-arm rotation moves it by one part in $10^{5}$. +So how much rod is accepted is nearly independent of the arm even though which +part is accepted is not, and an acceptance computed without arm bookkeeping +remains usable. Pass the arm angles anyway where they are known --- they cost +nothing. This is the opposite of the polarization factor +(Section~\ref{sec:notmodelled}), where ignoring the arm is a $10\,\%$ error at +a scattering angle of $18^{\circ}$. + +\section{Normalizations} +\label{sec:normalizations} + +\subsection{Counting time and monitor} + +Both published expressions carry $\Phi_0 T$, so neither an integrated rocking +curve nor a stationary frame means anything until it is divided by its +counting time and by whatever counter the incident flux is tracked with: +\begin{equation} + I = \frac{\mathcal{N}}{T \prod_j M_j} , +\end{equation} +with $\mathcal{N}$ the background-subtracted integrated counts of +Section~\ref{sec:intervals} and $M_j$ the monitor counters +(\code{corrections.normalization.normalization\_divisor}). + +For a rocking scan this is the \textbf{per-frame} counting time, not the sum +over the scan. The rocking angle is the integration variable; the time is not. +Using the summed time would divide by the number of frames a second time. + +Beyond mode equivalence, this matters \emph{within} one data set: rocking +scans taken at different $l$ with different counting times, or with a drifting +ring current, are otherwise placed on different scales with nothing in the +saved data recording it. + +\subsection{Polarization} + +The polarization factor $P$ is a per-pixel property of the scattering angle. +It is divided out as the mean of $1/P$ over the region +(\code{roi\_mean\_correction}) before the reduction; the master equation of +Section~\ref{sec:master} therefore takes an intensity from which $P$ has +already been removed. + +\subsection{Solid angle: why a summed region takes none} +\label{sec:solidangle} + +This is the one normalization whose \emph{absence} needs an argument, because +the array is available and applying it looks harmless. + +\paragraph{The statement.} A region-summed intensity gets no solid-angle +correction, in either direct-space mode. The array belongs only to the +per-pixel reciprocal-space reconstruction, which forms a differential +cross-section rather than a sum over an aperture. + +\paragraph{Why.} From Eq.~\eqref{eq:pixel}, the raw sum over a region is +\begin{equation} + \sum_{i\in S} N_i + = \Phi_0 T \sum_{i \in S} \left(\frac{d\sigma}{d\Omega}\right)_{\!i} \dOm_i + \;\simeq\; \Phi_0 T \iint_{S} \frac{d\sigma}{d\Omega}\, d\gamma\, d\psi , +\end{equation} +already the complete angular integral over the aperture. Every photon is +counted once, weighted by the solid angle of the pixel that caught it. There +is nothing to correct. Three remarks make this robust: + +\begin{enumerate}[leftmargin=1.6em] +\item If the reflection were \emph{not} fully contained --- post-sample slits + narrower than the peak --- the missing part is Vlieg's $\Cdet$, an + aperture-against-profile model. A per-pixel obliquity weight does not repair + a truncated tail, so the conclusion does not depend on the open-slit + assumption holding. +\item A rocking sum is a complete integral too, over a $\dgam$ slice of the + rod. The slice is what $\dgam$ accounts for; there is still no per-pixel + weighting to undo. +\item Where a differential cross-section \emph{is} the goal --- binning + individual pixels into reciprocal-space voxels --- the correction is + required, and the reconstruction path applies it under its own switch. +\end{enumerate} + +\paragraph{What goes wrong if it is applied anyway.} It does not merely add a +constant. For a rocking scan the $\omega$-integrated counts in pixel row $j$ +go as that row's angular height $\delta\gamma_j$, so +\begin{equation} + \text{raw sum} \;\longleftrightarrow\; \sum_j \delta\gamma_j = \dgam , + \qquad + \text{corrected sum} \;\longleftrightarrow\; + \sum_j \frac{\delta\gamma_j}{\Ored_j} \simeq \frac{\dgam}{\langle\Ored\rangle} . + \label{eq:partners} +\end{equation} +The solid-angle factor cancels against the acceptance in the rocking mode --- +which is exactly why leaving it in \emph{looks} harmless there. A stationary +sum has no $\dgam$ for it to cancel against, so the factor survives into the +saved number and the two modes disagree by $\langle 1/\Ored\rangle$: $0.7\,\%$ +for a detector at $1$~m and $7\,\%$ at $0.3$~m. + +Table~\ref{tab:f6} measures Eq.~\eqref{eq:partners} on a calibrated +$172\,\mu$m detector with a 60-row region. The last two columns +agreeing to $5\times10^{-6}$ is the cancellation. + +\begin{table}[htbp] +\centering +\begin{tabular}{@{}llrrrr@{}} +\toprule +$d$ & $\theta$ & $\dgam/\dgam_{\mathrm{nom}}$ & exact$/\dgam_{\mathrm{nom}}$ + & exact$/\dgam$ & $1/\langle\Ored\rangle$ \\ +\midrule +$1.0$~m & $2.7^{\circ}$ & 0.99774 & 1.00112 & 1.003383 & 1.003383 \\ +$0.3$~m & $5.6^{\circ}$ & 0.99039 & 1.00476 & 1.014512 & 1.014509 \\ +$0.3$~m & $9.0^{\circ}$ & 0.97549 & 1.01236 & 1.037795 & 1.037788 \\ +\bottomrule +\end{tabular} +\caption{The two partners of Eq.~\eqref{eq:partners}, against the nominal +$\dgam_{\mathrm{nom}} = n\,p/d$. Here the region is displaced from the point +of normal incidence along its rows, so by Eq.~\eqref{eq:pixelgeom} +$\dgam = \dgam_{\mathrm{nom}}\cos^{2}\theta$ against $\Ored = \cos^{3}\theta$ +and the corrected-sum partner is $\dgam_{\mathrm{nom}}/\cos\theta$. That +decomposition depends on the orientation of the region relative to the normal; +the ratio in the last two columns does not, since it follows from +Eq.~\eqref{eq:partners} alone. Pinned by +\code{test\_the\_solid\_angle\_correction\_and\_the\_acceptance\_carry\_one\_obliquity}.} +\label{tab:f6} +\end{table} + +\paragraph{Two traps this leaves behind.} +\begin{itemize}[leftmargin=1.4em] +\item \textbf{$\dgam$ does not become unnecessary.} It is a \emph{size} factor + --- how many rows of rod the region accepted --- while the solid angle is an + \emph{obliquity} factor. They overlap only in the obliquity, a fraction of a + percent. +\item \textbf{A constant nominal $n\,p/d$ is not a valid substitute for + Eq.~\eqref{eq:dgamma}.} It is wrong by $1/\cos\theta$: $0.1\,\%$ at $1$~m, + $1.2\,\%$ at $\theta = 9^{\circ}$. The single-pixel acceptance times the row + count is the same angle-derived quantity as Eq.~\eqref{eq:dgamma}, not a + cheaper alternative to it. +\end{itemize} + +\subsection{The active area and the beam footprint} +\label{sec:area} + +$A$ is the surface area that actually contributes counts. Vlieg writes it as +$A = A_0\,\Carea\,\Cbeam$ (his eq.~41). Which of two limits applies is an +experimental question, and orGUI builds both: + +\paragraph{Beam-limited (open slits, orGUI's usual case).} +\begin{equation} + A(\alpha) = w\, L\, \Cillum(\alpha, L) , + \label{eq:beamarea} +\end{equation} +with $w$ the horizontal beam width, $L$ the sample length along the beam, and +$\Cillum$ the profile integrated over the projected sample and normalized to a +uniform beam, +\begin{equation} + \Cillum(\alpha,L) + = \frac{1}{p_{\max}\, h_{\mathrm{proj}}} + \int_{-h_{\mathrm{proj}}/2}^{+h_{\mathrm{proj}}/2} p(z)\, dz , + \qquad h_{\mathrm{proj}} = L\sin\alpha . +\end{equation} +Note that $\Cillum$ is a dimensionless \emph{fraction}, not an area; the +horizontal direction is the plain beam width, since it is not projected. For a +top-hat profile of full height $h$, Eq.~\eqref{eq:beamarea} reduces exactly to +the familiar closed form +\begin{equation} + A = w \min\!\left(L,\; \frac{h}{\sin\alpha}\right) , +\end{equation} +verified to $3.3\times10^{-16}$. It does \emph{not} reduce to it for any other +profile --- a Gaussian of the same width departs by up to $19\,\%$ --- which +is why the closed form is not used. + +\paragraph{Slit-limited.} When post-sample slits cut the footprint, +$\Carea = 1/(\sin\delta\,\cos(\alpha - \bin))$ applies instead (Vlieg eqs.~37 +and~38). Because $\delta$ varies along a rod, this is a \emph{shape} effect, +not only a scale: on a simulated Pt(111) $(1,0,l)$ rod, $\delta$ moves from +$16.77^{\circ}$ to $16.95^{\circ}$ between $l = 0.4$ and $l = 3.0$, a +$1.1\,\%$ error, and much larger between rods at different in-plane momentum +transfer. + +\paragraph{It cannot explain a mode disagreement.} $A$ enters the rocking and +the stationary expressions identically and cancels from their ratio. It sets +the absolute scale and nothing else. + +\section{The master equation} +\label{sec:master} + +With $I$ the normalized integrated intensity of +Section~\ref{sec:normalizations} --- counts per second per monitor unit, with +$P$ already divided out --- +\begin{equation} + \boxed{\; + I \;=\; \Phi_0\, \frac{\re^{2} A \lambda^{2}}{\Au^{2}}\; + \Fhkl \; \eta \; \Cdet \; } + \label{eq:master} +\end{equation} +and the mode enters only through the angular factor $\eta$: + +\begin{center} +\begin{tabular}{@{}lll@{}} +\toprule +Mode & $\eta$ & Lorentz factor \\ +\midrule +Rocking scan (\code{th}/$\omega$) & $L_\varphi\,\Crod\,\dgam$ + & $L_\varphi = 1/(\sin\delta\,\cos\alpha\,\cos\gamma)$ \\ +Reflectivity rocking (\code{mu}) & $L_r\,\Crod\,\dgam$ + & $L_r = 1/\sin 2\alpha$ \\ +Stationary area detector & $L_s$ + & $L_s = 1/\sin\gamma$ \\ +Specular reflectivity & $L_s$ at $\gamma = \alpha$ + & $L_s = 1/\sin\alpha$ \\ +\bottomrule +\end{tabular} +\end{center} + +\noindent +with the rod interception $\Crod = \cos\gamma$. The three Lorentz factors are +\textbf{alternatives selected by how the intensity was measured}, not factors +to be combined. Two structural points: + +\begin{itemize}[leftmargin=1.4em] +\item The rocking modes carry $\Crod$ and $\dgam$; the stationary mode carries + neither. A stationary measurement integrates across the whole rod + cross-section, so there is no interception factor and no acceptance --- + \code{angular\_factor} \emph{rejects} an acceptance passed for a stationary + mode, rather than ignoring it. +\item The area correction $1/\sin\delta$ listed in the ANA/ROD z-axis table is + \emph{not} applied; the numerically evaluated footprint of + Section~\ref{sec:area} is used instead, which is the row the manual marks as + calculated numerically. +\end{itemize} + +\subsection{Dimensional check} + +The radian in $\dgam$ is what makes the two modes dimensionally distinct, and +it is worth checking that it lands correctly. Writing $[\cdot]$ for the unit +of a quantity and taking a unit monitor, +\begin{align} + \text{prefactor:}&\quad + \frac{1}{\mathrm{s}\,\mathrm{m}^{2}} \cdot \mathrm{m}^{2} + \cdot \mathrm{m}^{2} \cdot \frac{\mathrm{m}^{2}}{\mathrm{m}^{4}} + = \frac{1}{\mathrm{s}} , \\ + \text{stationary:}&\quad + [I] = \frac{\text{counts}}{\mathrm{s}} , + \qquad \eta \text{ dimensionless} \quad\checkmark \\ + \text{rocking:}&\quad + [I] = \frac{\text{counts}\cdot\mathrm{rad}}{\mathrm{s}} , + \qquad [\eta] = \mathrm{rad} \quad\checkmark +\end{align} +The rocking intensity carries one radian more than the stationary one, and +$\dgam$ is what absorbs it. Integrating in degrees and omitting $\dgam$ are +therefore not two independent mistakes but a single unbalanced unit, which is +why they were measured together as one ratio. + +\subsection{Inverting to a structure factor} + +\begin{equation} + \Fhkl = \frac{I}{S\,\eta\,\Cdet} , + \qquad + S = \Phi_0\, A\, \frac{\re^{2}\lambda^{2}}{\Au^{2}} , +\end{equation} +with $S$ the mode-independent prefactor, in $1/\mathrm{s}$ +(\code{measurement.scale\_factor}). Leaving $\Phi_0 = A = 1$ gives the +\textbf{relative} scale, which is all that is needed to place different scan +modes of one experiment on a common scale. Supplying the measured flux density +and illuminated area gives the \textbf{absolute} scale, and $\Fhkl$ then comes +out in electron units squared --- the same scale on which +\code{CTRcalc.SXRDCrystal} calculates. + +\section{Reflectivity} + +Specular reflectivity is the stationary case at $\gamma = \alpha$ with a +beam-limited active area $A_r = A_0/\sin\alpha$. Vlieg's eq.~63, generalized to +a non-specular exit angle, is +\begin{equation} + R = \frac{\re^{2}\lambda^{2} P_r}{\Au^{2}\,\sin\alpha\,\sin\bout}\, \Fhkl , + \label{eq:refl} +\end{equation} +the fraction of the incident flux scattered into the rod. For the specular rod +$\bout = \alpha$ and this is the familiar $1/\sin^{2}\alpha$: one power from +the footprint, one from the stationary Lorentz factor. + +Nothing beyond an absolutely scaled $\Fhkl$ is required, so \textbf{an absolute +reflectivity comes for free} once the scale factor is absolute, and a +reflectivity curve can be refined together with the truncation rods. +Equation~\eqref{eq:refl} inverts to put a measured reflectivity onto the +$\Fhkl$ scale. + +Three caveats: +\begin{enumerate}[leftmargin=1.6em] +\item It is the \textbf{kinematic} result. It fails near a bulk Bragg peak and + below the critical angle, where refraction and multiple scattering dominate + --- which is where much of the interesting part of a reflectivity curve + lies. Compare against a DWBA treatment there. +\item A reflectivity scan \textbf{drives the detector arm}, which is where the + polarization issue of Section~\ref{sec:notmodelled} bites hardest. +\item \textbf{Off-specular}, $R$ is well defined for a truncation rod + integrated across its cross-section, but not for diffuse scattering, where + only a differential cross-section is meaningful. +\end{enumerate} + +Note also that the ANA ``reflectivity rocking scan'' Lorentz factor +$1/\sin 2\alpha$ describes rocking \emph{through} the specular ridge. That is a +different measurement from a stationary specular scan; both are legitimate and +they are not interchangeable. + +\section{What is deliberately not modelled} +\label{sec:notmodelled} + +\begin{description}[leftmargin=0pt,style=unboxed] +\item[$\Cdet$, the in-plane detector acceptance.] Vlieg's sections 2.4--2.5. + It is close to $1$ for a region wide enough to contain the whole in-plane + peak profile, which is what Eq.~\eqref{eq:master} assumes by taking + $\Cdet = 1$. It is \emph{not} the same for the two modes when the rod is + broad: Drnec's Fig.~18 shows direct stationary integration underestimating + $|F|$ at low $l$ by up to a factor of $2$ for exactly this reason. This is + the one remaining mechanism that can make the two modes disagree after + everything in this document is applied, and it is a modelling problem rather + than a normalization one. It cannot be validated on simulated data --- it + needs the overlap region of a rocking and a stationary scan on the same rod. + +\item[The polarization at a moving detector arm.] The per-pixel polarization + array is built once, outside the frame loop, from the \emph{home} geometry + --- step 1 of Section~\ref{sec:pixelangles} is skipped, so it is the array + at the calibrated arm position rather than at the frame's. For a fixed arm + that is correct: each pixel already carries its own scattering angle, and + the apparent $\alpha$ dependence of the z-axis expression is only a change + of frame. For a scan that drives the arm it understates the correction: + $3\,\%$ at a scattering angle of $10^{\circ}$, $10\,\%$ at $18^{\circ}$ and + $33\,\%$ at $30^{\circ}$. A per-pixel evaluation that follows the arm + exists and is what such a scan needs. This is the opposite of the acceptance + (Section~\ref{sec:acceptance}), whose \emph{span} survives a moving arm + unchanged. + +\item[Uncertainty of $\dgam$.] Errors follow the same divisors as the + intensities, but a $\dgam$ estimated from the calibrated geometry has an + uncertainty of its own that is not currently propagated. + +\item[The reciprocal-space route.] Voxel binning absorbs the Lorentz factor + (Drnec section 4), so a reconstructed map needs $\Carea$, $\Cbeam$, $P$ and + a $\Delta l$, but no Lorentz factor --- a different set of factors from + either direct-space mode. Placing it on the same absolute scale is separate + work. +\end{description} + +\section{Summary} + +\begin{center} +\renewcommand{\arraystretch}{1.18} +\begin{tabular}{@{}lllcc@{}} +\toprule +Factor & Symbol & Where & Rocking & Stationary \\ +\midrule +Counting time, monitor & $T$, $M_j$ & \code{normalization} & yes & yes \\ +Integration in radian & -- & integration path & yes & n/a \\ +Out-of-plane acceptance & $\dgam$ & \code{acceptance} & yes & \textbf{no} \\ +Lorentz & $L_\varphi$ / $L_s$ & \code{geometry} & yes & yes \\ +Rod interception & $\Crod$ & \code{geometry} & yes & \textbf{no} \\ +Polarization & $P$ & \code{detector} & yes & yes \\ +Solid angle & $\dOm$ & \code{detector} & \textbf{no} & \textbf{no} \\ +Active area / footprint & $A$ & \code{activearea} & yes & yes \\ +Area correction & $\Carea$ & \code{geometry} & slits only & slits only \\ +In-plane acceptance & $\Cdet$ & --- & assumed 1 & assumed 1 \\ +\bottomrule +\end{tabular} +\end{center} + +\noindent +The three entries that distinguish the modes are $\dgam$, $\Crod$ and the +choice of Lorentz factor. Everything else is common, and the two factors +marked \textbf{no} for both modes are the ones whose absence is a deliberate +result rather than an omission. + +\section*{References} +\addcontentsline{toc}{section}{References} + +\begin{enumerate}[leftmargin=1.6em] +\item E.~Vlieg, \emph{Integrated intensities using a six-circle surface X-ray + diffractometer}, J.~Appl.~Cryst. \textbf{30} (1997) 532--543. +\item E.~Vlieg, \emph{ANA --- program for the analysis of surface X-ray + diffraction data}, Appendix~A (z-axis correction-factor table). +\item J.~Drnec, T.~Zhou, S.~Pintea, W.~Onderwaater, E.~Vlieg, G.~Renaud and + R.~Felici, \emph{Integration techniques for surface X-ray diffraction data + obtained with a two-dimensional detector}, J.~Appl.~Cryst. \textbf{47} + (2014) 365--377. +\end{enumerate} + +\end{document} diff --git a/orgui/datautils/xrayutils/test/test_corrections_acceptance.py b/orgui/datautils/xrayutils/test/test_corrections_acceptance.py index c8cf5c9..c6c3c6b 100644 --- a/orgui/datautils/xrayutils/test/test_corrections_acceptance.py +++ b/orgui/datautils/xrayutils/test/test_corrections_acceptance.py @@ -265,6 +265,59 @@ def test_one_arm_angle_alone_is_rejected(): ) +def test_the_solid_angle_correction_and_the_acceptance_carry_one_obliquity(): + """Which acceptance goes with which sum, and why F6 keeps the raw one. + + A rocking scan's omega-integrated counts in pixel row ``j`` go as that + row's gamma height ``dgamma_j``. A raw region sum -- which is what a + region-of-interest integration produces once F6 stops applying the + solid-angle correction -- therefore pairs with ``sum_j dgamma_j``, which + is what :func:`out_of_plane_acceptance` returns. A solid-angle corrected + sum would instead pair with ``sum_j dgamma_j / Omega~_j``, larger by the + region-mean correction that was applied to the counts. + + The two are pinned together because the second is the trap that made F6 + look optional: the correction cancels against the acceptance in a rocking + scan, so leaving it in *looks* harmless there. It is not, because a + stationary sum has no acceptance for it to cancel against. + + The third assertion is the other trap: a *constant* nominal ``n * pixel / + dist`` is neither partner. On a flat detector + ``dgamma = nominal cos^2(theta)`` while ``Omega~ = cos^3(theta)``, so the + corrected-sum partner is ``nominal / cos(theta)`` -- above the nominal, + where the acceptance itself is below it. + """ + det = _calibrated_detector() + alpha = np.deg2rad(0.6) + column, rows = SHAPE[1] // 2, 60 + solid_angle = np.asarray(det.solidAngleArray(SHAPE), dtype=np.float64) + + # Off the beam centre, where the detector is oblique enough to separate + # the three candidates. + for row in (480, 560): + edges = np.arange(row - rows / 2.0, row + rows / 2.0 + 1.0) + gamma = np.concatenate( + [acceptance._surface_gamma(det, e, column, alpha).ravel() for e in edges] + ) + per_row = np.abs(np.diff(gamma)) + omega = solid_angle[row - rows // 2:row + rows // 2, column] + + edge_to_edge = acceptance.out_of_plane_acceptance( + det, float(row), float(column), float(rows), alpha + ) + corrected_partner = np.sum(per_row / omega) + + # The raw-sum partner is the edge-to-edge span. + np.testing.assert_allclose(np.sum(per_row), edge_to_edge, rtol=1e-9) + # The corrected-sum partner is that, divided by the mean correction. + np.testing.assert_allclose( + corrected_partner, edge_to_edge / omega.mean(), rtol=1e-4 + ) + # Neither equals the nominal, and they straddle it. + nominal = rows * PIXEL / DIST + assert edge_to_edge < nominal < corrected_partner + + def test_the_acceptance_is_what_the_reduction_asks_for(): """It plugs straight into the rocking angular factor. From 0af9cb7f74a5efa4dca0b2d3cdee23fa9d8a7349 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Thu, 10 Sep 2026 12:18:22 -0400 Subject: [PATCH 06/33] feat(phys)!: put rocking and stationary integration on one structure-factor scale BREAKING CHANGE: F2_hkl changes in both integration modes. Rocking scans gain the per-frame exposure/monitor normalization, the rocking integral in radian, and division by the out-of-plane acceptance; both modes divide the detector solid-angle correction back out of F2_hkl, which still scales the intensity. Rods integrated with an earlier version are on a different scale and must be re-integrated before being combined with new ones. --- CHANGELOG.md | 52 ++- doc/design/ctr_structure_factor_handover.md | 178 +++++----- doc/design/ctr_structure_factor_scale.md | 130 +++++--- doc/source/image_integration.rst | 88 +++-- doc/source/release_notes.rst | 6 +- orgui/app/QScanSelector.py | 21 +- orgui/app/integration_corrections.py | 31 +- orgui/app/orGUI.py | 33 +- orgui/app/peak1Dintegr.py | 314 +++++++++++++++++- orgui/app/test/test_peak1Dintegr.py | 214 +++++++++++- orgui/app/test/test_scan_mode_equivalence.py | 138 ++++++-- .../xrayutils/corrections/detector.py | 82 ++++- .../test/test_corrections_detector.py | 136 ++++++++ 13 files changed, 1212 insertions(+), 211 deletions(-) create mode 100644 orgui/datautils/xrayutils/test/test_corrections_detector.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a4bb7..4d560da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ This is the changelog for the software orGUI, written by Timo Fuchs Scientific and analysis additions: +- **Rocking and stationary integration now produce the same structure factor.** + *This changes saved numbers in both modes.* A rocking scan and a stationary + scan of the same rod previously differed by exactly exposure time times + monitor times the out-of-plane acceptance in degrees; they now agree. Four + corrections changed. The rocking path gained the per-frame exposure and + monitor normalization, applied inside the rocking integral so that a varying + counting time or a drifting monitor is handled correctly rather than only on + average; it now integrates the rocking angle in radian as the published + expressions require, rather than in degrees; and it divides by the + out-of-plane acceptance of its region of interest, without which a rod + measured with regions resized along the scan came out with a distorted + *shape* -- a factor 2.3 across a simulated Pt(111) rod -- and not merely a + wrong scale. Separately, the detector **solid-angle correction no longer + reaches a structure factor** in either mode: summing a region of interest + already yields the complete angular integral, with every pixel weighted by + the solid angle it subtends, so dividing by that solid angle again + double-counted the detector obliquity (0.7 % for a detector at 1 m, 7 % at + 0.3 m, varying across the detector and therefore a rod shape error). The + switch stays, because that correction is the right one for a broad or + diffuse feature where a differential cross section is wanted: it still + scales the intensity counters, and is now measured over the same regions of + interest and divided back out when ``F2_hkl`` is formed, so a structure + factor is the same number whether or not it was enabled. Its tooltip and the + ``SOLA`` status badge say so. The reciprocal-space reconstruction keeps + applying it uncompensated, since that path does form a differential cross + section per pixel. For rocking scans the correction is applied when the + curves are extracted, so whether to remove it again is read from the + configuration stored with the scan; an older database where that cannot be + established warns and is left uncompensated. Integrated rocking scans now + store a + ``reduction`` group beside ``F2_hkl`` recording the mode, the angle unit, + which normalizations were applied, the acceptance used, whether the + solid-angle correction was compensated, and the active-area assumption, so + that a saved rod can be placed on a common scale afterwards. + Rocking normalization uses the counters stored with the scan, so it requires + a backend that declares ``exposure_time`` in ``auxillary_counters``; a + missing counter is skipped and recorded rather than failing the integration. + Existing configuration files load unchanged. + - **All correction factors collected into one package.** Every factor between detector counts and a structure factor now lives in ``orgui.datautils.xrayutils.corrections``, split by what it depends on: @@ -32,7 +71,8 @@ Scientific and analysis additions: the aperture, and vectorized over a scan because orGUI resizes regions per detector position. ``gamma_range`` reports the span over the whole region as a check on rolled-detector geometries, and ``pixel_acceptance`` the one-row - case. **A new API only: the integration paths do not call it yet.** + case. The rocking integration now divides by it -- see the mode-equivalence + entry below. - **One structure-factor scale for rocking scans, stationary scans, and reflectivity.** The new public module @@ -46,13 +86,11 @@ Scientific and analysis additions: reject it), and, given the incident flux density and the illuminated area, puts the result on the absolute electron-unit scale. It also converts between ``|F_hkl|^2`` and absolute reflectivity, so a reflectivity curve and - a set of truncation rods can be brought onto one scale. **This is a new API - only: no existing integration result changes.** The integration paths do - not use it yet, and rocking and stationary integration remain on different - scales, differing by exposure time times monitor times the acceptance in - degrees; the image-integration documentation now says so explicitly, and + a set of truncation rods can be brought onto one scale. ``doc/design/ctr_structure_factor_scale.md`` records the full analysis with - the measured size of every correction. + the measured size of every correction, and + ``doc/physics/ctr_structure_factor_physics.tex`` is a typeset reference for + the normalizations and integration intervals. - **Unified CTR fit predictions, lifecycle, and statistics.** CTR optimizer predictions, residuals, likelihoods, and diagnostics now use one final diff --git a/doc/design/ctr_structure_factor_handover.md b/doc/design/ctr_structure_factor_handover.md index bdde295..f60df74 100644 --- a/doc/design/ctr_structure_factor_handover.md +++ b/doc/design/ctr_structure_factor_handover.md @@ -1,15 +1,17 @@ # CTR structure-factor scale: implementation status and handover -> **Status as of 2026-09-09.** Branch `claude/ctr-structure-factor-9633bc`, -> four commits ahead of `master`, nothing pushed. +> **Status as of 2026-09-10.** Branch `claude/ctr-structure-factor-9633bc`, +> six commits ahead of `master`, nothing pushed. > -> The physics analysis is complete and quantified, the reduction and the -> acceptance estimator exist and are tested, and the correction factors have -> one home. **No saved number has changed yet**: the GUI integration paths do -> not call any of the new reduction. Issues -> [#82](https://github.com/tifuchs/orGUI/issues/82) and -> [#15](https://github.com/tifuchs/orGUI/issues/15) are therefore *analysed and -> equipped* but not closed. +> The physics analysis is complete and quantified, and the reduction is now +> **wired in**: a rocking scan and a stationary scan of the same rod come out +> with the same `F2_hkl`, asserted by +> `test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_agree`. +> This **changed saved numbers in both modes**. +> [#82](https://github.com/tifuchs/orGUI/issues/82) is closed up to the +> real-data check of section 6 and `C_det` (F7), the one mechanism simulation +> cannot test. [#15](https://github.com/tifuchs/orGUI/issues/15) still needs +> the two absolute-scale inputs of section 6. > > This document is the handover: what exists, how to run it, what to do next, > and which of my predictions turned out wrong. The physics itself is in @@ -18,16 +20,17 @@ ## 1. The one-paragraph summary -A rocking scan and a stationary scan of the same rod do not currently produce -the same `F2_hkl` in orGUI. The ratio is exactly +A rocking scan and a stationary scan of the same rod used to differ by exactly `T_omega * monitor_omega * Delta_gamma_in_degrees`, measured to seven digits on -simulated data. Three things are missing from the rocking path: exposure/monitor -normalization, integration in radian rather than degrees, and division by the -out-of-plane detector acceptance. All three now have working implementations in -`orgui/datautils/xrayutils/corrections/`; none of them is wired in. A fourth, -F6, affects **both** modes: a ROI sum is already a complete angular integral, -so the solid-angle correction has to stop being applied to it, or the two -modes stay apart by `<1/Omega~>` even after the rocking path is fixed. +simulated data. Three things were missing from the rocking path: +exposure/monitor normalization, integration in radian rather than degrees, and +division by the out-of-plane detector acceptance. A fourth, F6, affected +**both** modes: a ROI sum is already a complete angular integral, so the +solid-angle correction had to be divided back out of `F2_hkl`. It stays +applied to the *intensity*, where a broad or diffuse feature needs it. All +four are now handled, and the two modes agree to `1e-6` on simulated data — +the residual is the trapezoidal sampling of the rocking profile, not a +correction factor. ## 2. What is on the branch @@ -64,18 +67,32 @@ as re-export aliases because both were released under those names. `test_corrections_package.py` asserts the aliases hand out the *same objects*, not merely that they import. -### 2.2 What is wired, and what is not +### 2.2 What is wired | caller | uses the package for | still does its own thing | |---|---|---| -| `orGUI.integrateROI` (stationary) | `pixel_factors`, `mode_components`, `normalization_divisor`, `C_illum_area` | — | +| `orGUI.integrateROI` (stationary) | `pixel_factors`, `mode_components`, `normalization_divisor`, `C_illum_area`, `roi_mean_inverse_solid_angle` | — | | `orGUI.rocking_integrate` | `pixel_factors` | — | -| `peak1Dintegr.integrate` (rocking) | `mode_components` | **no normalization, degrees, no `Delta_gamma`** | -| `reconstruction_job` | `pixel_factors` | own native-fused application, own normalization loop | - -Nothing calls `measurement.structure_factor_squared`, -`measurement.normalized_intensity`, `measurement.angular_factor`, -`activearea.*` or `acceptance.*` outside the tests. That is the gap to close. +| `peak1Dintegr.integrate` (rocking) | `mode_components`, `normalization_divisor`, `normalized_intensity`, `out_of_plane_acceptance`, `roi_mean_inverse_solid_angle` | — | +| `reconstruction_job` | `pixel_factors` (solid angle applied, **not** compensated) | own native-fused application, own normalization loop | + +Still uncalled outside the tests: `measurement.structure_factor_squared`, +`measurement.angular_factor` and `activearea.*`. That is deliberate rather +than a gap — the two integration paths form `F2_hkl` on a *relative* scale by +dividing out the mode-dependent factors they already hold as +interval-weighted means, and `structure_factor_squared` additionally divides +by the absolute prefactor, which needs the issue #15 inputs. `angular_factor` +rebuilds `eta` from point angles, which is the wrong thing for a path that +has ROI-weighted means of each component. + +The rocking path could not use `normalization_divisor(scan, ...)` as the +stationary path does: it runs off the database and has no scan object. It +builds the divisor from the stored `auxillary` counters instead, via +`_rocking_normalization`, using the same monitor-name setting +(`reconstruction_monitor_corrections`) the other two paths use. Exposure time +is only there if the backend declares `exposure_time` in +`auxillary_counters` — ID31 does, `P212_tools` and the base `Scan` do not, and +a missing counter is skipped and recorded rather than failing the job. ## 3. Getting a green test run @@ -147,55 +164,66 @@ also in the `AGENTS.md` files. They are the constraints a follow-up must keep. not by inspection.** The refactor was checked at 80 failed / 782 passed before and after. -## 5. The next commit, in detail - -Wiring the rocking path, plus the F6 solid-angle removal, which also touches -the stationary path. This **changes saved numbers in both modes**, so it is -`feat(phys)!` with a `BREAKING CHANGE:` footer per the repository's commit -convention. - -Steps 1-3 are `peak1Dintegr.RockingPeakIntegrator.integrate`; step 4 is that -plus `orGUI.integrateROI`: - -1. Build the exposure/monitor divisor the way the stationary path does, via - `integration_corrections.normalization_divisor(scan, ...)`. Use the - **per-frame** counting time, not the sum over the scan: the rocking angle is - the integration variable, the time is not. -2. Convert the trapezoid integral from degrees to radian — - `measurement.normalized_intensity(..., angle_unit="deg")` does both this and - step 1. -3. Divide by `acceptance.out_of_plane_acceptance(detector, row, column, - row_size, alpha, gamma_arm, delta_arm)`. The ROI centre and size per `s` - point are already in the database group `integration/`; the arm angles come - from `orgui.backend.scans.scan_arm_angles`. -4. Stop applying the solid-angle correction to ROI sums — **in both modes**, - so this touches `orGUI.integrateROI` as well. F6 is resolved this way (see - the physics doc): a ROI sum is already a complete angular integral, and - leaving the correction in means the modes still differ by `<1/Omega~>` — - 0.7 % at 1 m, 7 % at 0.3 m — because a rocking scan's solid-angle factor - cancels against its acceptance and a stationary scan's has nothing to - cancel against. In practice: drop the `solid_angle=` argument at - `orGUI.py:1595` and `:5765`, then the `useSolidAngleBox` widget, the - `solidAngle` key in `get_integration_options` and the `SOLA` badge. - `set_integration_options` ignores keys it has no branch for, so old - configuration files still load. The reconstruction path has its own switch - (`reconstruction_job.py:1254`) and must keep it. -5. Store the acceptance, the applied normalization and the active-area - assumption next to `F2_hkl`. Without them a saved rod cannot be put on a - common scale after the fact. - -Then `orgui/app/test/test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_differ_by_the_missing_normalizations` -**must flip** from a characterization test to a plain equality. Its docstring -says so. It currently asserts the gap *is* `T * monitor * Delta_gamma_deg`; if -it starts failing after a wiring change, that is the change working. - -**F6 is settled** (physics doc, F6): ROI-summed integration stops applying the -solid-angle correction, in both modes. Two things fell out of measuring it -that matter for step 3: `Delta_gamma` does **not** become unnecessary — it is a -size factor, the solid angle an obliquity factor, and they overlap only in the -obliquity — and a constant nominal `n * pixel / dist` is **not** a valid -"pixel-derived" acceptance, being wrong by `1/cos(theta)` (1.2 % at 9 degrees). -`out_of_plane_acceptance` is still what the rocking path divides by. +## 5. The wiring commit, as landed + +`feat(phys)!` — it changed saved numbers in **both** modes. What it did: + +1. **Normalization.** `peak1Dintegr.RockingPeakIntegrator._rocking_normalization` + builds the per-frame exposure/monitor divisor from the stored `auxillary` + counters (see section 2.2 for why not from a scan object) and it is applied + **inside** the rocking integral, not to the finished integral: with a + varying counting time or a drifting monitor the quantity Vlieg integrates is + `Int N(omega)/(T M) d omega`, and dividing the result by a mean is only + equivalent for constant counters. It rides the existing `C_corr` machinery, + which already carried `C_illum_area` per `(s, omega)` point, so the error + propagation followed for free. +2. **Radian.** `measurement.normalized_intensity(..., angle_unit="deg")` + converts when `F2_hkl` is formed. The stored `croibg` and `int_interval` + stay in the unit they were measured in — changing those would change the + meaning of two saved columns for no gain. +3. **Acceptance.** `_rocking_acceptance` calls `out_of_plane_acceptance` with + the region centre and vertical size stored per `s` point. Two traps here: + the coordinate order (`surfaceAnglesPoint` takes pyFAI dimension 1 first, + which is the *row*, and orGUI's `y` is the row while `x` is the column — + every call site in the application passes them swapped), and `vsize` being + the row extent, which follows from `detvsize, dethsize = detector.shape` + at `orGUI.py:1096`. It is evaluated at the calibrated arm position, which + costs nothing measurable because the span is arm-invariant to nine digits. + With no calibrated detector reachable it warns and leaves `F2_hkl` on the + acceptance-blind scale rather than failing the integration. +4. **Solid angle compensated, not removed.** The switch, its config key and + its badge are untouched, and it still scales the intensity — a broad or + diffuse feature wants a differential cross-section, and that capability was + worth keeping. What changed is that `F2_hkl` divides it back out, via the + new `detector.roi_mean_inverse_solid_angle`, measured over the same regions + from the calibrated geometry alone (no image, so it runs outside the + integration loop). Two things to know: the compensation is approximate at + the `1e-6` level, because `pixel_factors` fuses the solid angle with the + polarization so the applied mean is `<1/(Omega~ P)>` rather than a product + of means; and for a rocking scan the correction was applied at *extraction* + time, so whether to compensate is read from the configuration stored with + the scan (`configuration/orgui/integration_corrections/json`) rather than + from the current switch. An older database where that cannot be read warns + and is left uncompensated rather than guessed at. + + A related trap, found by breaking it first: `useSolidAngleBox` is *also* + the store the reconstruction persists its own solid-angle setting through + (`config_data.py:383`/`:467` map the `solidAngle` integration option onto + `CorrectionState.use_solid_angle`, and `ReconstructionDialog` mirrors its + checkbox via `scanSelector.get/set_integration_options`). Removing the key + would have silently disabled the correction in the one path that must keep + it, and that is invisible from either file alone. +5. **Provenance.** A `reduction` group beside `F2_hkl` records the mode, the + angle unit, which normalizations applied, whether the acceptance was + applied, whether the solid-angle correction was compensated, the + active-area assumption, and the acceptance array itself. + +`test_rocking_and_stationary_paths_differ_by_the_missing_normalizations` +became `test_rocking_and_stationary_paths_agree`, and +`test_a_resized_region_of_interest_distorts_the_rocking_rod` became +`..._no_longer_distorts_...`. Both keep the *old* behaviour as a contrast +assertion via a `reduce=False` switch on the helper, so the factor that used to +be left behind cannot come back unnoticed. ## 6. After that diff --git a/doc/design/ctr_structure_factor_scale.md b/doc/design/ctr_structure_factor_scale.md index 3b3cb7a..8ef1a18 100644 --- a/doc/design/ctr_structure_factor_scale.md +++ b/doc/design/ctr_structure_factor_scale.md @@ -1,26 +1,26 @@ # One structure-factor scale for rocking scans, stationary scans and reflectivity -> **Status: analysis complete, reduction core and regression tests landed, GUI -> paths not yet switched over.** This is the review document for +> **Status: analysis complete, reduction landed and wired into both +> integration paths.** This is the review document for > [issue #82](https://github.com/tifuchs/orGUI/issues/82) ("Regression tests > and validation of equivalence of rocking and stationary scan integration") > and for the physics half of > [issue #15](https://github.com/tifuchs/orGUI/issues/15) ("Calculate > quantitatively exact structure factors"). > -> It records why a rocking scan and a stationary scan of the same rod do -> **not** currently produce the same `F2_hkl` in orGUI, quantifies each reason -> on simulated data, and states what is still needed for an absolute scale and +> It records why a rocking scan and a stationary scan of the same rod did +> **not** produce the same `F2_hkl` in orGUI, quantifies each reason on +> simulated data, and states what is still needed for an absolute scale and > for absolute reflectivity. Everything below was verified numerically against -> the code as of this branch, not by inspection alone. +> the code, not by inspection alone. > -> Landed with this document: -> `orgui/datautils/xrayutils/corrections/measurement.py` (the reduction, pure -> functions, additive - no existing number changes), -> `orgui/datautils/xrayutils/test/test_corrections_measurement.py` (18 tests -> against the published equations) and -> `orgui/app/test/test_scan_mode_equivalence.py` (5 tests that simulate one -> rod measured both ways and push it through both of orGUI's current paths). +> F1, F2, F3 and F6 are now **applied**, which changed saved numbers in both +> modes; the two paths agree to `1e-6` on simulated data, limited by the +> trapezoidal sampling of the rocking profile. F4, F5 and F7 remain open and +> are described below as they stand. Findings are written in the present tense +> of the analysis; section 5 says what each one's status is now, and +> [`ctr_structure_factor_handover.md`](ctr_structure_factor_handover.md) +> section 5 says how each was wired. ## 1. What the two papers require @@ -70,7 +70,7 @@ Their ratio (Vlieg eq. 64/65) is the identity issue #82 is really about: I_s / I_omega = T omega_0 sin(delta) cos(beta_in) / (Delta_gamma sin(gamma)) ``` -## 2. What orGUI does today +## 2. What orGUI did before this work | | rocking (`peak1Dintegr`) | stationary (`orGUI.integrateROI` + `integration_corrections`) | reconstruction (`reconstruction_job`) | |---|---|---|---| @@ -220,11 +220,19 @@ The solid-angle array and an angle-derived `Delta_gamma` describe the same geometry. Applying one without the other double-counts, so the pair has to be decided together with F3. -**Resolved: ROI-summed integration does not apply a solid-angle correction at -all, in either mode.** The array stays where it is genuinely required, the -per-pixel reconstruction path, which forms a differential cross-section rather -than a sum over an aperture - and which has its own independent switch -(`reconstruction_job.py:1254`), so this does not touch it. +**Resolved: the solid-angle correction stays available as a switch, scales the +intensity, and is divided back out when `F2_hkl` is formed.** It is kept +because it is the right correction for a *broad or diffuse* feature, where a +differential cross-section rather than an integrated rod intensity is wanted; +it is removed from the structure factor because a rod is integrated by summing +a region, which is already the complete angular integral. The per-pixel +reconstruction keeps applying it without compensation, under its own switch +(`reconstruction_job.py:1254`). + +An earlier revision of this document had the correction dropped from ROI +integration altogether. That was reversed: it discarded a capability that has +a real use, and the compensation costs one geometric factor. The physics below +is unchanged - what changed is where the factor is removed, not whether. Three steps to that conclusion: @@ -241,11 +249,18 @@ Three steps to that conclusion: the rod. The slice is what `Delta_gamma` accounts for; there is still no per-pixel weighting to undo. -Leaving the correction in and compensating for it in the reduction was -considered and rejected. It gives the identical `|F|^2` - what is divided out -is exactly the scalar that was multiplied in - but it needs one more argument -on the reduction, and it puts the stored intensity column and `F2_hkl` on -different scales. What it cannot do is stay in *uncompensated*, because the +The compensation is +`detector.roi_mean_inverse_solid_angle`, the mean of `1/Omega~` over the +nominal region rectangle, evaluated from the calibrated geometry alone so it +needs no image and can be computed per frame outside the integration loop. +Two approximations come with that, both far below the effect being removed: +it is the mean over the *nominal* region rather than over the valid pixels the +integration accumulated, and because `pixel_factors` fuses the solid angle +with the polarization into one array, the applied mean is +`<1/(Omega~ P)>` rather than `<1/Omega~><1/P>`, leaving their covariance over +the region - second order in the variation across it, of order `1e-6`. + +What the correction cannot do is stay in *uncompensated*, because the two modes do not carry it symmetrically: for a rocking scan the omega-integrated counts in pixel row *j* go as that row's gamma height `dgamma_j`, so a corrected sum pairs with `Sum_j dgamma_j / Omega~_j` and the @@ -280,14 +295,19 @@ gone wrong under the other one: degrees. `pixel_acceptance` times the row count is the same angle-derived quantity as `out_of_plane_acceptance`, not a cheaper alternative to it. -On the user-facing side, `useSolidAngleBox` (`QScanSelector.py:748`) drives -nothing else: its only consumers are the two direct-space integration paths -(`orGUI.py:1595` in `rocking_integrate`, `:5765` in `integrateROI`). Dropping -the widget, the `solidAngle` key from `get_integration_options` and the `SOLA` -badge is therefore the whole change. Old configuration files stay loadable -without a shim - `set_integration_options` is an `if`/`elif` chain over the -keys that are present, with no `else`, so a stored `solidAngle` entry is -ignored rather than an error. +On the user-facing side the switch and its config key are untouched, so +nothing about existing configurations changes. Note that `useSolidAngleBox` +is *also* the store the reciprocal-space reconstruction persists its own +solid-angle setting through: `config_data.py:383`/`:467` map the `solidAngle` +integration option onto `CorrectionState.use_solid_angle`, and +`ReconstructionDialog` mirrors its checkbox via +`scanSelector.get/set_integration_options`. Removing the key would therefore +have silently disabled the correction in the one path that must keep it - a +trap worth recording, because it is invisible from either file alone. + +What the switch means is now stated in its tooltip and in the `SOLA` badge, +since "applied to the intensity but not to `F2_hkl`" is not something a user +can infer from a checkbox. ### F7 - `C_det` is assumed to be 1 in both modes @@ -466,9 +486,17 @@ Measured against a calibrated 172 um detector at 1 m, a 60-row region accepts mille towards the detector edge - the obliquity that makes `Delta_gamma` vary along a scan in the first place. -## 5. What is needed to close issue #82 +## 5. Status of each finding + +**F1, F2, F3 and F6 are applied.** Steps 1-5 below are done; the wiring of +each is in [`ctr_structure_factor_handover.md`](ctr_structure_factor_handover.md) +section 5, and the equivalence is asserted by +`test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_agree`. +Step 6, the real-data check, is the one that remains, together with F4, F5 and +F7. -In order: +Recorded in the order they had to be done, because the ordering was itself a +result -- F6 had to be settled before F3 could be wired: 1. **Normalize rocking scans.** Give `RockingPeakIntegrator.integrate` the same `normalization_divisor` the stationary path uses. The per-frame counting @@ -486,23 +514,31 @@ In order: * Pass the per-frame arm angles from `scan_arm_angles` where they are known. They turn out to matter far less than expected (section 4.4), but they cost nothing. - * Stop applying the solid-angle correction first (step 4): `Delta_gamma` - and the solid-angle correction each carry the same obliquity, and - leaving both in double-counts it. -4. **Stop applying the solid-angle correction to ROI sums, in both modes.** - (F6) A ROI sum is already a complete angular integral. This also touches - `orGUI.integrateROI`, and it changes stationary saved numbers by - `<1/Omega~>` - 0.7 % at 1 m, 7 % at 0.3 m - so it belongs in the same - breaking commit as the rocking wiring, not a later one. The reconstruction - path keeps its own solid-angle switch and is unaffected. + * Settle the solid-angle compensation first (step 4): `Delta_gamma` and + the solid-angle correction each carry the same obliquity, and leaving + both in double-counts it. +4. **Divide the solid-angle correction back out of `F2_hkl`, in both modes.** + (F6) A ROI sum is already a complete angular integral, so the correction + must not reach a structure factor - but it stays on the intensity, where a + broad or diffuse feature needs it. This also touches `orGUI.integrateROI`, + and it changes stationary `F2_hkl` by `<1/Omega~>` whenever the switch was + on - 0.7 % at 1 m, 7 % at 0.3 m - so it belongs in the same breaking commit + as the rocking wiring, not a later one. The reconstruction path keeps + applying it uncompensated and is unaffected. 5. **Record the mode and its inputs in the saved data**, next to `F2_hkl`: the acceptance, the normalization that was applied, and the active-area assumption of F4. Without them a saved rod cannot be put on a common scale after the fact. -6. **Then verify on real data.** The overlap region of a rocking scan and a - stationary scan on the same rod (Drnec Fig. 8, right) is the acceptance - test. Simulation cannot catch F7, an incorrect `Delta_gamma` definition, or - a beamline that reports counting time in the wrong place. +6. **Then verify on real data. Not done — this is the remaining step.** The + overlap region of a rocking scan and a stationary scan on the same rod + (Drnec Fig. 8, right) is the acceptance test. Simulation cannot catch F7, + an incorrect `Delta_gamma` definition, or a beamline that reports counting + time in the wrong place. The simulated equivalence test does cover the F6 + compensation + (`test_the_solid_angle_correction_does_not_reach_the_structure_factor`), + but it supplies the region mean itself rather than reading a detector, so + the geometry evaluation is pinned separately in + `test_corrections_detector.py`. ## 6. What is needed to close issue #15, and reflectivity diff --git a/doc/source/image_integration.rst b/doc/source/image_integration.rst index 1f346f5..737a033 100644 --- a/doc/source/image_integration.rst +++ b/doc/source/image_integration.rst @@ -166,45 +166,69 @@ instead, which is the row the manual marks as calculated numerically. Comparing Scan Modes ~~~~~~~~~~~~~~~~~~~~ -``F2_hkl`` is proportional to :math:`|F_{hkl}|^2` **within** one integration -mode, but a rocking scan and a stationary scan of the same rod are not -currently on the same scale, and the two must not be plotted or fitted -together without rescaling. +A rocking scan and a stationary scan of the same rod are reduced to the same +``F2_hkl`` and may be plotted and fitted together. Verified on simulated data +to a relative :math:`10^{-6}`, the residual being the trapezoidal sampling of +the rocking profile. Vlieg's rocking-scan expression (equation 42) contains three factors that the -stationary expression (equation 54) does not, and that orGUI's rocking -integration does not divide out: - -* the **counting time and monitor**. ``Normalize integrated intensities`` - applies to stationary integration only; a rocking integration is not - normalized. -* the **unit of the rocking angle**. The rocking curve is integrated over the - motor position in degrees, while the published expressions integrate in - radian, a factor :math:`180/\pi`. +stationary expression (equation 54) does not, and all three are applied: + +* the **counting time and monitor**, divided out per frame *inside* the + rocking integral, so that a varying counting time or a drifting monitor is + handled correctly and not merely on average. A rocking integration takes + these from the counters stored with the scan, so it needs a beamline backend + that lists ``exposure_time`` among its auxiliary counters; a counter that is + not there is skipped, and what was applied is recorded with the result. +* the **unit of the rocking angle**. The published expressions integrate in + radian; the motor axis is in degrees, and the factor :math:`180/\pi` is + applied when ``F2_hkl`` is formed. The stored ``croibg`` and the integration + interval stay in degrees, the unit they were measured in. * the **out-of-plane angular acceptance** :math:`\Delta\gamma` of the region of interest. A rocking scan intercepts a slice of rod whose length is proportional to :math:`\Delta\gamma`, so its integrated intensity is too; a stationary measurement intercepts the whole rod cross-section and has no such factor. Because ROIs are sized per detector position, this factor is - not even constant along one rocking data set, so it changes the *shape* of - a rod and not only its scale. - -Together, - -.. math:: - - \frac{F^2_{hkl,\mathrm{rocking}}}{F^2_{hkl,\mathrm{stationary}}} - = T\;M\;\Delta\gamma[^\circ] - -with :math:`T` the per-frame counting time, :math:`M` the monitor value and -:math:`\Delta\gamma` the acceptance in degrees. - -:mod:`orgui.datautils.xrayutils.corrections.measurement` implements the full -reduction, for rocking scans, stationary scans and reflectivity, and is the -supported way to put integrated intensities from different modes on one -scale --- and, given the incident flux and the illuminated area, on the -absolute scale of :math:`|F_{hkl}|^2` in electron units. The integration -paths do not use it yet. + not constant along one rocking data set, so leaving it out changed the + *shape* of a rod and not only its scale. + +The **detector solid-angle correction** is treated separately, and does not +reach a structure factor. Summing a region of interest already produces the +complete angular integral, each pixel weighted by the solid angle it subtends, +so dividing by that solid angle again would double-count the detector +obliquity -- 0.7 % for a detector at 1 m and 7 % at 0.3 m, varying across the +detector face, and therefore a rod *shape* error. + +The switch remains, because the correction is the right one for a **broad or +diffuse feature**, where a differential cross section rather than an +integrated rod intensity is what is wanted. What it does is scoped: + +* it scales the **intensity** counters, as before; +* it is measured over the same regions of interest and **divided back out** + when ``F2_hkl`` is formed, so a structure factor is the same number whether + or not the switch was on; +* the **reciprocal-space reconstruction** keeps applying it and does not + divide it out, since that path forms a differential cross section per pixel. + +For a rocking scan the correction is applied when the curves are extracted, so +whether to remove it again is read from the configuration stored with the +scan rather than from the current state of the switch. If that cannot be +established -- an older database -- the integration warns and leaves it in +rather than guessing. + +Each integrated rocking scan stores a ``reduction`` group beside ``F2_hkl`` +recording the mode, the angle unit, which normalizations were applied, the +acceptance that was divided out, whether the solid-angle correction was +compensated and the active-area assumption, so that a saved rod can be placed +on a common scale after the fact. + +What is still **not** on this scale is the absolute one: ``F2_hkl`` is +proportional to :math:`|F_{hkl}|^2` with one common, arbitrary constant. +:mod:`orgui.datautils.xrayutils.corrections.measurement` supplies the absolute +prefactor given the incident flux density and the illuminated area, and +converts between :math:`|F_{hkl}|^2` and absolute reflectivity. +``doc/physics/ctr_structure_factor_physics.tex`` is a typeset reference for +every normalization and integration interval described here. Exposure and Monitor Normalization diff --git a/doc/source/release_notes.rst b/doc/source/release_notes.rst index f1cf643..6d85d19 100644 --- a/doc/source/release_notes.rst +++ b/doc/source/release_notes.rst @@ -9,11 +9,13 @@ Unreleased (2026-07-19) Scientific and analysis additions: +- **Rocking and stationary integration now produce the same structure factor.** *This changes saved numbers in both modes.* A rocking scan and a stationary scan of the same rod previously differed by exactly exposure time times monitor times the out-of-plane acceptance in degrees; they now agree. Four corrections changed. The rocking path gained the per-frame exposure and monitor normalization, applied inside the rocking integral so that a varying counting time or a drifting monitor is handled correctly rather than only on average; it now integrates the rocking angle in radian as the published expressions require, rather than in degrees; and it divides by the out-of-plane acceptance of its region of interest, without which a rod measured with regions resized along the scan came out with a distorted *shape* -- a factor 2.3 across a simulated Pt(111) rod -- and not merely a wrong scale. Separately, the detector **solid-angle correction no longer reaches a structure factor** in either mode: summing a region of interest already yields the complete angular integral, with every pixel weighted by the solid angle it subtends, so dividing by that solid angle again double-counted the detector obliquity (0.7 % for a detector at 1 m, 7 % at 0.3 m, varying across the detector and therefore a rod shape error). The switch stays, because that correction is the right one for a broad or diffuse feature where a differential cross section is wanted: it still scales the intensity counters, and is now measured over the same regions of interest and divided back out when ``F2_hkl`` is formed, so a structure factor is the same number whether or not it was enabled. Its tooltip and the ``SOLA`` status badge say so. The reciprocal-space reconstruction keeps applying it uncompensated, since that path does form a differential cross section per pixel. For rocking scans the correction is applied when the curves are extracted, so whether to remove it again is read from the configuration stored with the scan; an older database where that cannot be established warns and is left uncompensated. Integrated rocking scans now store a ``reduction`` group beside ``F2_hkl`` recording the mode, the angle unit, which normalizations were applied, the acceptance used, whether the solid-angle correction was compensated, and the active-area assumption, so that a saved rod can be placed on a common scale afterwards. Rocking normalization uses the counters stored with the scan, so it requires a backend that declares ``exposure_time`` in ``auxillary_counters``; a missing counter is skipped and recorded rather than failing the integration. Existing configuration files load unchanged. + - **All correction factors collected into one package.** Every factor between detector counts and a structure factor now lives in ``orgui.datautils.xrayutils.corrections``, split by what it depends on: ``geometry`` (the z-axis Lorentz, rod-interception and area table), ``beamprofile``, ``activearea``, ``detector`` (per-pixel solid angle and polarization), ``normalization`` (counting time and monitor), ``roi``, and ``measurement``. The rocking integration, the stationary integration and the reciprocal-space reconstruction previously each carried their own copy of several of these; they now share one definition, so they cannot drift onto different scales. The package is physics only -- numbers in, numbers out -- and reads no scan object, configuration or widget; ``orgui.app`` ``integration_corrections`` is the adapter that supplies those. ``orgui.datautils.xrayutils.geometrycorrections`` and ``orgui.datautils.xrayutils.beamprofile`` keep working as aliases of the moved modules. **No calculated value changes.** -- **Out-of-plane detector acceptance.** ``orgui.datautils.xrayutils.corrections.acceptance`` estimates ``Delta_gamma``, the angular height of a region of interest as seen from the sample, which a rocking-scan integrated intensity is proportional to (Vlieg equations 20 and 42) and which orGUI previously did not compute at all. Measured edge to edge, at the region's centre column where the rod crosses the aperture, and vectorized over a scan because orGUI resizes regions per detector position. ``gamma_range`` reports the span over the whole region as a check on rolled-detector geometries, and ``pixel_acceptance`` the one-row case. **A new API only: the integration paths do not call it yet.** +- **Out-of-plane detector acceptance.** ``orgui.datautils.xrayutils.corrections.acceptance`` estimates ``Delta_gamma``, the angular height of a region of interest as seen from the sample, which a rocking-scan integrated intensity is proportional to (Vlieg equations 20 and 42) and which orGUI previously did not compute at all. Measured edge to edge, at the region's centre column where the rod crosses the aperture, and vectorized over a scan because orGUI resizes regions per detector position. ``gamma_range`` reports the span over the whole region as a check on rolled-detector geometries, and ``pixel_acceptance`` the one-row case. The rocking integration now divides by it -- see the mode-equivalence entry below. -- **One structure-factor scale for rocking scans, stationary scans, and reflectivity.** The new public module ``orgui.datautils.xrayutils.corrections.measurement`` reduces an integrated intensity to ``|F_hkl|^2`` for any scan mode, following E. Vlieg, *J. Appl. Cryst.* 30 (1997) 532 and J. Drnec et al., *J. Appl. Cryst.* 47 (2014) 365. It normalizes counts by exposure time and monitor, converts a rocking integral from degrees to radians, applies the mode-dependent angular factor (rocking scans additionally require the out-of-plane acceptance of the region of interest, stationary measurements reject it), and, given the incident flux density and the illuminated area, puts the result on the absolute electron-unit scale. It also converts between ``|F_hkl|^2`` and absolute reflectivity, so a reflectivity curve and a set of truncation rods can be brought onto one scale. **This is a new API only: no existing integration result changes.** The integration paths do not use it yet, and rocking and stationary integration remain on different scales, differing by exposure time times monitor times the acceptance in degrees; the image-integration documentation now says so explicitly, and ``doc/design/ctr_structure_factor_scale.md`` records the full analysis with the measured size of every correction. +- **One structure-factor scale for rocking scans, stationary scans, and reflectivity.** The new public module ``orgui.datautils.xrayutils.corrections.measurement`` reduces an integrated intensity to ``|F_hkl|^2`` for any scan mode, following E. Vlieg, *J. Appl. Cryst.* 30 (1997) 532 and J. Drnec et al., *J. Appl. Cryst.* 47 (2014) 365. It normalizes counts by exposure time and monitor, converts a rocking integral from degrees to radians, applies the mode-dependent angular factor (rocking scans additionally require the out-of-plane acceptance of the region of interest, stationary measurements reject it), and, given the incident flux density and the illuminated area, puts the result on the absolute electron-unit scale. It also converts between ``|F_hkl|^2`` and absolute reflectivity, so a reflectivity curve and a set of truncation rods can be brought onto one scale. ``doc/design/ctr_structure_factor_scale.md`` records the full analysis with the measured size of every correction, and ``doc/physics/ctr_structure_factor_physics.tex`` is a typeset reference for the normalizations and integration intervals. - **Unified CTR fit predictions, lifecycle, and statistics.** CTR optimizer predictions, residuals, likelihoods, and diagnostics now use one final analytically scaled result path. ``flat_prediction`` supports the common F/R result contract, while ``flat_Fcalc``, ``Rfactor``, and the new ``Rfactor_R`` enforce quantity-specific diagnostics. ``calculated_CTRs`` now always exposes the latest successful final predictions, independently of resolution broadening. Evaluation requires ``prepareFit()`` after parameter layout changes, supported fixed-model changes refresh automatically, and a failed evaluation cannot leave a stale public result. Fit statistics count eliminated analytical scales in their degrees of freedom, report covariance on the same reduced-chi-square scale as parameter errors, and clear unavailable errors throughout the fitted crystal instead of retaining an older estimate. diff --git a/orgui/app/QScanSelector.py b/orgui/app/QScanSelector.py index 0663c34..94df425 100644 --- a/orgui/app/QScanSelector.py +++ b/orgui/app/QScanSelector.py @@ -924,7 +924,12 @@ def get_integration_options(self): #: corrections button shows. CORRECTION_BADGES = ( ("useMaskBox", "MASK", "#b58900", "Pixel mask applied"), - ("useSolidAngleBox", "SOLA", "#268bd2", "Solid angle correction"), + ( + "useSolidAngleBox", + "SOLA", + "#268bd2", + "Solid angle correction (intensity only; divided back out of F2_hkl)", + ), ("usePolarizationBox", "POL", "#6c71c4", "Polarization correction"), ( "useLorentzBox", @@ -1903,6 +1908,20 @@ def __init__(self, selector, parent=None): ) self.maskToolBtn.clicked.connect(self._openMaskTool) detectorLayout.addWidget(self.maskToolBtn) + selector.useSolidAngleBox.setToolTip( + "Divide each pixel by the solid angle it subtends, giving an " + "intensity proportional to the differential cross section. This " + "is what a broad or diffuse feature needs.\n\n" + "It does not affect structure factors. A rod is integrated by " + "summing a region, which already gives the complete angular " + "integral with every pixel weighted by its own solid angle, so " + "the correction is measured over the region and divided back out " + "when F2_hkl is formed. Leaving it in would double-count the " + "detector obliquity: 0.7 % for a detector at 1 m and 7 % at " + "0.3 m, varying across the detector.\n\n" + "The reciprocal-space reconstruction has its own switch, and does " + "need this correction." + ) detectorLayout.addWidget(selector.useSolidAngleBox) detectorLayout.addWidget(selector.usePolarizationBox) detector.setLayout(detectorLayout) diff --git a/orgui/app/integration_corrections.py b/orgui/app/integration_corrections.py index bd9d708..ec68ec5 100644 --- a/orgui/app/integration_corrections.py +++ b/orgui/app/integration_corrections.py @@ -154,6 +154,7 @@ def stationary_correction_factors( beam_profile=None, sample_size=None, normalization=None, + solid_angle_mean=None, ): r"""Correction divisors for one stationary-scan trajectory. @@ -174,6 +175,13 @@ def stationary_correction_factors( when ``use_footprint`` is set. :param normalization: Optional per-image exposure and monitor divisor from :func:`normalization_divisor`, stored as ``C_norm``. + :param solid_angle_mean: Optional per-image region mean of + :math:`1/\widetilde{\Omega}` from + :func:`~orgui.datautils.xrayutils.corrections.detector.roi_mean_inverse_solid_angle`, + stored as ``C_solid_angle``. Pass it when the solid-angle correction + was applied to the intensity, so that :func:`structure_factor` can + divide it back out; a region sum is already a complete angular + integral and must not carry it (finding F6). :returns: The factors, each broadcast to the shape of ``alpha``. :rtype: CorrectionFactors :raises ValueError: If the footprint correction is requested without a @@ -189,6 +197,12 @@ def stationary_correction_factors( ).copy() applied.append("normalization") + if solid_angle_mean is not None: + factors["C_solid_angle"] = np.broadcast_to( + np.asarray(solid_angle_mean, dtype=np.float64), alpha.shape + ).copy() + applied.append("solid_angle") + if use_footprint: if beam_profile is None: raise ValueError( @@ -236,16 +250,27 @@ def apply_stationary_corrections(intensity, errors, factors): def structure_factor(intensity, errors, factors): r"""Form :math:`F^2_{hkl}` from an already corrected intensity. - :math:`F^2 = I_\mathrm{corr} / L_\mathrm{stationary}`. Unlike a rocking + :math:`F^2 = I_\mathrm{corr} / + (L_\mathrm{stationary}\,C_\mathrm{solid\,angle})`. Unlike a rocking scan, stationary area-detector integration has no rod-interception factor. + ``C_solid_angle`` is present only when the solid-angle correction was + applied to the intensity, and dividing by it removes that correction + again. A region-summed intensity is already the complete angular + integral, with every pixel weighted by the solid angle it subtends, so + the correction double-counts the detector obliquity in a structure + factor -- while remaining useful on the intensity itself for broad, + non-rod features, where a differential cross-section is the goal. See + ``doc/design/ctr_structure_factor_scale.md`` finding F6. + :param intensity: Corrected intensity per image. :param errors: 1-sigma errors of ``intensity``. - :param CorrectionFactors factors: Must contain ``C_Lorentz``. + :param CorrectionFactors factors: Must contain ``C_Lorentz``; divides by + ``C_solid_angle`` as well when it is present. :returns: ``(F2_hkl, F2_hkl_errors)``. :rtype: tuple of numpy.ndarray :raises KeyError: If the Lorentz factors are absent. """ - divisor = factors["C_Lorentz"] + divisor = factors["C_Lorentz"] * factors.divisor("C_solid_angle") return np.asarray(intensity) / divisor, np.asarray(errors) / divisor diff --git a/orgui/app/orGUI.py b/orgui/app/orGUI.py index 6ec5bef..f454a37 100644 --- a/orgui/app/orGUI.py +++ b/orgui/app/orGUI.py @@ -6383,8 +6383,38 @@ def sumImage(i): ) ) + # The solid-angle correction is useful on the *intensity* -- for broad, + # non-rod features a differential cross-section is what is wanted -- + # but it must not reach a structure factor: a region sum is already the + # complete angular integral, each pixel weighted by the solid angle it + # subtends. So it is measured here over the same regions and divided + # back out when F2_hkl is formed. Finding F6 of + # doc/design/ctr_structure_factor_scale.md; the per-pixel + # reconstruction keeps it, because that path does form a differential + # cross-section. + solid_angle_means = (None, None) + if self.scanSelector.useSolidAngleBox.isChecked(): + solid_angle_means = tuple( + detector_corrections.roi_mean_inverse_solid_angle( + dc, + row, + column, + # A frame whose region fell off the detector can leave a + # degenerate size behind; it carries no counts either, so + # one pixel keeps the factor defined and harmless. + np.maximum(row_size, 1), + np.maximum(column_size, 1), + ) + for row, column, row_size, column_size in ( + (y_coord1_a, x_coord1_a, roi_vsize1_a, roi_hsize1_a), + (y_coord2_a, x_coord2_a, roi_vsize2_a, roi_hsize2_a), + ) + ) + correction_factors = [] - for hkl_del_gam in (hkl_del_gam_1, hkl_del_gam_2): + for hkl_del_gam, solid_angle_mean in zip( + (hkl_del_gam_1, hkl_del_gam_2), solid_angle_means + ): correction_factors.append( integration_corrections.stationary_correction_factors( alpha_all, @@ -6395,6 +6425,7 @@ def sumImage(i): beam_profile=beam_profile, sample_size=sample_size, normalization=normalization, + solid_angle_mean=solid_angle_mean, ) ) factors1, factors2 = correction_factors diff --git a/orgui/app/peak1Dintegr.py b/orgui/app/peak1Dintegr.py index 0a9ee6c..8b99d4c 100644 --- a/orgui/app/peak1Dintegr.py +++ b/orgui/app/peak1Dintegr.py @@ -31,6 +31,7 @@ __maintainer__ = "Timo Fuchs" __email__ = "tfuchs@cornell.edu" +import json import logging import sys import os @@ -59,7 +60,10 @@ from .. import logger_utils from ..datautils.xrayutils.corrections import beamprofile from ..datautils.xrayutils.corrections import ( + acceptance as acceptance_corrections, + detector as detector_corrections, measurement as measurement_corrections, + normalization as normalization_corrections, ) import numpy as np @@ -113,6 +117,10 @@ def _compute_rocking_integration( C_rod=1.0, C_flux_on_sample=1.0, C_illum_area=1.0, + C_norm=1.0, + detector_acceptance=None, + solid_angle_mean=None, + angle_unit="deg", progress_callback=None, should_cancel=None, ): @@ -158,6 +166,32 @@ def _compute_rocking_integration( :param C_illum_area: Scalar ``1.0`` or array of shape ``(n_s, n_pts)``, illuminated-area factor. + :param C_norm: + Scalar ``1.0`` or array of shape ``(n_s, n_pts)``, the per-frame + exposure-time and monitor divisor. It is applied **inside** the + rocking integral, not to the result: with a varying counting time or a + drifting monitor the quantity Vlieg's expression integrates is + :math:`\\int N(\\omega)/(T M)\\,d\\omega`, and dividing the finished + integral by a mean would only be equivalent for constant counters. + :param detector_acceptance: + Out-of-plane acceptance :math:`\\Delta\\gamma` of the region of + interest per ``s`` point, in **radian**, shape ``(n_s,)``. A rocking + scan intercepts a slice of rod proportional to it, so it divides + ``F2_hkl``. ``None`` leaves it out, which reproduces the historical, + acceptance-blind scale. + :param solid_angle_mean: + Region mean of :math:`1/\\widetilde{\\Omega}` per ``s`` point, shape + ``(n_s,)``, when the solid-angle correction is already inside the + curves. It divides ``F2_hkl``, removing it again: a region sum is the + complete angular integral already, so the correction double-counts the + detector obliquity in a structure factor even though it is what a + broad, non-rod feature wants on its intensity. ``None`` when the + correction was not applied. + :param str angle_unit: + Unit of ``axis``, ``'deg'`` or ``'rad'``. The published expressions + integrate the rocking angle in radian; ``'deg'`` converts the integral + when ``F2_hkl`` is formed, leaving the stored intensities and interval + widths in the unit they were measured in. :param progress_callback: Optional callable invoked with the current ``s`` index after each point is processed. @@ -182,6 +216,7 @@ def _compute_rocking_integration( "raw_cnts": [], "raw_cnts_errors": [], "int_interval": [], + "C_norm": [], "C_Lor": [], "C_rod": [], "C_flux_on_sample": [], @@ -216,6 +251,17 @@ def _compute_rocking_integration( cnts_errors = croibg_errors[roi_slice] C_corr = np.ones(cnts.size, dtype=float) + if not np.isscalar(C_norm) or C_norm != 1.0: + # Per-frame, so it belongs under the integral sign; see the + # C_norm parameter documentation. + C_norm_roi = np.broadcast_to( + np.asarray(C_norm, dtype=float), croibg_curves.shape + )[i][roi_slice] + int_data[roikey]["C_norm"].append(np.mean(C_norm_roi)) + C_corr = C_corr * C_norm_roi + else: + int_data[roikey]["C_norm"].append(1.0) + if use_lorentz: int_data[roikey]["C_Lor"].append(np.mean(C_Lor[i][roi_slice])) int_data[roikey]["C_rod"].append(np.mean(C_rod[i][roi_slice])) @@ -404,8 +450,26 @@ def _compute_rocking_integration( result["auxil"] = auxil if use_lorentz: - result["F2_hkl"] = croibg / (C_Lorentz * C_rod_intersect) - result["F2_hkl_errors"] = croibg_errors / (C_Lorentz * C_rod_intersect) + # The rocking angle is the integration variable and must be in radian + # (Vlieg eq. 42, Drnec eq. 2). The exposure and monitor divisor is + # already inside croibg, applied per frame above, so only the angle + # conversion is left for normalized_intensity to do here. + intensity = measurement_corrections.normalized_intensity( + croibg, angle_unit=angle_unit + ) + intensity_errors = measurement_corrections.normalized_intensity( + croibg_errors, angle_unit=angle_unit + ) + # The mode components are interval-weighted means over the rocking + # interval, which is why they are multiplied here rather than asking + # measurement.angular_factor to rebuild eta from point angles. + denominator = C_Lorentz * C_rod_intersect + if detector_acceptance is not None: + denominator = denominator * np.asarray(detector_acceptance, dtype=float) + if solid_angle_mean is not None: + denominator = denominator * np.asarray(solid_angle_mean, dtype=float) + result["F2_hkl"] = intensity / denominator + result["F2_hkl_errors"] = intensity_errors / denominator return result @@ -1304,6 +1368,184 @@ def fit_anchors_along_rod(self): roih5grp[roikey]["to"][:] = to_ar self.plotRoCurve(self._idx) + def _rocking_normalization(self, aux, size): + """Per-frame exposure and monitor divisor of the rocking scan. + + Unlike the stationary path this cannot ask a live scan object: a + rocking integration runs off the database, so the counters are read + from the ``auxillary`` group that + :meth:`orgui.app.orGUI.orGUI.rocking_integrate` copied there. Which + counters count as a monitor is the same setting the stationary + integration and the reconstruction use, so all three normalize + identically. + + A counter that is simply not there is not an error -- a backend that + does not declare ``exposure_time`` in ``auxillary_counters`` never + stored one. The names of the factors that did apply are returned so + they can be saved beside ``F2_hkl``; without them a rod cannot be put + on a common scale after the fact. + + :param dict aux: Auxiliary counters, each of shape ``(n_pts,)``. + :param int size: Number of frames of the rocking scan. + :returns: ``(divisor, applied)`` with the divisor of shape + ``(size,)``. + :rtype: tuple + """ + config_target = self.database.config_target + monitor_names = tuple( + getattr(config_target, "reconstruction_monitor_corrections", ()) or () + ) + + exposure = aux.get("exposure_time") + if exposure is None: + logger.warning( + "The rocking scan stores no exposure_time counter, so the " + "integrated intensities are not normalized to counting time. " + "They are then only comparable to other scans of the same " + "duration. The scan backend decides this by declaring " + "'exposure_time' in auxillary_counters." + ) + + monitors = {} + for name in monitor_names: + if name in aux: + monitors[name] = aux[name] + else: + logger.warning( + "Monitor counter %r is configured but was not stored with " + "this rocking scan; skipping it.", + name, + ) + + return normalization_corrections.normalization_divisor( + size, exposure_time=exposure, monitors=monitors + ) + + def _rocking_solid_angle_mean(self, scangroup, cnters, x, y): + """Region mean of the solid-angle correction that was applied, or None. + + The solid-angle correction is applied to the *intensity* when the + rocking curves are extracted, which is useful there: for a broad, + non-rod feature a differential cross-section is what is wanted. It + must not reach a structure factor, though, because a region sum is + already the complete angular integral with every pixel weighted by the + solid angle it subtends. So it is measured over the same regions here + and divided back out of ``F2_hkl``. See + ``doc/design/ctr_structure_factor_scale.md`` finding F6. + + Whether it was applied is a property of the *extraction*, not of the + switches in this dialog, so it is read from the configuration snapshot + stored with the scan rather than from the current GUI state. + + :param scangroup: The scan group holding the ``configuration`` written + when the rocking curves were extracted. + :param cnters: The ``rois`` group of the rocking scan. + :param x: Region centre column per ``s`` point, in pixels. + :param y: Region centre row per ``s`` point, in pixels. + :returns: ``(mean, applied)`` -- the per-``s`` mean of + :math:`1/\\widetilde{\\Omega}` and whether it will be divided out, + or ``(None, False)`` when the correction was not applied or cannot + be established. + :rtype: tuple + """ + try: + raw = scangroup["configuration/orgui/integration_corrections/json"][()] + if isinstance(raw, bytes): + raw = raw.decode() + was_applied = bool(json.loads(str(raw)).get("use_solid_angle", False)) + except Exception: + logger.warning( + "Cannot tell from this scan's stored configuration whether the " + "solid angle correction was applied when the rocking curves " + "were extracted, so it is not divided out of F2_hkl. If it was " + "applied, F2_hkl carries the detector obliquity and will not " + "agree with a stationary integration of the same rod." + ) + return None, False + + if not was_applied: + return None, False + + config_target = self.database.config_target + detector = getattr(getattr(config_target, "ubcalc", None), "detectorCal", None) + if detector is None: + logger.warning( + "The solid angle correction was applied to these rocking " + "curves, but no calibrated detector is available to measure it " + "over the regions of interest, so it is not divided out of " + "F2_hkl." + ) + return None, False + + hsize = np.asarray(cnters["hsize"][()], dtype=float) + vsize = np.asarray(cnters["vsize"][()], dtype=float) + if hsize.ndim > 1: + hsize = hsize[:, 0] + if vsize.ndim > 1: + vsize = vsize[:, 0] + + mean = detector_corrections.roi_mean_inverse_solid_angle( + detector, + np.asarray(y, dtype=float), + np.asarray(x, dtype=float), + np.maximum(vsize, 1.0), + np.maximum(hsize, 1.0), + ) + return np.asarray(mean, dtype=float), True + + def _rocking_acceptance(self, cnters, x, y): + """Out-of-plane acceptance of every region of interest, in radian. + + A rocking scan intercepts a slice of rod proportional to + :math:`\\Delta\\gamma` (Vlieg equation 20), so ``F2_hkl`` is only on + the same scale as a stationary measurement once it is divided by it. + + The region centre and its vertical size are stored per ``s`` point. + Note the coordinate order: ``surfaceAnglesPoint`` takes pyFAI + dimension 1 first, which is the detector *row*, and orGUI's ``y`` is + the row while ``x`` is the column -- every call site in the + application passes them in that swapped order. + + The acceptance is evaluated at the **calibrated** arm position. The + span a region subtends is invariant under an arm rotation to nine + digits, because that rotation is about the very axis + :math:`\\gamma` is measured around, so this costs nothing measurable; + see ``doc/design/ctr_structure_factor_scale.md`` section 4.4. + + :param cnters: The ``rois`` group of the rocking scan. + :param x: Region centre column per ``s`` point, in pixels. + :param y: Region centre row per ``s`` point, in pixels. + :returns: ``(acceptance, applied)`` -- the acceptance in radian of + shape ``(n_s,)``, or ``(None, False)`` when no calibrated + detector is reachable. + :rtype: tuple + """ + config_target = self.database.config_target + detector = getattr(getattr(config_target, "ubcalc", None), "detectorCal", None) + if detector is None: + logger.warning( + "No calibrated detector is available, so the out-of-plane " + "acceptance of the regions of interest cannot be calculated. " + "F2_hkl is left on the acceptance-blind scale and will not " + "agree with a stationary integration of the same rod." + ) + return None, False + + vsize = cnters["vsize"][()] + vsize = np.asarray(vsize, dtype=float) + if vsize.ndim > 1: + vsize = vsize[:, 0] + alpha_pk = np.deg2rad(np.asarray(cnters["alpha_pk"][()], dtype=float)) + + acceptance = acceptance_corrections.out_of_plane_acceptance( + detector, + np.asarray(y, dtype=float), + np.asarray(x, dtype=float), + vsize, + alpha_pk, + ) + return np.asarray(acceptance, dtype=float), True + def integrate(self): """Integrate rocking-scan ROIs. @@ -1367,6 +1609,36 @@ def integrate(self): C_flux_on_sample = 1.0 C_illum_area = 1.0 + if cnters["x"].ndim > 1: + warnings.warn( + "You are using an old data base orGUI v1.3.0-alpha" + "X and Y pixel coordinates of rocking scans will be incorrect" + ) + x = cnters["x"][:, 0][ + () + ] # may provide fix of database here, if anyone asks + y = cnters["y"][:, 0][ + () + ] # may provide fix of database here, if anyone asks + else: + x = cnters["x"][()] + y = cnters["y"][()] + + C_norm, normalization_applied = self._rocking_normalization(aux, axis.size) + C_norm = np.broadcast_to(C_norm, np.shape(curves["croibg"])).copy() + + # Only F2_hkl is divided by these, so asking for them with the + # Lorentz switch off would warn about a detector nothing needs. + detector_acceptance, acceptance_applied = None, False + solid_angle_mean, solid_angle_compensated = None, False + if self.lorentzButton.isChecked(): + detector_acceptance, acceptance_applied = self._rocking_acceptance( + cnters, x, y + ) + solid_angle_mean, solid_angle_compensated = ( + self._rocking_solid_angle_mean(scangroup, cnters, x, y) + ) + self.database.nxfile[self._currentRoInfo["name"] + "/integration/"] roi_info = h5todict( self.database.nxfile, self._currentRoInfo["name"] + "/integration/" @@ -1389,6 +1661,10 @@ def integrate(self): C_rod=C_rod, C_flux_on_sample=C_flux_on_sample, C_illum_area=C_illum_area, + C_norm=C_norm, + detector_acceptance=detector_acceptance, + solid_angle_mean=solid_angle_mean, + angle_unit="deg", progress_callback=progress.update, should_cancel=progress.wasCanceled, ) @@ -1448,21 +1724,6 @@ def integrate(self): i += 1 availname1 = name1 + suffix - if cnters["x"].ndim > 1: - warnings.warn( - "You are using an old data base orGUI v1.3.0-alpha" - "X and Y pixel coordinates of rocking scans will be incorrect" - ) - x = cnters["x"][:, 0][ - () - ] # may provide fix of database here, if anyone asks - y = cnters["y"][:, 0][ - () - ] # may provide fix of database here, if anyone asks - else: - x = cnters["x"][()] - y = cnters["y"][()] - datas1 = { "@NX_class": "NXdata", "sixc_angles": { @@ -1522,6 +1783,25 @@ def integrate(self): measurement[availname1]["counters"]["F2_hkl"] = F2_hkl measurement[availname1]["counters"]["F2_hkl_errors"] = F2_hkl_errors measurement[availname1]["@signal"] = "counters/F2_hkl" + # What the reduction actually divided out. A saved rod cannot be + # put on a common scale with another scan after the fact without + # this, and which factors were available depends on the scan. + reduction = { + "@NX_class": "NXcollection", + "@mode": mode, + "@angle_unit": "rad", + "@normalization_applied": ",".join(normalization_applied) or "none", + "@acceptance_applied": bool(acceptance_applied), + "@solid_angle_compensated": bool(solid_angle_compensated), + "@active_area_applied": bool(self.footprintButton.isChecked()), + } + if detector_acceptance is not None: + reduction["detector_acceptance"] = detector_acceptance + reduction["@detector_acceptance_unit"] = "rad" + if self.footprintButton.isChecked(): + reduction["sample_size"] = L + reduction["@sample_size_unit"] = "m" + measurement[availname1]["reduction"] = reduction self.database.add_nxdict( measurement, diff --git a/orgui/app/test/test_peak1Dintegr.py b/orgui/app/test/test_peak1Dintegr.py index 36b8b37..a36b1e7 100644 --- a/orgui/app/test/test_peak1Dintegr.py +++ b/orgui/app/test/test_peak1Dintegr.py @@ -13,9 +13,15 @@ saved rocking intensities and uncertainties from regression. """ +from types import SimpleNamespace + import numpy as np -from orgui.app.peak1Dintegr import _compute_rocking_integration, _trapz_impl +from orgui.app.peak1Dintegr import ( + RockingPeakIntegrator, + _compute_rocking_integration, + _trapz_impl, +) def _piecewise_curve(axis, regions, background=0.0): @@ -291,3 +297,209 @@ def test_integrated_error_is_finite_when_raw_signal_integral_is_zero(): ) assert np.all(np.isfinite(result["croibg_errors"])), result["croibg_errors"] + + +def test_normalization_is_applied_inside_the_rocking_integral(): + """A varying counting time must be divided out per frame. + + Vlieg's rocking expression integrates ``N(omega)/(T M)`` over the rocking + angle, so the divisor belongs under the integral sign. Dividing the + finished integral by a mean counting time is only the same thing when the + counting time is constant, and a scan whose exposure drifts is exactly the + case the normalization exists for. + + The curve is flat at ``10`` and the exposure ramps as ``T = 2 + omega``, + which makes both readings closed forms rather than a re-implementation of + the code: + + * per frame, as required: + ``Int 10/(2+omega) domega = 10 ln(2.4/1.6)`` over ``[-0.4, 0.4]`` + * divided afterwards by the mean exposure, which must not be what comes + out: ``10 * 0.8 / 2 = 4`` exactly, since the mean of ``T`` over a + symmetric interval is 2. + + They differ by 1.4 %, so the wrong one cannot pass. + """ + axis = np.linspace(-1.0, 1.0, 401) + curve = _piecewise_curve(axis, [(-0.5, 0.5, 10.0)]) + exposure = 2.0 + axis + + result = _compute_rocking_integration( + np.array([0.0]), + axis, + curve[None, :], + np.sqrt(curve)[None, :], + _roi_info(0.0, {"sig_1": (-0.4, 0.4)}), + {}, + False, + False, + C_norm=exposure[None, :], + angle_unit="rad", + ) + + per_frame = 10.0 * np.log(2.4 / 1.6) + np.testing.assert_allclose(result["croibg"], per_frame, rtol=1e-5) + assert not np.isclose(per_frame, 4.0, rtol=1e-3) + + +def test_the_rocking_integral_is_converted_to_radian_for_f2(): + """``F2_hkl`` carries the radian integral; the stored intensity does not. + + The published expressions integrate the rocking angle in radian, but the + motor axis is in degrees and ``croibg`` is kept in the unit it was + measured in, so the ``180/pi`` shows up as the ratio between them. + """ + axis = np.linspace(-1.0, 1.0, 401) + curve = _piecewise_curve(axis, [(-0.5, 0.5, 10.0)]) + ones = np.ones((1, axis.size)) + + result = _compute_rocking_integration( + np.array([0.0]), + axis, + curve[None, :], + np.sqrt(curve)[None, :], + _roi_info(0.0, {"sig_1": (-0.4, 0.4)}), + {}, + True, + False, + C_Lor=ones, + C_rod=ones, + angle_unit="deg", + ) + + np.testing.assert_allclose( + result["F2_hkl"] / result["croibg"], np.deg2rad(1.0), rtol=1e-12 + ) + + +def test_the_acceptance_divides_f2_and_only_f2(): + """``Delta_gamma`` scales the structure factor, not the intensity. + + The intensity column stays the measured integral; the acceptance is part + of forming ``F2_hkl``, because it is the rod slice the region intercepted + rather than anything about the counts. + """ + axis = np.linspace(-1.0, 1.0, 401) + curve = _piecewise_curve(axis, [(-0.5, 0.5, 10.0)]) + ones = np.ones((1, axis.size)) + acceptance = np.array([np.deg2rad(0.4)]) + + common = dict( + C_Lor=ones, + C_rod=ones, + angle_unit="rad", + ) + args = ( + np.array([0.0]), + axis, + curve[None, :], + np.sqrt(curve)[None, :], + _roi_info(0.0, {"sig_1": (-0.4, 0.4)}), + {}, + True, + False, + ) + without = _compute_rocking_integration(*args, **common) + with_it = _compute_rocking_integration( + *args, detector_acceptance=acceptance, **common + ) + + np.testing.assert_allclose(with_it["croibg"], without["croibg"], rtol=1e-12) + np.testing.assert_allclose( + with_it["F2_hkl"] * acceptance, without["F2_hkl"], rtol=1e-12 + ) + np.testing.assert_allclose( + with_it["F2_hkl_errors"] * acceptance, without["F2_hkl_errors"], rtol=1e-12 + ) + + +class _RowOnlyDetector: + """A detector whose exit angle depends only on pyFAI dimension 1. + + ``surfaceAnglesPoint`` takes the *row* first, so a gamma built from its + first argument alone is a direct probe of whether the caller got the + coordinate order right. + """ + + PER_ROW = 1e-4 + + def surfaceAnglesPoint(self, x, y, alpha_i, gamma_arm=None, delta_arm=None): + gamma = self.PER_ROW * np.asarray(x, dtype=float) + return gamma, np.zeros_like(gamma) + + +def _stub(detector=None, monitors=()): + """Minimal stand-in for the parts of the integrator the helpers touch.""" + ubcalc = None if detector is None else SimpleNamespace(detectorCal=detector) + config_target = SimpleNamespace( + ubcalc=ubcalc, reconstruction_monitor_corrections=tuple(monitors) + ) + return SimpleNamespace(database=SimpleNamespace(config_target=config_target)) + + +def test_the_acceptance_reads_the_row_from_y_and_the_column_from_x(): + """orGUI's ``y`` is the detector row, and pyFAI takes the row first. + + ``detvsize, dethsize = detector.shape`` at ``orGUI.py:1096`` fixes ``y`` + as the row and ``vsize`` as its extent, and every ``surfaceAnglesPoint`` + call in the application passes the two coordinates swapped. Getting this + backwards yields a plausible but wrong acceptance, so it is pinned here: + the result must follow ``vsize`` and ``y`` and ignore ``x``. + """ + detector = _RowOnlyDetector() + vsize = np.array([10.0, 40.0, 80.0]) + cnters = { + "vsize": vsize, + "alpha_pk": np.zeros(3), + } + + acceptance, applied = RockingPeakIntegrator._rocking_acceptance( + _stub(detector), cnters, x=np.array([5.0, 300.0, 470.0]), y=np.full(3, 250.0) + ) + + assert applied is True + np.testing.assert_allclose(acceptance, vsize * detector.PER_ROW, rtol=1e-12) + + +def test_a_missing_detector_leaves_the_acceptance_out_rather_than_failing(): + """CLI use without a calibration must not lose the integration.""" + cnters = {"vsize": np.array([40.0]), "alpha_pk": np.zeros(1)} + + acceptance, applied = RockingPeakIntegrator._rocking_acceptance( + _stub(None), cnters, x=np.array([100.0]), y=np.array([200.0]) + ) + + assert acceptance is None + assert applied is False + + +def test_the_rocking_normalization_uses_the_stored_counters(): + """Exposure and the configured monitors multiply into one divisor.""" + aux = { + "exposure_time": np.array([2.0, 4.0]), + "mondio": np.array([10.0, 5.0]), + "unused": np.array([7.0, 7.0]), + } + + divisor, applied = RockingPeakIntegrator._rocking_normalization( + _stub(monitors=("mondio",)), aux, 2 + ) + + np.testing.assert_allclose(divisor, [20.0, 20.0], rtol=1e-12) + assert applied == ["exposure", "monitor:mondio"] + + +def test_a_missing_exposure_counter_is_skipped_and_recorded(): + """A backend that declares no exposure_time still integrates. + + ``P212_tools`` and the base ``Scan`` return an empty + ``auxillary_counters``, so nothing was stored to normalize by. That is a + scale the user has to know about, not a reason to fail the job, and + ``applied`` is what records it. + """ + divisor, applied = RockingPeakIntegrator._rocking_normalization( + _stub(monitors=("mondio",)), {}, 3 + ) + + np.testing.assert_allclose(divisor, np.ones(3), rtol=1e-12) + assert applied == [] diff --git a/orgui/app/test/test_scan_mode_equivalence.py b/orgui/app/test/test_scan_mode_equivalence.py index 54bb086..175c457 100644 --- a/orgui/app/test/test_scan_mode_equivalence.py +++ b/orgui/app/test/test_scan_mode_equivalence.py @@ -145,15 +145,39 @@ def _roi_info(size): } -def _orgui_rocking_f2(rod, acceptance): - """``F2_hkl`` as :mod:`orgui.app.peak1Dintegr` computes it today.""" +def _orgui_rocking_f2(rod, acceptance, reduce=True, solid_angle_mean=None): + """``F2_hkl`` as :mod:`orgui.app.peak1Dintegr` computes it. + + :param rod: The ``rod`` fixture. + :param acceptance: Out-of-plane acceptance per rod point, in radian. It + both shapes the simulated measurement and, when ``reduce`` is set, is + divided back out. + :param bool reduce: Pass the exposure/monitor divisor, the acceptance and + the radian conversion, as :meth:`RockingPeakIntegrator.integrate` + does. ``False`` reproduces the historical scale, which is what the + distortion test below needs to compare against. + """ ell, alpha, delta, gamma, _, _, _ = rod axis, curves = _simulate_rocking(rod, acceptance) + if solid_angle_mean is not None: + # As the per-pixel correction does when the switch is on: it scales + # the counts, region by region. + curves = curves * np.asarray(solid_angle_mean)[:, None] shape = curves.shape lorentz = np.broadcast_to( (1.0 / (np.sin(delta) * np.cos(alpha) * np.cos(gamma)))[:, None], shape ) rod_interception = np.broadcast_to(np.cos(gamma)[:, None], shape) + extra = {} + if reduce: + extra = dict( + C_norm=np.full(shape, ROCKING_EXPOSURE * ROCKING_MONITOR), + detector_acceptance=acceptance, + solid_angle_mean=solid_angle_mean, + angle_unit="deg", + ) + else: + extra = dict(angle_unit="rad") # no conversion: integrate as measured result = _compute_rocking_integration( ell, axis, @@ -165,14 +189,23 @@ def _orgui_rocking_f2(rod, acceptance): False, C_Lor=lorentz, C_rod=rod_interception, + **extra, ) return result["F2_hkl"] -def _orgui_stationary_f2(rod): - """``F2_hkl`` as :mod:`orgui.app.integration_corrections` computes it.""" +def _orgui_stationary_f2(rod, solid_angle_mean=None): + """``F2_hkl`` as :mod:`orgui.app.integration_corrections` computes it. + + :param solid_angle_mean: When given, the region-mean solid-angle + correction is applied to the counts, as the per-pixel array does when + the switch is on, and handed to the reduction so that it comes back + out of the structure factor. + """ ell, alpha, delta, gamma, _, _, _ = rod counts = _simulate_stationary(rod) + if solid_angle_mean is not None: + counts = counts * np.asarray(solid_angle_mean) factors = ic.stationary_correction_factors( alpha, delta, @@ -181,6 +214,7 @@ def _orgui_stationary_f2(rod): normalization=np.full( ell.size, STATIONARY_EXPOSURE * STATIONARY_MONITOR ), + solid_angle_mean=solid_angle_mean, ) intensity, errors = ic.apply_stationary_corrections( counts, np.sqrt(counts), factors @@ -260,46 +294,64 @@ def test_the_stationary_path_recovers_the_rod_up_to_one_constant(rod): np.testing.assert_allclose(ratio, ratio[0], rtol=1e-12) -def test_rocking_and_stationary_paths_differ_by_the_missing_normalizations(rod): - """The gap between the two paths, pinned to the factor it is. +def test_rocking_and_stationary_paths_agree(rod): + """The two integration paths of orGUI now land on one scale. - With the acceptance held constant the two paths differ by exactly - ``exposure * monitor * Delta_gamma_in_degrees``: the rocking path applies - neither the exposure/monitor normalization nor the out-of-plane - acceptance, and integrates the rocking angle in degrees rather than - radian. The degree-to-radian factor and the acceptance combine into the - acceptance expressed in degrees. + This is issue #82. The same rod, simulated as a rocking scan and as a + stationary scan with *different* counting times and monitors, goes through + the two real integration paths and comes out with the same ``F2_hkl``. - This test characterizes today's behavior. It must be updated -- to a - plain equality -- when the rocking path adopts the unified reduction. + This test used to characterize the gap between the paths, which was + exactly ``exposure * monitor * Delta_gamma_in_degrees`` -- the rocking + path applied neither the exposure/monitor normalization nor the + out-of-plane acceptance, and integrated the rocking angle in degrees. + Those three are the wiring this asserts is in place; the factor they used + to leave behind is kept below as the thing that must not come back. """ ell = rod[0] acceptance = np.full(ell.size, np.deg2rad(0.35)) - ratio = _orgui_rocking_f2(rod, acceptance) / _orgui_stationary_f2(rod) - expected = ROCKING_EXPOSURE * ROCKING_MONITOR * np.rad2deg(acceptance) + rocking = _orgui_rocking_f2(rod, acceptance) + stationary = _orgui_stationary_f2(rod) + + np.testing.assert_allclose(rocking / stationary, 1.0, rtol=1e-6) - np.testing.assert_allclose(ratio, expected, rtol=1e-6) + # The historical scale, for contrast: without the reduction the rocking + # path overshoots by the acceptance expressed in degrees times the + # counting time and monitor it never divided out. + historical = _orgui_rocking_f2(rod, acceptance, reduce=False) + gap = ROCKING_EXPOSURE * ROCKING_MONITOR * np.rad2deg(acceptance) + np.testing.assert_allclose(historical / stationary, gap, rtol=1e-6) -def test_a_resized_region_of_interest_distorts_the_rocking_rod(rod): - """The missing acceptance is not merely an overall scale factor. +def test_a_resized_region_of_interest_no_longer_distorts_the_rocking_rod(rod): + """The acceptance divisor removes a *shape* error, not just a scale one. :func:`orgui.app.ROIutils.calc_corrections` sizes regions of interest from the projected sample size and the parallax at each detector position, so their out-of-plane acceptance changes along a scan. Without the ``1/Delta_gamma`` divisor that change is carried straight into - ``F2_hkl``, so the same rod measured with a resized region of interest - comes out with a different *shape*, not just a different scale. + ``F2_hkl`` and the same rod comes out with a different shape; with it the + rod is independent of how the regions were sized. + + Both halves are asserted, because the second is what makes the first + worth having: a factor 2.3 of distortion across this rod, cured. """ ell = rod[0] fixed = np.full(ell.size, np.deg2rad(0.35)) resized = np.deg2rad(0.35) * np.linspace(0.7, 1.6, ell.size) - with_fixed = _orgui_rocking_f2(rod, fixed) - with_resized = _orgui_rocking_f2(rod, resized) + # With the reduction: the resized regions give the same rod. + np.testing.assert_allclose( + _orgui_rocking_f2(rod, resized) / _orgui_rocking_f2(rod, fixed), + 1.0, + rtol=1e-6, + ) - carried = with_resized / with_fixed + # Without it: the region sizing leaks into the rod shape. + carried = _orgui_rocking_f2(rod, resized, reduce=False) / _orgui_rocking_f2( + rod, fixed, reduce=False + ) shape_change = carried / carried[0] np.testing.assert_allclose( shape_change, np.linspace(0.7, 1.6, ell.size) / 0.7, rtol=1e-6 @@ -326,3 +378,41 @@ def test_the_stationary_path_assumes_a_slit_independent_active_area(rod): spread = ratio.max() / ratio.min() - 1.0 assert spread > 1e-3, "delta must vary enough along the rod to see this" + + +def test_the_solid_angle_correction_does_not_reach_the_structure_factor(rod): + """Switching it on changes the intensity but not ``F2_hkl``, in both modes. + + The detector solid-angle correction stays available because it is the + right thing for a broad, non-rod feature, where a differential cross + section is what is wanted. For a rod it is not: a region sum is already + the complete angular integral, each pixel weighted by the solid angle it + subtends. So the reduction divides it back out, and a structure factor is + the same number whether or not the switch was on. + + The factor here varies along the rod, as a real one does -- the obliquity + grows as the reflection moves up the detector -- so a leftover would show + up as a shape error and not merely a scale. + """ + ell = rod[0] + acceptance = np.full(ell.size, np.deg2rad(0.35)) + solid_angle = 1.0 + 0.07 * np.linspace(0.0, 1.0, ell.size) + + np.testing.assert_allclose( + _orgui_stationary_f2(rod, solid_angle_mean=solid_angle), + _orgui_stationary_f2(rod), + rtol=1e-12, + ) + np.testing.assert_allclose( + _orgui_rocking_f2(rod, acceptance, solid_angle_mean=solid_angle), + _orgui_rocking_f2(rod, acceptance), + rtol=1e-12, + ) + + # And the modes still agree with the correction enabled. + np.testing.assert_allclose( + _orgui_rocking_f2(rod, acceptance, solid_angle_mean=solid_angle) + / _orgui_stationary_f2(rod, solid_angle_mean=solid_angle), + 1.0, + rtol=1e-6, + ) diff --git a/orgui/datautils/xrayutils/corrections/detector.py b/orgui/datautils/xrayutils/corrections/detector.py index 6d5d686..77f45f8 100644 --- a/orgui/datautils/xrayutils/corrections/detector.py +++ b/orgui/datautils/xrayutils/corrections/detector.py @@ -55,7 +55,7 @@ import numpy as np -__all__ = ["pixel_factors"] +__all__ = ["pixel_factors", "roi_mean_inverse_solid_angle"] def pixel_factors(detector, solid_angle=False, polarization=False, shape=None): @@ -87,3 +87,83 @@ def pixel_factors(detector, solid_angle=False, polarization=False, shape=None): ) factor = 1.0 / values if factor is None else factor / values return factor + + +def roi_mean_inverse_solid_angle( + detector, row, column, row_size, column_size, shape=None +): + r"""Mean of :math:`1/\widetilde{\Omega}` over a rectangular region. + + The solid-angle content of the per-pixel correction of + :func:`pixel_factors`, reduced onto one region of interest. A + region-summed intensity that has been divided by the solid angle needs + this factor divided back out before it becomes a structure factor: the + sum over a region is already the complete angular integral, each pixel + weighted by the solid angle it subtends, so the correction double-counts + the detector obliquity there. See + ``doc/design/ctr_structure_factor_scale.md`` finding F6. + + Evaluated over the **nominal** region rectangle, from the calibrated + geometry alone, so it needs no image and can be computed per frame outside + an integration loop. That is deliberately not identical to the mean over + the *valid* pixels that the integration accumulates: the two differ only + where masked pixels correlate with the detector obliquity, and + :math:`\widetilde{\Omega}` varies by well under a percent across one + region. + + .. note:: + + Because :func:`pixel_factors` returns the solid angle and the + polarization as one fused array, the applied region mean is + :math:`\langle 1/(\widetilde{\Omega}P)\rangle` rather than + :math:`\langle 1/\widetilde{\Omega}\rangle\,\langle 1/P\rangle`. + Dividing this factor out therefore leaves the covariance of the two + over the region, which is second order in their variation across it -- + of order :math:`10^{-6}` for a region of a hundred pixels at a metre. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`. + :param row: Region centre row, in pixels along pyFAI dimension 1. Scalar + or one value per frame. + :param column: Region centre column, in pixels along pyFAI dimension 2. + :param row_size: Region height in pixels. + :param column_size: Region width in pixels. + :param shape: Detector shape; taken from the detector when omitted. + :returns: The mean of the reciprocal normalized solid angle, broadcast + over the inputs. A region entirely off the detector contains no pixels + and no counts, and yields ``1.0`` so that it neither rescales nor + invalidates the zero intensity there. + :rtype: numpy.ndarray + :raises ValueError: If a region size is not positive. + """ + solid_angle = np.asarray( + detector.solidAngleArray(shape) if shape is not None + else detector.solidAngleArray(), + dtype=np.float64, + ) + row, column, row_size, column_size = np.broadcast_arrays( + np.asarray(row, dtype=np.float64), + np.asarray(column, dtype=np.float64), + np.asarray(row_size, dtype=np.float64), + np.asarray(column_size, dtype=np.float64), + ) + for name, value in (("height", row_size), ("width", column_size)): + if np.any(value <= 0) or not np.all(np.isfinite(value)): + raise ValueError( + f"the region of interest must have a positive {name} in " + f"pixels; got {value!r}" + ) + + n_rows, n_columns = solid_angle.shape[0], solid_angle.shape[1] + out = np.ones(row.shape, dtype=np.float64) + for index in np.ndindex(*row.shape): + r0 = int(np.floor(row[index] - row_size[index] / 2.0)) + r1 = int(np.ceil(row[index] + row_size[index] / 2.0)) + c0 = int(np.floor(column[index] - column_size[index] / 2.0)) + c1 = int(np.ceil(column[index] + column_size[index] / 2.0)) + block = solid_angle[ + max(r0, 0):min(r1, n_rows), max(c0, 0):min(c1, n_columns) + ] + if block.size: + out[index] = np.mean(1.0 / block) + return out diff --git a/orgui/datautils/xrayutils/test/test_corrections_detector.py b/orgui/datautils/xrayutils/test/test_corrections_detector.py new file mode 100644 index 0000000..d33a47d --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_corrections_detector.py @@ -0,0 +1,136 @@ +"""Regression tests for the per-pixel detector correction factors. + +:mod:`orgui.datautils.xrayutils.corrections.detector` owns the solid-angle and +polarization arrays and the region reduction of the solid-angle part that a +structure factor has to divide back out. See +``doc/design/ctr_structure_factor_scale.md`` finding F6. +""" + +import numpy as np +import pytest + +pyFAI = pytest.importorskip("pyFAI") + +from orgui.datautils.xrayutils import DetectorCalibration # noqa: E402 +from orgui.datautils.xrayutils.corrections import detector as detector_corrections # noqa: E402 + +#: Pixel size and distance of the calibrated test detector, in meter. +PIXEL, DIST = 172e-6, 0.3 +SHAPE = (619, 487) + + +def _calibrated_detector(): + """A real, calibrated area detector, close in so obliquity is visible.""" + det = DetectorCalibration.Detector2D_SXRD() + det.detector = pyFAI.detectors.Detector( + pixel1=PIXEL, pixel2=PIXEL, max_shape=SHAPE + ) + det.poni1 = SHAPE[0] * PIXEL / 2.0 + det.poni2 = SHAPE[1] * PIXEL / 2.0 + det.rot1 = det.rot2 = det.rot3 = 0.0 + det.dist = DIST + det.set_energy(17.7) + det.setAzimuthalReference(np.deg2rad(90.0)) + det.setPolarization(0.0, 1.0) + det.reset() + det._cached_array = {} + return det + + +def test_the_region_mean_is_one_at_normal_incidence(): + """At the point of normal incidence the correction is nearly neutral. + + ``solidAngleArray`` is normalized to that point, so a region centred + there cannot introduce a scale of its own beyond its own curvature: the + off-centre pixels of a 20-pixel region at 0.3 m still reach + :math:`\\theta = 0.33^\\circ`, worth :math:`4\\times10^{-5}`. That + residual is the factor being real rather than an artefact, so it is + bounded here rather than asserted away. + """ + det = _calibrated_detector() + + got = detector_corrections.roi_mean_inverse_solid_angle( + det, SHAPE[0] / 2.0, SHAPE[1] / 2.0, 20.0, 20.0 + ) + + np.testing.assert_allclose(got, 1.0, rtol=1e-4) + assert got > 1.0, "the mean of 1/cos^3 over a region is never below one" + + +def test_the_region_mean_follows_the_obliquity(): + """It is the mean of ``1/cos^3(theta)`` over the region's pixels. + + Written out from the geometry rather than by calling the module back: the + normalized solid angle of a flat detector is ``cos^3`` of the incidence + angle on its face, which is what makes this factor grow away from the + beam centre. + """ + det = _calibrated_detector() + row, column, rows, columns = 560.0, 240.0, 60.0, 40.0 + + got = detector_corrections.roi_mean_inverse_solid_angle( + det, row, column, rows, columns + ) + + r = np.arange(row - rows / 2, row + rows / 2) + c = np.arange(column - columns / 2, column + columns / 2) + dr = (r[:, None] + 0.5) * PIXEL - det.poni1 + dc = (c[None, :] + 0.5) * PIXEL - det.poni2 + theta = np.arctan(np.hypot(dr, dc) / DIST) + expected = np.mean(1.0 / np.cos(theta) ** 3) + + np.testing.assert_allclose(got, expected, rtol=1e-6) + assert got > 1.02, "the test region must be oblique enough to matter" + + +def test_the_region_mean_is_vectorized_over_a_scan(): + """orGUI resizes regions along a scan, so this is normally an array.""" + det = _calibrated_detector() + row = np.array([310.0, 450.0, 560.0]) + size = np.array([20.0, 40.0, 60.0]) + + got = detector_corrections.roi_mean_inverse_solid_angle( + det, row, 240.0, size, size + ) + + assert got.shape == (3,) + # Monotonic away from the beam centre, which is what makes it a shape + # error rather than a scale error when it is left in. + assert got[0] < got[1] < got[2] + + +def test_a_region_off_the_detector_is_neutral(): + """No pixels means no counts; the factor must not scale or poison them.""" + det = _calibrated_detector() + + got = detector_corrections.roi_mean_inverse_solid_angle( + det, -500.0, -500.0, 20.0, 20.0 + ) + + np.testing.assert_allclose(got, 1.0, rtol=0.0) + + +def test_a_region_partly_off_the_detector_uses_the_pixels_it_has(): + """Clipped at the detector edge rather than padded or rejected.""" + det = _calibrated_detector() + + edge = detector_corrections.roi_mean_inverse_solid_angle( + det, 5.0, 240.0, 40.0, 40.0 + ) + + assert np.isfinite(edge) + assert edge > 1.0 + + +def test_a_zero_or_negative_region_is_rejected(): + """An empty region would divide a structure factor by a meaningless mean.""" + det = _calibrated_detector() + + with pytest.raises(ValueError, match="positive height"): + detector_corrections.roi_mean_inverse_solid_angle( + det, 300.0, 240.0, 0.0, 20.0 + ) + with pytest.raises(ValueError, match="positive width"): + detector_corrections.roi_mean_inverse_solid_angle( + det, 300.0, 240.0, 20.0, -5.0 + ) From 46cab907d4e593603230c98823416ef81747a4bf Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Thu, 10 Sep 2026 14:17:21 -0400 Subject: [PATCH 07/33] fix(phys)!: follow the detector arm in the polarization correction BREAKING CHANGE: intensities and F2_hkl change for scans that drive the detector arm, and only those. The per-pixel polarization array is built at the calibrated arm position, which understated the correction by 3 % at a scattering angle of 10 degrees, 10 % at 18 and 33 % at 30; both integration paths now apply a per-frame factor that moves it onto the arm position each frame was measured at. A fixed-arm scan is bit-identical. The reciprocal-space reconstruction applies the polarization per pixel and is unchanged. --- CHANGELOG.md | 23 +++ doc/design/ctr_structure_factor_handover.md | 38 +++-- doc/design/ctr_structure_factor_scale.md | 48 +++++- doc/physics/ctr_structure_factor_physics.tex | 47 ++++-- doc/source/image_integration.rst | 11 ++ doc/source/release_notes.rst | 2 + orgui/app/orGUI.py | 112 +++++++++++++ orgui/app/test/test_orGUI_reflections.py | 58 +++++++ .../xrayutils/corrections/detector.py | 153 +++++++++++++++++- .../test/test_corrections_detector.py | 90 ++++++++++- 10 files changed, 542 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d560da..901d048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ This is the changelog for the software orGUI, written by Timo Fuchs Scientific and analysis additions: +- **The polarization correction now follows the detector arm.** + *This changes saved numbers for scans that move the detector arm, and only + those.* The per-pixel polarization array is built from the calibrated + geometry, which is right only while the arm stays there. On a scan that + drives the arm -- a reflectivity curve, where it follows twice the incidence + angle -- the same pixel looks in a different direction on every frame, and + the calibrated-position value understated the correction by 3 % at a + scattering angle of 10 degrees, 10 % at 18 and 33 % at 30. Both integration + paths now apply a per-frame factor, + ``corrections.detector.polarization_arm_correction``, that moves the + correction onto the arm position each frame was measured at. It is exactly + one while the arm sits at its calibrated position, so a fixed-arm scan is + bit-identical and needs no switch. The factor is a ratio of two region + means rather than a rebuilt per-pixel array, which keeps the cost to a + region-sized evaluation per frame; the polarization is not flat across a + region at a large scattering angle, so the means matter. The detector solid + angle needs no such correction: an arm rotation is a rigid rotation about + the sample, so every pixel keeps its distance and its obliquity to its own + line of sight, and the solid angle is invariant under it exactly. The + reciprocal-space reconstruction applies the polarization per pixel rather + than as a region mean and is unchanged; correcting it there would need the + array rebuilt per frame. + - **Rocking and stationary integration now produce the same structure factor.** *This changes saved numbers in both modes.* A rocking scan and a stationary scan of the same rod previously differed by exactly exposure time times diff --git a/doc/design/ctr_structure_factor_handover.md b/doc/design/ctr_structure_factor_handover.md index f60df74..78da839 100644 --- a/doc/design/ctr_structure_factor_handover.md +++ b/doc/design/ctr_structure_factor_handover.md @@ -1,7 +1,7 @@ # CTR structure-factor scale: implementation status and handover > **Status as of 2026-09-10.** Branch `claude/ctr-structure-factor-9633bc`, -> six commits ahead of `master`, nothing pushed. +> seven commits ahead of `master`, nothing pushed. > > The physics analysis is complete and quantified, and the reduction is now > **wired in**: a rocking scan and a stationary scan of the same rod come out @@ -32,6 +32,11 @@ four are now handled, and the two modes agree to `1e-6` on simulated data — the residual is the trapezoidal sampling of the rocking profile, not a correction factor. +F5, the arm-blind polarization, is fixed too, in its own commit. It is +independent of mode equivalence — it cancels between the modes at the same +reflection — but it is up to a 33 % error on a scan that drives the detector +arm, which is exactly the reflectivity case. + ## 2. What is on the branch | commit | what it did | @@ -40,6 +45,9 @@ correction factor. | `4193c80` | `feat: reduce integrated intensities to \|F_hkl\|^2 on one scale` | | `e25b8df` | `docs: record the rocking/stationary structure-factor scale analysis` | | `6959191` | `feat: estimate the out-of-plane detector acceptance` | +| `ecb3bb5` | `docs: settle F6 and add the structure-factor physics reference` | +| `0af9cb7` | `feat(phys)!: put rocking and stationary integration on one structure-factor scale` | +| *(this one)* | `fix(phys)!: follow the detector arm in the polarization correction` | The first three were split out of one working tree at the end, which required *staged versions* of four files: `peak1Dintegr.py`, `integration_corrections.py`, @@ -56,7 +64,8 @@ orgui/datautils/xrayutils/corrections/ beamprofile.py 1049 beam profile shapes and their integrals activearea.py 190 active area in m^2, slit- and beam-limited acceptance.py 255 Delta_gamma, gamma_range, pixel_acceptance - detector.py 89 per-pixel solid angle and polarization + detector.py 319 per-pixel solid angle and polarization, their + region means, and the polarization arm correction normalization.py 103 counting time and monitor, from values roi.py 93 CorrectionFactors, roi_mean_correction measurement.py 605 mode dispatch, master equation, reflectivity @@ -71,10 +80,10 @@ not merely that they import. | caller | uses the package for | still does its own thing | |---|---|---| -| `orGUI.integrateROI` (stationary) | `pixel_factors`, `mode_components`, `normalization_divisor`, `C_illum_area`, `roi_mean_inverse_solid_angle` | — | -| `orGUI.rocking_integrate` | `pixel_factors` | — | +| `orGUI.integrateROI` (stationary) | `pixel_factors`, `mode_components`, `normalization_divisor`, `C_illum_area`, `roi_mean_inverse_solid_angle`, `polarization_arm_correction` | — | +| `orGUI.rocking_integrate` | `pixel_factors`, `polarization_arm_correction` | — | | `peak1Dintegr.integrate` (rocking) | `mode_components`, `normalization_divisor`, `normalized_intensity`, `out_of_plane_acceptance`, `roi_mean_inverse_solid_angle` | — | -| `reconstruction_job` | `pixel_factors` (solid angle applied, **not** compensated) | own native-fused application, own normalization loop | +| `reconstruction_job` | `pixel_factors` (solid angle applied and **not** compensated; polarization **not** arm-corrected) | own native-fused application, own normalization loop | Still uncalled outside the tests: `measurement.structure_factor_squared`, `measurement.angular_factor` and `activearea.*`. That is deliberate rather @@ -233,10 +242,12 @@ be left behind cannot come back unnoticed. sample size and the beam profile. Everything else — `lambda`, `A_u`, `r_e` — is available. * **Reflectivity comes for free** once `|F|^2` is absolute; - `measurement.reflectivity_from_structure_factor` is the conversion. But fix - **F5** first: a reflectivity scan drives the detector arm, and the - polarization is still evaluated at the calibrated position, which is a 10 % - error at `2theta = 18` degrees and 33 % at 30. + `measurement.reflectivity_from_structure_factor` is the conversion. **F5 is + now fixed**, which mattered most here: a reflectivity scan drives the arm, + and the arm-blind polarization was a 10 % error at `2theta = 18` degrees and + 33 % at 30. The correction applies to the two direct-space integration + paths; the reciprocal-space reconstruction still evaluates the polarization + per pixel at the calibrated position. * **`C_det` (F7)** is the only mechanism that can still break mode equivalence after the wiring, and it cannot be validated on simulated data. It needs the real-data overlap comparison. @@ -277,8 +288,13 @@ Recorded because each cost time and none was obvious in advance. variance propagation. It shares the *definition* (`pixel_factors`) but keeps its own streaming application; that was the right boundary. * **F5 was not fixed while moving the code.** `pixel_factors` reproduces the - historical arm-blind behaviour exactly. Changing it is a numerical fix that - belongs in its own `phys` commit, not smuggled into a refactor. + historical arm-blind behaviour exactly. Changing it was a numerical fix that + belonged in its own `phys` commit, not smuggled into a refactor -- and it + landed as one. Note what it did *not* need: rebuilding the per-pixel array + per frame. Because the integration paths reduce the polarization to a region + mean anyway, the fix is a per-frame ratio of two region means, which is + exactly 1 at the calibrated arm position and so leaves every fixed-arm scan + bit-identical. `pixel_factors` itself is unchanged. * **`meson.build` was not changed.** Its `exclude_directories: ['__pycache__']` only excludes the top-level directory, so 72 stale `cpython-312.pyc` files are sitting in the installed copy. Inert under 3.14, and the repository owner diff --git a/doc/design/ctr_structure_factor_scale.md b/doc/design/ctr_structure_factor_scale.md index 8ef1a18..2a0b4ee 100644 --- a/doc/design/ctr_structure_factor_scale.md +++ b/doc/design/ctr_structure_factor_scale.md @@ -14,10 +14,10 @@ > for absolute reflectivity. Everything below was verified numerically against > the code, not by inspection alone. > -> F1, F2, F3 and F6 are now **applied**, which changed saved numbers in both -> modes; the two paths agree to `1e-6` on simulated data, limited by the -> trapezoidal sampling of the rocking profile. F4, F5 and F7 remain open and -> are described below as they stand. Findings are written in the present tense +> F1, F2, F3, F5 and F6 are now **applied**, which changed saved numbers in +> both modes; the two paths agree to `1e-6` on simulated data, limited by the +> trapezoidal sampling of the rocking profile. F4 and F7 remain open and are +> described below as they stand. Findings are written in the present tense > of the analysis; section 5 says what each one's status is now, and > [`ctr_structure_factor_handover.md`](ctr_structure_factor_handover.md) > section 5 says how each was wired. @@ -207,6 +207,36 @@ its own scattering angle, and the apparent `alpha` dependence of the ANA z-axis expression is only a change of frame. The bug is scoped to scans that move the arm, which is exactly the reflectivity case of section 6. +**Fixed**, as a per-frame multiplicative factor rather than by rebuilding the +array. `detector.polarization_arm_correction` returns +`<1/P_arm> / <1/P_home>` over the region of interest, which is what an +intensity already corrected at the calibrated position has to be multiplied +by, and both integration paths apply it. Three properties made this the +proportionate fix rather than a per-frame rebuild of the full array: + +* It is **exactly 1** when the arm sits at its calibrated reference, because + both evaluations then use the same geometry. A fixed-arm scan is therefore + bit-identical, and no switch or special case is needed to keep it that way. +* It costs a region-sized evaluation per frame instead of a detector-sized + one, capped at a 17x17 sample of the region because the polarization is + smooth across it. A constant arm is evaluated once and broadcast. +* It leaves the fused per-pixel array, the native ROI accumulation and the + stored `Cfactors_croi` untouched. + +Re-measured through the shipped factor, along a specular scan whose arm +follows `gamma = 2 alpha`, it reproduces the table above: `+0.03 %` at +`2theta = 1`, `+0.49 %` at 4, `+3.11 %` at 10, `+10.57 %` at 18 and +`+33.35 %` at 30 degrees. + +Two things it does **not** cover. The polarization and the solid angle are +fused into one array by `pixel_factors`, so the ratio of region means leaves +their covariance over the region - second order in the variation of both +across it. And the **reciprocal-space reconstruction** applies the +polarization per pixel rather than as a region mean, so this factor does not +apply to it; that path would need the array itself rebuilt per frame, which is +a detector-sized evaluation per frame and belongs with the rest of the +reconstruction work in section 7. + ### F6 - solid-angle correction applied to an already-summed ROI For a ROI-summed intensity, the raw sum over pixels *is* the angular integral @@ -492,7 +522,7 @@ along a scan in the first place. each is in [`ctr_structure_factor_handover.md`](ctr_structure_factor_handover.md) section 5, and the equivalence is asserted by `test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_agree`. -Step 6, the real-data check, is the one that remains, together with F4, F5 and +Step 6, the real-data check, is the one that remains, together with F4 and F7. Recorded in the order they had to be done, because the ordering was itself a @@ -585,9 +615,11 @@ Three caveats, all now in the module docstrings: the critical angle, where refraction and multiple scattering take over - which is where the interesting part of a reflectivity curve usually is. Compare against the DWBA machinery already in `CTRdwba.py` there. -2. **A reflectivity scan moves the detector arm**, so F5 bites hardest here: - 10 % at `2theta = 18` degrees, 33 % at 30 degrees. Fix F5 before trusting an - absolute reflectivity. +2. **A reflectivity scan moves the detector arm**, which is where F5 bit + hardest: 10 % at `2theta = 18` degrees, 33 % at 30. That is now corrected + for the two direct-space integration paths, but *not* for the + reciprocal-space reconstruction, which applies the polarization per pixel + (see F5). 3. **Off-specular**, `R` is the fraction of the incident flux scattered into that rod. That is well defined for a truncation rod integrated across its cross-section, but not for diffuse scattering, where only a differential diff --git a/doc/physics/ctr_structure_factor_physics.tex b/doc/physics/ctr_structure_factor_physics.tex index d615f22..04557ed 100644 --- a/doc/physics/ctr_structure_factor_physics.tex +++ b/doc/physics/ctr_structure_factor_physics.tex @@ -170,10 +170,10 @@ \subsection{From a pixel to its scattering angles} \item $\dgam$ (Section~\ref{sec:acceptance}) is a \emph{difference} of Eq.~\eqref{eq:gamma} between two pixel rows, which is only meaningful because $\gamma$ varies across the detector face within one frame. -\item The polarization factor (Section~\ref{sec:notmodelled}) is the one - correction still evaluated on the \emph{home} geometry rather than at the - frame's arm position, which is why it is wrong for a scan that drives the - arm. +\item The polarization factor (Section~\ref{sec:notmodelled}) is built on the + \emph{home} geometry rather than at the frame's arm position, which is why + it needs a per-frame correction for a scan that drives the arm --- and why + the reconstruction, which cannot use that correction, is still wrong there. \end{itemize} \subsection{Units at the boundaries} @@ -650,7 +650,9 @@ \section{Reflectivity} --- which is where much of the interesting part of a reflectivity curve lies. Compare against a DWBA treatment there. \item A reflectivity scan \textbf{drives the detector arm}, which is where the - polarization issue of Section~\ref{sec:notmodelled} bites hardest. + polarization issue of Section~\ref{sec:notmodelled} bit hardest. The two + direct-space integration paths correct for it; a reflectivity reduced from a + reconstructed map does not. \item \textbf{Off-specular}, $R$ is well defined for a truncation rod integrated across its cross-section, but not for diffuse scattering, where only a differential cross-section is meaningful. @@ -676,18 +678,28 @@ \section{What is deliberately not modelled} than a normalization one. It cannot be validated on simulated data --- it needs the overlap region of a rocking and a stationary scan on the same rod. -\item[The polarization at a moving detector arm.] The per-pixel polarization - array is built once, outside the frame loop, from the \emph{home} geometry - --- step 1 of Section~\ref{sec:pixelangles} is skipped, so it is the array - at the calibrated arm position rather than at the frame's. For a fixed arm - that is correct: each pixel already carries its own scattering angle, and - the apparent $\alpha$ dependence of the z-axis expression is only a change - of frame. For a scan that drives the arm it understates the correction: - $3\,\%$ at a scattering angle of $10^{\circ}$, $10\,\%$ at $18^{\circ}$ and - $33\,\%$ at $30^{\circ}$. A per-pixel evaluation that follows the arm - exists and is what such a scan needs. This is the opposite of the acceptance - (Section~\ref{sec:acceptance}), whose \emph{span} survives a moving arm - unchanged. +\item[The polarization at a moving detector arm, in the reconstruction.] The + per-pixel polarization array is built once, outside the frame loop, from the + \emph{home} geometry --- step 1 of Section~\ref{sec:pixelangles} is skipped, + so it is the array at the calibrated arm position rather than at the + frame's. For a fixed arm that is correct: each pixel already carries its own + scattering angle, and the apparent $\alpha$ dependence of the z-axis + expression is only a change of frame. For a scan that drives the arm it + understates the correction: $3\,\%$ at a scattering angle of $10^{\circ}$, + $10\,\%$ at $18^{\circ}$ and $33\,\%$ at $30^{\circ}$. This is the opposite + of the acceptance (Section~\ref{sec:acceptance}), whose \emph{span} survives + a moving arm unchanged, and of the solid angle, which is invariant under it + exactly --- an arm rotation is a rigid rotation about the sample, so every + pixel keeps its distance and its obliquity to its own line of sight. + + The two direct-space integration paths \textbf{correct} for this: they + reduce the polarization to a region mean anyway, so a per-frame ratio of two + region means, $\langle 1/P_{\mathrm{arm}}\rangle / + \langle 1/P_{\mathrm{home}}\rangle$, moves it onto the frame's arm position. + That ratio is exactly $1$ at the calibrated position, so a fixed-arm scan is + untouched. What remains uncorrected is the \textbf{reciprocal-space + reconstruction}, which applies the polarization per pixel rather than as a + region mean and would need the array itself rebuilt per frame. \item[Uncertainty of $\dgam$.] Errors follow the same divisors as the intensities, but a $\dgam$ estimated from the calibrated geometry has an @@ -714,6 +726,7 @@ \section{Summary} Lorentz & $L_\varphi$ / $L_s$ & \code{geometry} & yes & yes \\ Rod interception & $\Crod$ & \code{geometry} & yes & \textbf{no} \\ Polarization & $P$ & \code{detector} & yes & yes \\ +\quad arm correction & -- & \code{detector} & yes & yes \\ Solid angle & $\dOm$ & \code{detector} & \textbf{no} & \textbf{no} \\ Active area / footprint & $A$ & \code{activearea} & yes & yes \\ Area correction & $\Carea$ & \code{geometry} & slits only & slits only \\ diff --git a/doc/source/image_integration.rst b/doc/source/image_integration.rst index 737a033..b86a723 100644 --- a/doc/source/image_integration.rst +++ b/doc/source/image_integration.rst @@ -93,6 +93,17 @@ integration. The current implementation records the applied ROI sizes, reciprocal-space coordinates, detector coordinates, and relevant scan metadata with the integrated intensities. +The **polarization correction follows the detector arm**. Its per-pixel array +is built from the calibrated geometry, which is correct only while the arm +stays there; on a scan that drives the arm the same pixel looks in a different +direction on every frame, and the calibrated-position value understates the +correction by 3 % at a scattering angle of 10 degrees, 10 % at 18 and 33 % at +30. Both integration paths therefore apply a per-frame factor that moves the +correction onto the arm position each frame was measured at. It is exactly one +while the arm is at its calibrated position, so a fixed-arm scan is unchanged. +The reciprocal-space reconstruction applies the polarization per pixel and +does not carry this correction. + The Corrections Dialog ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/source/release_notes.rst b/doc/source/release_notes.rst index 6d85d19..27cd90c 100644 --- a/doc/source/release_notes.rst +++ b/doc/source/release_notes.rst @@ -9,6 +9,8 @@ Unreleased (2026-07-19) Scientific and analysis additions: +- **The polarization correction now follows the detector arm.** *This changes saved numbers for scans that move the detector arm, and only those.* The per-pixel polarization array is built from the calibrated geometry, which is right only while the arm stays there. On a scan that drives the arm -- a reflectivity curve, where it follows twice the incidence angle -- the same pixel looks in a different direction on every frame, and the calibrated-position value understated the correction by 3 % at a scattering angle of 10 degrees, 10 % at 18 and 33 % at 30. Both integration paths now apply a per-frame factor, ``corrections.detector.polarization_arm_correction``, that moves the correction onto the arm position each frame was measured at. It is exactly one while the arm sits at its calibrated position, so a fixed-arm scan is bit-identical and needs no switch. The factor is a ratio of two region means rather than a rebuilt per-pixel array, which keeps the cost to a region-sized evaluation per frame; the polarization is not flat across a region at a large scattering angle, so the means matter. The detector solid angle needs no such correction: an arm rotation is a rigid rotation about the sample, so every pixel keeps its distance and its obliquity to its own line of sight, and the solid angle is invariant under it exactly. The reciprocal-space reconstruction applies the polarization per pixel rather than as a region mean and is unchanged; correcting it there would need the array rebuilt per frame. + - **Rocking and stationary integration now produce the same structure factor.** *This changes saved numbers in both modes.* A rocking scan and a stationary scan of the same rod previously differed by exactly exposure time times monitor times the out-of-plane acceptance in degrees; they now agree. Four corrections changed. The rocking path gained the per-frame exposure and monitor normalization, applied inside the rocking integral so that a varying counting time or a drifting monitor is handled correctly rather than only on average; it now integrates the rocking angle in radian as the published expressions require, rather than in degrees; and it divides by the out-of-plane acceptance of its region of interest, without which a rod measured with regions resized along the scan came out with a distorted *shape* -- a factor 2.3 across a simulated Pt(111) rod -- and not merely a wrong scale. Separately, the detector **solid-angle correction no longer reaches a structure factor** in either mode: summing a region of interest already yields the complete angular integral, with every pixel weighted by the solid angle it subtends, so dividing by that solid angle again double-counted the detector obliquity (0.7 % for a detector at 1 m, 7 % at 0.3 m, varying across the detector and therefore a rod shape error). The switch stays, because that correction is the right one for a broad or diffuse feature where a differential cross section is wanted: it still scales the intensity counters, and is now measured over the same regions of interest and divided back out when ``F2_hkl`` is formed, so a structure factor is the same number whether or not it was enabled. Its tooltip and the ``SOLA`` status badge say so. The reciprocal-space reconstruction keeps applying it uncompensated, since that path does form a differential cross section per pixel. For rocking scans the correction is applied when the curves are extracted, so whether to remove it again is read from the configuration stored with the scan; an older database where that cannot be established warns and is left uncompensated. Integrated rocking scans now store a ``reduction`` group beside ``F2_hkl`` recording the mode, the angle unit, which normalizations were applied, the acceptance used, whether the solid-angle correction was compensated, and the active-area assumption, so that a saved rod can be placed on a common scale afterwards. Rocking normalization uses the counters stored with the scan, so it requires a backend that declares ``exposure_time`` in ``auxillary_counters``; a missing counter is skipped and recorded rather than failing the integration. Existing configuration files load unchanged. - **All correction factors collected into one package.** Every factor between detector counts and a structure factor now lives in ``orgui.datautils.xrayutils.corrections``, split by what it depends on: ``geometry`` (the z-axis Lorentz, rod-interception and area table), ``beamprofile``, ``activearea``, ``detector`` (per-pixel solid angle and polarization), ``normalization`` (counting time and monitor), ``roi``, and ``measurement``. The rocking integration, the stationary integration and the reciprocal-space reconstruction previously each carried their own copy of several of these; they now share one definition, so they cannot drift onto different scales. The package is physics only -- numbers in, numbers out -- and reads no scan object, configuration or widget; ``orgui.app`` ``integration_corrections`` is the adapter that supplies those. ``orgui.datautils.xrayutils.geometrycorrections`` and ``orgui.datautils.xrayutils.beamprofile`` keep working as aliases of the moved modules. **No calculated value changes.** diff --git a/orgui/app/orGUI.py b/orgui/app/orGUI.py index f454a37..f638ddf 100644 --- a/orgui/app/orGUI.py +++ b/orgui/app/orGUI.py @@ -2123,6 +2123,24 @@ def sumImage(i): croibg1_bgimg_a *= Corr1 croibg1_bgimg_err_a *= Corr1 + if self.scanSelector.usePolarizationBox.isChecked(): + # Corr1 carries the polarization of the calibrated geometry; + # move it onto the arm position of each frame (finding F5). + # Exactly 1 for a detector whose arm does not move. + pol_arm1 = self._polarizationArmFactor( + dc, + xylist[d][1], + xylist[d][0], + roi_d[1].stop - roi_d[1].start, + roi_d[0].stop - roi_d[0].start, + mu, + ) + croibg1_a = croibg1_a * pol_arm1 + croibg1_err_a = croibg1_err_a * pol_arm1 + if croibg1_bgimg_a is not None: + croibg1_bgimg_a = croibg1_bgimg_a * pol_arm1 + croibg1_bgimg_err_a = croibg1_bgimg_err_a * pol_arm1 + rod_mask1 = np.isfinite(croibg1_a) axis_masked = hkl_del_gam_1[:, 5][rod_mask1] @@ -3464,6 +3482,76 @@ def _qInSelectedFrame(self, pos): except Exception: return np.full(3, np.nan) + def _polarizationArmFactor(self, dc, row, column, row_size, column_size, alpha): + """Per-frame factor moving the polarization onto the real arm position. + + The per-pixel polarization array is built once, from the calibrated + geometry. That is correct for a detector whose arm does not move, but + on a scan that drives the arm the same pixel looks in a different + direction on every frame and the correction comes out far too small -- + 10 % at a scattering angle of 18 degrees, 33 % at 30. This returns + what the already-corrected intensity has to be multiplied by; see + ``doc/design/ctr_structure_factor_scale.md`` finding F5. + + The factor is exactly ``1.0`` wherever the arm sits at its calibrated + reference, because both evaluations then use the same geometry, so a + fixed-arm scan is untouched without needing to be special-cased. A + constant arm is evaluated once and broadcast, which keeps the cost off + the common path. + + The arm position comes from :meth:`getArmAngles`, so this shares its + convention with every other arm consumer in the application: a scan + that knows nothing about an arm reports zero, which is the calibrated + reference for the default calibration and therefore leaves the + correction at one. + + :param dc: The calibrated + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`. + :param row: Region centre row per frame, in pixels (orGUI's ``y``). + :param column: Region centre column per frame, in pixels (``x``). + :param row_size: Region height per frame, in pixels. + :param column_size: Region width per frame, in pixels. + :param alpha: Incidence angle per frame, in radian. + :returns: The factor per frame. + :rtype: numpy.ndarray + """ + gamma_arm, delta_arm = self.getArmAngles() + row, column, row_size, column_size, alpha, gamma_arm, delta_arm = ( + np.broadcast_arrays( + np.asarray(row, dtype=np.float64), + np.asarray(column, dtype=np.float64), + np.maximum(np.asarray(row_size, dtype=np.float64), 1.0), + np.maximum(np.asarray(column_size, dtype=np.float64), 1.0), + np.asarray(alpha, dtype=np.float64), + np.asarray(gamma_arm, dtype=np.float64), + np.asarray(delta_arm, dtype=np.float64), + ) + ) + + def _at(index): + return detector_corrections.polarization_arm_correction( + dc, + row[index], + column[index], + row_size[index], + column_size[index], + alpha[index], + float(gamma_arm[index]), + float(delta_arm[index]), + ) + + constant = all( + np.all(values == values.flat[0]) if values.size else True + for values in (row, column, row_size, column_size, alpha, + gamma_arm, delta_arm) + ) + if constant and row.size: + return np.full(row.shape, _at(np.unravel_index(0, row.shape))) + factor = np.ones(row.shape, dtype=np.float64) + for index in np.ndindex(*row.shape): + factor[index] = _at(index) + return factor + def getArmAngles(self, imageno=None): """Return the detector arm position for an image or the whole scan. @@ -6383,6 +6471,30 @@ def sumImage(i): ) ) + if options["polarization"]: + # Corr1/Corr2 carry the polarization of the calibrated geometry; + # move it onto the arm position of each frame (finding F5). This is + # exactly 1 for a detector whose arm does not move, and it is the + # reflectivity case -- where the arm follows 2*alpha -- that needs + # it most. + pol_arm1 = self._polarizationArmFactor( + dc, y_coord1_a, x_coord1_a, roi_vsize1_a, roi_hsize1_a, alpha_all + ) + croibg1_a = croibg1_a * pol_arm1 + croibg1_err_a = croibg1_err_a * pol_arm1 + if croibg1_bgimg_a is not None: + croibg1_bgimg_a = croibg1_bgimg_a * pol_arm1 + croibg1_bgimg_err_a = croibg1_bgimg_err_a * pol_arm1 + + pol_arm2 = self._polarizationArmFactor( + dc, y_coord2_a, x_coord2_a, roi_vsize2_a, roi_hsize2_a, alpha_all + ) + croibg2_a = croibg2_a * pol_arm2 + croibg2_err_a = croibg2_err_a * pol_arm2 + if croibg2_bgimg_a is not None: + croibg2_bgimg_a = croibg2_bgimg_a * pol_arm2 + croibg2_bgimg_err_a = croibg2_bgimg_err_a * pol_arm2 + # The solid-angle correction is useful on the *intensity* -- for broad, # non-rod features a differential cross-section is what is wanted -- # but it must not reach a structure factor: a region sum is already the diff --git a/orgui/app/test/test_orGUI_reflections.py b/orgui/app/test/test_orGUI_reflections.py index ba03d2e..5a51656 100644 --- a/orgui/app/test/test_orGUI_reflections.py +++ b/orgui/app/test/test_orGUI_reflections.py @@ -924,3 +924,61 @@ def test_display_roi_geometry_maps_clipped_detector_rows_to_plot_sides( expected_top, expected_bottom, ) + + +class FakeArmPolarizationDetector: + """A detector whose polarization falls off with the arm angle. + + Lets the per-frame arm factor be written in closed form, independently of + the real z-axis expression, so a failure points at the plumbing rather + than at the physics. + """ + + def polarizationAtPoints( + self, row, column, alpha_i, gamma_arm=None, delta_arm=None + ): + arm = 0.0 if gamma_arm is None else float(gamma_arm) + return np.full(np.shape(row), 1.0 - 0.5 * arm**2, dtype=float) + + +def test_the_polarization_arm_factor_follows_a_moving_arm(): + """Finding F5: the factor is per frame and one at the calibrated position. + + ``_polarizationArmFactor`` reads the arm from ``getArmAngles``, sharing + that convention with every other arm consumer in the application, and + returns what an intensity corrected at the calibrated position has to be + multiplied by. + """ + arms = np.array([0.0, 0.1, 0.2]) + stub = SimpleNamespace(getArmAngles=lambda: (arms, np.zeros_like(arms))) + + got = orGUI._polarizationArmFactor( + stub, FakeArmPolarizationDetector(), 300.0, 240.0, 20.0, 20.0, 0.01 + ) + + np.testing.assert_allclose(got, 1.0 / (1.0 - 0.5 * arms**2), rtol=1e-12) + assert got[0] == 1.0, "no arm rotation must leave the intensity alone" + + +def test_the_polarization_arm_factor_broadcasts_a_constant_arm(): + """The constant-arm shortcut must agree with the per-frame path. + + A fixed arm is evaluated once and broadcast to keep the cost off the + common path, so the two branches have to give the same number. + """ + constant = np.full(4, 0.2) + varying = np.array([0.2, 0.2, 0.2, 0.2000001]) + detector = FakeArmPolarizationDetector() + + fast = orGUI._polarizationArmFactor( + SimpleNamespace(getArmAngles=lambda: (constant, np.zeros(4))), + detector, 300.0, 240.0, 20.0, 20.0, 0.01, + ) + looped = orGUI._polarizationArmFactor( + SimpleNamespace(getArmAngles=lambda: (varying, np.zeros(4))), + detector, 300.0, 240.0, 20.0, 20.0, 0.01, + ) + + assert fast.shape == (4,) + np.testing.assert_allclose(fast, 1.0 / (1.0 - 0.5 * 0.2**2), rtol=1e-12) + np.testing.assert_allclose(looped[:3], fast[:3], rtol=1e-12) diff --git a/orgui/datautils/xrayutils/corrections/detector.py b/orgui/datautils/xrayutils/corrections/detector.py index 77f45f8..39f4da0 100644 --- a/orgui/datautils/xrayutils/corrections/detector.py +++ b/orgui/datautils/xrayutils/corrections/detector.py @@ -55,7 +55,12 @@ import numpy as np -__all__ = ["pixel_factors", "roi_mean_inverse_solid_angle"] +__all__ = [ + "pixel_factors", + "polarization_arm_correction", + "roi_mean_inverse_polarization", + "roi_mean_inverse_solid_angle", +] def pixel_factors(detector, solid_angle=False, polarization=False, shape=None): @@ -167,3 +172,149 @@ def roi_mean_inverse_solid_angle( if block.size: out[index] = np.mean(1.0 / block) return out + + +def _roi_sample_grid(row, column, row_size, column_size, samples): + """Coordinates spanning one region, at most ``samples`` per direction. + + The polarization varies smoothly across a region, so a coarse sample + gives its mean to far better accuracy than the correction itself is + known. Capping the count keeps the cost per frame independent of how + large the region is. + + Samples sit at the centres of equal sub-intervals, the midpoint rule, so + a single sample lands on the region centre rather than on an edge and no + sample count is biased towards one side. + + :returns: ``(rows, columns)`` as 1D arrays of pixel coordinates. + :rtype: tuple + """ + + def _centres(centre, size, count): + count = int(min(max(int(round(size)), 1), count)) + return centre + ((np.arange(count) + 0.5) / count - 0.5) * size + + return ( + _centres(row, row_size, samples), + _centres(column, column_size, samples), + ) + + +def roi_mean_inverse_polarization( + detector, + row, + column, + row_size, + column_size, + alpha, + gamma_arm=None, + delta_arm=None, + samples=17, +): + r"""Mean of :math:`1/P` over a region, at one detector arm position. + + Uses + :meth:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD.polarizationAtPoints`, + which evaluates the z-axis polarization expression in the surface-frame + angles of each pixel **at the arm position given**. That is the difference + from :func:`pixel_factors`, whose array comes from pyFAI's detector-frame + expression at the *calibrated* position; see + :func:`polarization_arm_correction`. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`. + :param row: Region centre row, in pixels along pyFAI dimension 1. + :param column: Region centre column, in pixels along pyFAI dimension 2. + :param row_size: Region height in pixels. + :param column_size: Region width in pixels. + :param alpha: Incidence angle of the frame, in radian. + :param gamma_arm: Detector arm position, in radian; ``None`` is the + calibrated position. Give both arm angles or neither. + :param delta_arm: Detector arm position, in radian. + :param int samples: Upper bound on the sample count per direction. + :returns: The mean of :math:`1/P` over the region. + :rtype: float + """ + rows, columns = _roi_sample_grid( + float(row), float(column), float(row_size), float(column_size), samples + ) + grid_rows, grid_columns = np.meshgrid(rows, columns, indexing="ij") + polarization = detector.polarizationAtPoints( + np.ascontiguousarray(grid_rows.ravel()), + np.ascontiguousarray(grid_columns.ravel()), + float(alpha), + gamma_arm, + delta_arm, + ) + return float(np.mean(1.0 / np.asarray(polarization, dtype=np.float64))) + + +def polarization_arm_correction( + detector, + row, + column, + row_size, + column_size, + alpha, + gamma_arm, + delta_arm, + samples=17, +): + r"""Factor moving a polarization correction onto the frame's arm position. + + :func:`pixel_factors` divides by the polarization of the **calibrated** + geometry, evaluated once outside the frame loop. For a detector whose arm + does not move that is correct -- every pixel already carries its own + scattering angle. For a scan that drives the arm it is not: the same pixel + looks in a different direction on every frame, and the correction comes + out far too small. Measured at the centre of a detector at one metre, with + the arm following a specular scan, the calibrated-position polarization is + high by 0.8 % at a scattering angle of 5 degrees, 3.2 % at 10, 10.7 % at + 18 and 33.6 % at 30. + + This returns + :math:`\langle 1/P_\mathrm{arm}\rangle / \langle 1/P_\mathrm{home}\rangle` + over the region, the factor an intensity already corrected with the + calibrated-position polarization must be multiplied by. It is **exactly + one** when the arm sits at its calibrated position, so a fixed-arm scan is + untouched. + + The ratio is taken between two region means rather than at the region + centre because the polarization is not flat across a region at a large + scattering angle: for a 100-pixel region at one metre near + :math:`2\theta = 30` degrees it varies by about a percent from edge to + edge. What is left out is the covariance with the solid angle over the + region, which :func:`pixel_factors` fuses into the same array -- second + order in the variation of both across one region. + + See ``doc/design/ctr_structure_factor_scale.md`` finding F5. + + :param detector: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`. + :param row: Region centre row, in pixels along pyFAI dimension 1. + :param column: Region centre column, in pixels along pyFAI dimension 2. + :param row_size: Region height in pixels. + :param column_size: Region width in pixels. + :param alpha: Incidence angle of the frame, in radian. + :param gamma_arm: Detector arm position of the frame, in radian. + :param delta_arm: Detector arm position of the frame, in radian. + :param int samples: Upper bound on the sample count per direction. + :returns: The multiplicative correction, ``1.0`` at the calibrated + position. + :rtype: float + """ + at_home = roi_mean_inverse_polarization( + detector, row, column, row_size, column_size, alpha, samples=samples + ) + at_arm = roi_mean_inverse_polarization( + detector, + row, + column, + row_size, + column_size, + alpha, + gamma_arm, + delta_arm, + samples=samples, + ) + return at_arm / at_home diff --git a/orgui/datautils/xrayutils/test/test_corrections_detector.py b/orgui/datautils/xrayutils/test/test_corrections_detector.py index d33a47d..89d9aa3 100644 --- a/orgui/datautils/xrayutils/test/test_corrections_detector.py +++ b/orgui/datautils/xrayutils/test/test_corrections_detector.py @@ -1,9 +1,11 @@ """Regression tests for the per-pixel detector correction factors. :mod:`orgui.datautils.xrayutils.corrections.detector` owns the solid-angle and -polarization arrays and the region reduction of the solid-angle part that a -structure factor has to divide back out. See -``doc/design/ctr_structure_factor_scale.md`` finding F6. +polarization arrays, the region reduction of the solid-angle part that a +structure factor has to divide back out (finding F6), and the factor that +moves a polarization correction from the calibrated arm position onto the arm +position a frame was actually measured at (finding F5). See +``doc/design/ctr_structure_factor_scale.md``. """ import numpy as np @@ -134,3 +136,85 @@ def test_a_zero_or_negative_region_is_rejected(): detector_corrections.roi_mean_inverse_solid_angle( det, 300.0, 240.0, 20.0, -5.0 ) + + +def test_the_arm_correction_is_one_at_the_calibrated_position(): + """A fixed-arm scan must come out bit-identical. + + The whole point of expressing finding F5 as a ratio is that it changes + nothing for a detector that does not move: the correction already applied + is the right one there. + """ + det = _calibrated_detector() + + got = detector_corrections.polarization_arm_correction( + det, 300.0, 240.0, 60.0, 60.0, np.deg2rad(0.6), None, None + ) + + assert got == 1.0 + + +def test_the_arm_correction_recovers_the_documented_errors(): + """The size of finding F5, written out from the two evaluations. + + The calibrated-position polarization is too small a correction for a + moving arm, so the factor is above one and grows with the scattering + angle. The reference is the ratio of the polarization at the two arm + positions at the region centre, which is what the region means reduce to + for a small region. + """ + det = _calibrated_detector() + row, column = 310.0, 244.0 + alpha = np.deg2rad(0.6) + + for two_theta, expected_percent in ((10.0, 3.2), (18.0, 10.7), (30.0, 33.6)): + arm = np.deg2rad(two_theta) + got = detector_corrections.polarization_arm_correction( + det, row, column, 4.0, 4.0, alpha, arm, 0.0 + ) + + p_home = det.polarizationAtPoints( + np.array([row]), np.array([column]), alpha + )[0] + p_arm = det.polarizationAtPoints( + np.array([row]), np.array([column]), alpha, arm, 0.0 + )[0] + np.testing.assert_allclose(got, p_home / p_arm, rtol=1e-3) + np.testing.assert_allclose(100.0 * (got - 1.0), expected_percent, rtol=5e-2) + + +def test_the_arm_correction_averages_over_the_region(): + """At a large scattering angle the polarization is not flat over a region. + + Taking the ratio at the region centre instead of between two region means + is a fraction of a percent off for a large region at + :math:`2\\theta = 30` degrees, which is why the means are used. + """ + det = _calibrated_detector() + row, column, alpha = 310.0, 244.0, np.deg2rad(0.6) + arm = np.deg2rad(30.0) + + small = detector_corrections.polarization_arm_correction( + det, row, column, 2.0, 2.0, alpha, arm, 0.0 + ) + large = detector_corrections.polarization_arm_correction( + det, row, column, 300.0, 300.0, alpha, arm, 0.0 + ) + + assert not np.isclose(small, large, rtol=1e-4) + # Both still describe the same correction, so they stay close. + np.testing.assert_allclose(large, small, rtol=2e-2) + + +def test_the_region_mean_polarization_follows_the_arm(): + """The underlying quantity, which the ratio is built from.""" + det = _calibrated_detector() + args = (det, 310.0, 244.0, 40.0, 40.0, np.deg2rad(0.6)) + + home = detector_corrections.roi_mean_inverse_polarization(*args) + moved = detector_corrections.roi_mean_inverse_polarization( + *args, gamma_arm=np.deg2rad(30.0), delta_arm=0.0 + ) + + assert home < moved, "a larger scattering angle needs a larger correction" + np.testing.assert_allclose(home, 1.0, rtol=1e-3) From 6672938d6c2c3728d49927c7ff96c7395772650d Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 13:35:12 -0400 Subject: [PATCH 08/33] fix: start the CLI on the offscreen Qt platform --- orgui/main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/orgui/main.py b/orgui/main.py index 585847c..7341d0d 100644 --- a/orgui/main.py +++ b/orgui/main.py @@ -268,9 +268,13 @@ def main(): os.environ["NUMEXPR_NUM_THREADS"] = os.environ["NUMEXPR_MAX_THREADS"] if options.cli: - os.environ["QT_QPA_PLATFORM"] = ( - "minimal" # "offscreen" # maybe use minimal instead - ) + # "offscreen" rather than "minimal": the minimal platform plugin has + # no font database, so Qt cannot load an application font there. silx + # builds its plot windows with qtawesome icons, which loads one, and + # under "minimal" that fails the whole CLI startup with a FontError + # before any batch script runs. setdefault so an explicitly chosen + # platform still wins. + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from . import logger_utils logger_utils.set_logging_context("cli") From 9fcfb51e8b625e417a5a04d2933c8c4993f45b34 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 13:37:55 -0400 Subject: [PATCH 09/33] fix(phys): keep off-detector regions out of the detector corrections --- .../xrayutils/corrections/detector.py | 29 ++++++++-- .../test/test_corrections_detector.py | 54 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/orgui/datautils/xrayutils/corrections/detector.py b/orgui/datautils/xrayutils/corrections/detector.py index 39f4da0..d6f543b 100644 --- a/orgui/datautils/xrayutils/corrections/detector.py +++ b/orgui/datautils/xrayutils/corrections/detector.py @@ -137,9 +137,12 @@ def roi_mean_inverse_solid_angle( :returns: The mean of the reciprocal normalized solid angle, broadcast over the inputs. A region entirely off the detector contains no pixels and no counts, and yields ``1.0`` so that it neither rescales nor - invalidates the zero intensity there. + invalidates the zero intensity there. So does a region whose position + is not finite: a real scan has frames where the rod never reaches the + detector, and those arrive here as ``inf`` or ``nan`` centres while + carrying no counts either. :rtype: numpy.ndarray - :raises ValueError: If a region size is not positive. + :raises ValueError: If a finite region size is not positive. """ solid_angle = np.asarray( detector.solidAngleArray(shape) if shape is not None @@ -152,8 +155,17 @@ def roi_mean_inverse_solid_angle( np.asarray(row_size, dtype=np.float64), np.asarray(column_size, dtype=np.float64), ) + # A non-finite position or size is a frame on which the rod never reached + # the detector. Those carry no counts, so they are skipped rather than + # rejected; only a *finite* size that is not positive is a real error. + defined = ( + np.isfinite(row) + & np.isfinite(column) + & np.isfinite(row_size) + & np.isfinite(column_size) + ) for name, value in (("height", row_size), ("width", column_size)): - if np.any(value <= 0) or not np.all(np.isfinite(value)): + if np.any(value[defined] <= 0): raise ValueError( f"the region of interest must have a positive {name} in " f"pixels; got {value!r}" @@ -162,6 +174,8 @@ def roi_mean_inverse_solid_angle( n_rows, n_columns = solid_angle.shape[0], solid_angle.shape[1] out = np.ones(row.shape, dtype=np.float64) for index in np.ndindex(*row.shape): + if not defined[index]: + continue r0 = int(np.floor(row[index] - row_size[index] / 2.0)) r1 = int(np.ceil(row[index] + row_size[index] / 2.0)) c0 = int(np.floor(column[index] - column_size[index] / 2.0)) @@ -300,9 +314,16 @@ def polarization_arm_correction( :param delta_arm: Detector arm position of the frame, in radian. :param int samples: Upper bound on the sample count per direction. :returns: The multiplicative correction, ``1.0`` at the calibrated - position. + position and ``1.0`` where the region position is not finite -- a + frame on which the rod never reached the detector, which carries no + counts to correct. :rtype: float """ + if not all( + np.isfinite(float(value)) + for value in (row, column, row_size, column_size, alpha) + ): + return 1.0 at_home = roi_mean_inverse_polarization( detector, row, column, row_size, column_size, alpha, samples=samples ) diff --git a/orgui/datautils/xrayutils/test/test_corrections_detector.py b/orgui/datautils/xrayutils/test/test_corrections_detector.py index 89d9aa3..171df64 100644 --- a/orgui/datautils/xrayutils/test/test_corrections_detector.py +++ b/orgui/datautils/xrayutils/test/test_corrections_detector.py @@ -218,3 +218,57 @@ def test_the_region_mean_polarization_follows_the_arm(): assert home < moved, "a larger scattering angle needs a larger correction" np.testing.assert_allclose(home, 1.0, rtol=1e-3) + + +def test_a_non_finite_region_position_is_neutral_not_fatal(): + """Real scans have frames on which the rod never reaches the detector. + + Those arrive with ``inf`` or ``nan`` region centres -- and with no counts, + since no pixel was valid. Found on FeReO4 scan 39, where it aborted a + stationary integration with ``OverflowError: cannot convert float + infinity to integer`` after the frame loop had already finished. + """ + det = _calibrated_detector() + row = np.array([300.0, np.inf, np.nan, 400.0]) + column = np.array([240.0, 240.0, np.nan, np.inf]) + + got = detector_corrections.roi_mean_inverse_solid_angle( + det, row, column, 20.0, 20.0 + ) + + assert np.all(np.isfinite(got)) + np.testing.assert_allclose(got[[1, 2, 3]], 1.0, rtol=0.0) + assert got[0] > 1.0, "the finite frame must still be corrected" + + +def test_a_non_finite_region_size_is_neutral_too(): + """The size can be degenerate on an off-detector frame as well.""" + det = _calibrated_detector() + + got = detector_corrections.roi_mean_inverse_solid_angle( + det, 300.0, 240.0, np.array([20.0, np.nan]), 20.0 + ) + + assert np.all(np.isfinite(got)) + np.testing.assert_allclose(got[1], 1.0, rtol=0.0) + + +def test_a_finite_but_empty_region_is_still_rejected(): + """Guarding non-finite sizes must not swallow a genuinely bad one.""" + det = _calibrated_detector() + + with pytest.raises(ValueError, match="positive height"): + detector_corrections.roi_mean_inverse_solid_angle( + det, 300.0, 240.0, np.array([20.0, 0.0]), 20.0 + ) + + +def test_the_arm_correction_is_neutral_for_a_non_finite_region(): + """Same guard on the F5 factor, which is fed the same coordinates.""" + det = _calibrated_detector() + + got = detector_corrections.polarization_arm_correction( + det, np.inf, 240.0, 20.0, 20.0, np.deg2rad(0.6), np.deg2rad(30.0), 0.0 + ) + + assert got == 1.0 From 964d2875d9ebb5f3aa386b11024075517c74c15f Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 13:38:50 -0400 Subject: [PATCH 10/33] fix(phys): measure the rocking corrections on the stored detector --- orgui/app/peak1Dintegr.py | 76 ++++++++++++++++++-------- orgui/app/test/test_peak1Dintegr.py | 84 +++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 24 deletions(-) diff --git a/orgui/app/peak1Dintegr.py b/orgui/app/peak1Dintegr.py index 8b99d4c..9421173 100644 --- a/orgui/app/peak1Dintegr.py +++ b/orgui/app/peak1Dintegr.py @@ -55,7 +55,7 @@ import traceback from . import qutils -from .config_data import ConfigData +from .config_data import ConfigData, detector_from_nxdict from .. import resources from .. import logger_utils from ..datautils.xrayutils.corrections import beamprofile @@ -1421,7 +1421,45 @@ def _rocking_normalization(self, aux, size): size, exposure_time=exposure, monitors=monitors ) - def _rocking_solid_angle_mean(self, scangroup, cnters, x, y): + def _stored_detector(self, scangroup): + """The detector geometry the rocking curves were measured with. + + Read from the configuration stored beside the scan, never from the + current application state. The acceptance and the solid-angle factor + are properties of the geometry the data was *taken* with, and a + reduction run later -- from a batch script, or after another + calibration has been loaded -- would otherwise silently use whatever + detector happens to be loaded. Measured on a LaNiO3 rocking scan, that + mistake scaled every ``Delta_gamma`` by 2.3 and left no trace in the + output. + + :param scangroup: The scan group holding ``configuration``. + :returns: A + :class:`~orgui.datautils.xrayutils.DetectorCalibration.Detector2D_SXRD`, + or ``None`` when the scan stores no detector configuration. + :rtype: object or None + """ + path = scangroup.name + "/configuration/instrument/detector_SXRD" + if path not in self.database.nxfile: + logger.warning( + "This scan stores no detector configuration, so the " + "out-of-plane acceptance cannot be calculated from the " + "geometry the data was measured with. F2_hkl is left on the " + "acceptance-blind scale." + ) + return None + try: + return detector_from_nxdict(h5todict(self.database.nxfile, path)) + except Exception: + logger.exception( + "Cannot rebuild the detector geometry stored with this scan, " + "so the out-of-plane acceptance cannot be calculated. F2_hkl " + "is left on the acceptance-blind scale.", + extra={"title": "Cannot read the stored detector geometry"}, + ) + return None + + def _rocking_solid_angle_mean(self, detector, scangroup, cnters, x, y): """Region mean of the solid-angle correction that was applied, or None. The solid-angle correction is applied to the *intensity* when the @@ -1435,8 +1473,11 @@ def _rocking_solid_angle_mean(self, scangroup, cnters, x, y): Whether it was applied is a property of the *extraction*, not of the switches in this dialog, so it is read from the configuration snapshot - stored with the scan rather than from the current GUI state. + stored with the scan rather than from the current GUI state -- as is + the detector geometry it is measured over. + :param detector: The geometry the scan was measured with, from + :meth:`_stored_detector`. :param scangroup: The scan group holding the ``configuration`` written when the rocking curves were extracted. :param cnters: The ``rois`` group of the rocking scan. @@ -1466,14 +1507,12 @@ def _rocking_solid_angle_mean(self, scangroup, cnters, x, y): if not was_applied: return None, False - config_target = self.database.config_target - detector = getattr(getattr(config_target, "ubcalc", None), "detectorCal", None) if detector is None: logger.warning( "The solid angle correction was applied to these rocking " - "curves, but no calibrated detector is available to measure it " - "over the regions of interest, so it is not divided out of " - "F2_hkl." + "curves, but the detector geometry stored with the scan is " + "not available to measure it over the regions of interest, so " + "it is not divided out of F2_hkl." ) return None, False @@ -1493,7 +1532,7 @@ def _rocking_solid_angle_mean(self, scangroup, cnters, x, y): ) return np.asarray(mean, dtype=float), True - def _rocking_acceptance(self, cnters, x, y): + def _rocking_acceptance(self, detector, cnters, x, y): """Out-of-plane acceptance of every region of interest, in radian. A rocking scan intercepts a slice of rod proportional to @@ -1512,23 +1551,17 @@ def _rocking_acceptance(self, cnters, x, y): :math:`\\gamma` is measured around, so this costs nothing measurable; see ``doc/design/ctr_structure_factor_scale.md`` section 4.4. + :param detector: The geometry the scan was measured with, from + :meth:`_stored_detector`; ``None`` leaves the acceptance out. :param cnters: The ``rois`` group of the rocking scan. :param x: Region centre column per ``s`` point, in pixels. :param y: Region centre row per ``s`` point, in pixels. :returns: ``(acceptance, applied)`` -- the acceptance in radian of - shape ``(n_s,)``, or ``(None, False)`` when no calibrated - detector is reachable. + shape ``(n_s,)``, or ``(None, False)`` when the stored geometry + is unavailable. :rtype: tuple """ - config_target = self.database.config_target - detector = getattr(getattr(config_target, "ubcalc", None), "detectorCal", None) if detector is None: - logger.warning( - "No calibrated detector is available, so the out-of-plane " - "acceptance of the regions of interest cannot be calculated. " - "F2_hkl is left on the acceptance-blind scale and will not " - "agree with a stationary integration of the same rod." - ) return None, False vsize = cnters["vsize"][()] @@ -1632,11 +1665,12 @@ def integrate(self): detector_acceptance, acceptance_applied = None, False solid_angle_mean, solid_angle_compensated = None, False if self.lorentzButton.isChecked(): + detector = self._stored_detector(scangroup) detector_acceptance, acceptance_applied = self._rocking_acceptance( - cnters, x, y + detector, cnters, x, y ) solid_angle_mean, solid_angle_compensated = ( - self._rocking_solid_angle_mean(scangroup, cnters, x, y) + self._rocking_solid_angle_mean(detector, scangroup, cnters, x, y) ) self.database.nxfile[self._currentRoInfo["name"] + "/integration/"] diff --git a/orgui/app/test/test_peak1Dintegr.py b/orgui/app/test/test_peak1Dintegr.py index a36b1e7..1b3d033 100644 --- a/orgui/app/test/test_peak1Dintegr.py +++ b/orgui/app/test/test_peak1Dintegr.py @@ -13,9 +13,13 @@ saved rocking intensities and uncertainties from regression. """ +import os +import tempfile from types import SimpleNamespace +import h5py import numpy as np +import pytest from orgui.app.peak1Dintegr import ( RockingPeakIntegrator, @@ -454,7 +458,8 @@ def test_the_acceptance_reads_the_row_from_y_and_the_column_from_x(): } acceptance, applied = RockingPeakIntegrator._rocking_acceptance( - _stub(detector), cnters, x=np.array([5.0, 300.0, 470.0]), y=np.full(3, 250.0) + _stub(), detector, cnters, + x=np.array([5.0, 300.0, 470.0]), y=np.full(3, 250.0) ) assert applied is True @@ -462,11 +467,11 @@ def test_the_acceptance_reads_the_row_from_y_and_the_column_from_x(): def test_a_missing_detector_leaves_the_acceptance_out_rather_than_failing(): - """CLI use without a calibration must not lose the integration.""" + """A scan storing no detector geometry must not lose the integration.""" cnters = {"vsize": np.array([40.0]), "alpha_pk": np.zeros(1)} acceptance, applied = RockingPeakIntegrator._rocking_acceptance( - _stub(None), cnters, x=np.array([100.0]), y=np.array([200.0]) + _stub(), None, cnters, x=np.array([100.0]), y=np.array([200.0]) ) assert acceptance is None @@ -503,3 +508,76 @@ def test_a_missing_exposure_counter_is_skipped_and_recorded(): np.testing.assert_allclose(divisor, np.ones(3), rtol=1e-12) assert applied == [] + + +def test_the_detector_comes_from_the_scan_not_from_the_application(): + """The acceptance must use the geometry the data was measured with. + + ``Delta_gamma`` is a property of the detector the rocking curves were + recorded on. Reading it from whatever calibration the application happens + to hold makes a reduction run later -- from a batch script, or after a + different calibration was loaded -- silently wrong: on a real LaNiO3 scan + that scaled every acceptance by 2.3 with nothing in the output to show + for it. + """ + pyFAI = pytest.importorskip("pyFAI") + from silx.io.dictdump import dicttonx + + from orgui.app.config_data import detector_to_nxdict + from orgui.datautils.xrayutils import DetectorCalibration + + stored = DetectorCalibration.Detector2D_SXRD() + stored.detector = pyFAI.detectors.Detector( + pixel1=172e-6, pixel2=172e-6, max_shape=(619, 487) + ) + stored.dist = 0.5 + stored.poni1, stored.poni2 = 0.05, 0.04 + stored.rot1 = stored.rot2 = stored.rot3 = 0.0 + stored.set_energy(15.0) + stored.setAzimuthalReference(np.deg2rad(90.0)) + stored.setPolarization(0.0, 1.0) + + with tempfile.TemporaryDirectory() as folder: + path = os.path.join(folder, "scan.h5") + dicttonx( + {"configuration": {"instrument": { + "detector_SXRD": detector_to_nxdict(stored)}}}, + path, + h5path="/61.1", + update_mode="add", + ) + with h5py.File(path, "r") as handle: + integrator = SimpleNamespace( + database=SimpleNamespace( + nxfile=handle, + # A *different* geometry in the application, which must + # not be the one that gets used. + config_target=SimpleNamespace( + ubcalc=SimpleNamespace(detectorCal="wrong detector") + ), + ) + ) + got = RockingPeakIntegrator._stored_detector( + integrator, handle["/61.1"] + ) + + assert got is not None + assert got != "wrong detector" + assert got.dist == pytest.approx(0.5) + assert got.poni1 == pytest.approx(0.05) + assert got.poni2 == pytest.approx(0.04) + + +def test_a_scan_without_a_stored_detector_gives_none(): + """An older database has no geometry to read; that is not a crash.""" + with tempfile.TemporaryDirectory() as folder: + path = os.path.join(folder, "scan.h5") + with h5py.File(path, "w") as handle: + handle.create_group("/61.1") + with h5py.File(path, "r") as handle: + integrator = SimpleNamespace( + database=SimpleNamespace(nxfile=handle, config_target=None) + ) + assert RockingPeakIntegrator._stored_detector( + integrator, handle["/61.1"] + ) is None From ec5454b67f379d78a7ea0bd58c6e0bf96775f9e7 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 13:54:57 -0400 Subject: [PATCH 11/33] refactor: name every integration option in snake_case --- orgui/app/QScanSelector.py | 192 ++++++++++++++----- orgui/app/ROIutils.py | 19 +- orgui/app/ReconstructionDialog.py | 4 +- orgui/app/_option_keys.py | 154 +++++++++++++++ orgui/app/orGUI.py | 26 +-- orgui/app/test/test_config_data.py | 2 +- orgui/app/test/test_option_keys.py | 113 +++++++++++ orgui/app/test/test_reconstruction_dialog.py | 8 +- 8 files changed, 450 insertions(+), 68 deletions(-) create mode 100644 orgui/app/_option_keys.py create mode 100644 orgui/app/test/test_option_keys.py diff --git a/orgui/app/QScanSelector.py b/orgui/app/QScanSelector.py index 94df425..13ec7a8 100644 --- a/orgui/app/QScanSelector.py +++ b/orgui/app/QScanSelector.py @@ -49,6 +49,7 @@ from ..backend import backends, scans from . import qutils from . import integration_corrections +from ._option_keys import LegacyKeyDict, canonical_options from .QReflectionSelector import QReflectionAnglesDialog from .QHKLDialog import HKLDialog @@ -891,32 +892,115 @@ def fun(x): self.sigROIChanged.emit() + #: Integration switch name -> the checkbox holding it. + @property + def _switch_boxes(self): + return { + "mask": self.useMaskBox, + "solid_angle": self.useSolidAngleBox, + "polarization": self.usePolarizationBox, + "lorentz": self.useLorentzBox, + "footprint": self.useFootprintBox, + "normalization": self.useNormalizationBox, + } + + #: Region control name -> the spin box or check box holding it. + @property + def _region_controls(self): + return { + "hsize": self.hsize, + "vsize": self.vsize, + "left": self.left, + "right": self.right, + "top": self.top, + "bottom": self.bottom, + "auto_hsize": self.autoROIHsize, + "auto_vsize": self.autoROIVsize, + } + + #: Rocking-scan sampling control name -> the spin box holding it. + @property + def _rocking_controls(self): + return {"delta_s": self.roscanDeltaS, "max_s": self.roscanMaxS} + def set_integration_options(self, ddict): - for key in ddict: - if key == "mask": - self.useMaskBox.setChecked(ddict[key]) - elif key == "solidAngle": - self.useSolidAngleBox.setChecked(ddict[key]) - elif key == "polarization": - self.usePolarizationBox.setChecked(ddict[key]) - elif key == "lorentz": - self.useLorentzBox.setChecked(ddict[key]) - elif key == "footprint": - self.useFootprintBox.setChecked(ddict[key]) - elif key == "normalization": - self.useNormalizationBox.setChecked(ddict[key]) + """Restore integration options from a mapping. + + A key this version does not know is ignored rather than rejected, + which is what lets an older orGUI read a newer configuration. Legacy + key spellings are accepted with a deprecation warning; see + :mod:`orgui.app._option_keys`. + + :param dict ddict: Any subset of the keys + :meth:`get_integration_options` returns. + """ + ddict = canonical_options(ddict) + boxes = self._switch_boxes + for key, value in ddict.items(): + if key in boxes: + boxes[key].setChecked(bool(value)) elif key == "advanced": - self.roioptions.set_parameters(ddict[key]) + self.roioptions.set_parameters(value) + elif key == "region": + self._set_region_options(value) + elif key == "rocking_scan": + self._set_rocking_options(value) + + def _set_region_options(self, ddict): + """Restore the region sizes and the automatic-sizing switches.""" + controls = self._region_controls + ddict = canonical_options(ddict) + for key, value in ddict.items(): + control = controls.get(key) + if control is None: + continue + with blockSignals(control): + if key.startswith("auto_"): + control.setChecked(bool(value)) + else: + control.setValue(float(value)) + self.sigROIChanged.emit() + + def _set_rocking_options(self, ddict): + """Restore the rocking-scan sampling. + + Signals are blocked deliberately: assigning ``delta_s`` normally runs + :meth:`onRoSChanged`, which clips it to the detector resolution and + writes the clipped value back. The stored value is already the + clipped one, and re-clipping it against whatever scan happens to be + loaded would not restore what was saved. + """ + controls = self._rocking_controls + ddict = canonical_options(ddict) + for key, value in ddict.items(): + control = controls.get(key) + if control is not None: + with blockSignals(control): + control.setValue(float(value)) def get_integration_options(self): - ddict = {} - ddict["mask"] = self.useMaskBox.isChecked() - ddict["solidAngle"] = self.useSolidAngleBox.isChecked() - ddict["polarization"] = self.usePolarizationBox.isChecked() - ddict["lorentz"] = self.useLorentzBox.isChecked() - ddict["footprint"] = self.useFootprintBox.isChecked() - ddict["normalization"] = self.useNormalizationBox.isChecked() + """Every integration option, under its current key spelling. + + :returns: The switches as flat booleans, plus ``advanced`` (the + region-of-interest options dialog), ``region`` (sizes and + automatic sizing) and ``rocking_scan`` (the ``s`` sampling). + Reading a legacy key off the result still works, with a + deprecation warning. + :rtype: LegacyKeyDict + """ + ddict = LegacyKeyDict( + (name, box.isChecked()) for name, box in self._switch_boxes.items() + ) ddict["advanced"] = self.roioptions.get_parameters() + ddict["region"] = LegacyKeyDict( + (name, control.isChecked() if name.startswith("auto_") + else control.value()) + for name, control in self._region_controls.items() + ) + ddict["rocking_scan"] = LegacyKeyDict( + (name, control.value()) + for name, control in self._rocking_controls.items() + ) return ddict #: Enabled corrections, as ``(checkbox attribute, abbreviation, color, @@ -1763,36 +1847,56 @@ def set_offsets(self, offsetx, offsety): self._onAnyValueChanged() def get_parameters(self): + """Advanced region-of-interest options, under current key spellings. + + Sample sizes are in **meter** and offsets in **pixels**, which is the + unit the consumers of this dictionary expect; the widgets show + micrometer. Reading a legacy key off the result still works, with a + deprecation warning. + + :rtype: LegacyKeyDict + """ sizes = self.get_sample_size() - offX, offY = self._offsetx.value(), self._offsety.value() - - ddict = { - "DetectorInclination": self.hasDetectorInclination(), - "ProjectSampleSize": self.hasProjectSampleSize(), - "xoffset": offX, - "yoffset": offY, - "sizeX": sizes[0], - "sizeY": sizes[1], - "sizeZ": sizes[2], + return LegacyKeyDict({ + "detector_inclination": self.hasDetectorInclination(), + "project_sample_size": self.hasProjectSampleSize(), + "offset_x": self._offsetx.value(), + "offset_y": self._offsety.value(), + "sample_size_x": sizes[0], + "sample_size_y": sizes[1], + "sample_size_z": sizes[2], "factor": self.get_apply_factor(), - "FittedBackground": self.hasFittedBackground(), - "FittedBackgroundOrder": self.get_background_fit_order(), - } - - return ddict + "fitted_background": self.hasFittedBackground(), + "fitted_background_order": self.get_background_fit_order(), + }) def set_parameters(self, ddict): + """Restore the advanced options from a mapping. + + Legacy key spellings are accepted with a deprecation warning; see + :mod:`orgui.app._option_keys`. Sample sizes are in meter, offsets in + pixels. + + :param dict ddict: As returned by :meth:`get_parameters`. + """ + ddict = canonical_options(ddict) self._updating_parameters = True try: - self.inclinationBox.setChecked(ddict["DetectorInclination"]) - self.sizeGroup.setChecked(ddict["ProjectSampleSize"]) - self.set_sample_size(ddict["sizeX"], ddict["sizeY"], ddict["sizeZ"]) - self.set_offsets(ddict["xoffset"], ddict["yoffset"]) + self.inclinationBox.setChecked(ddict["detector_inclination"]) + self.sizeGroup.setChecked(ddict["project_sample_size"]) + self.set_sample_size( + ddict["sample_size_x"], + ddict["sample_size_y"], + ddict["sample_size_z"], + ) + self.set_offsets(ddict["offset_x"], ddict["offset_y"]) self.set_apply_factor(ddict["factor"]) - self.backgroundFitGroup.setChecked(ddict.get("FittedBackground", False)) - self._backgroundFitOrder.setValue(ddict.get("FittedBackgroundOrder", 1)) - except Exception: - raise + self.backgroundFitGroup.setChecked( + ddict.get("fitted_background", False) + ) + self._backgroundFitOrder.setValue( + ddict.get("fitted_background_order", 1) + ) finally: self._updating_parameters = False self._onAnyValueChanged() diff --git a/orgui/app/ROIutils.py b/orgui/app/ROIutils.py index 65c651d..96512b5 100644 --- a/orgui/app/ROIutils.py +++ b/orgui/app/ROIutils.py @@ -30,6 +30,8 @@ import numpy as np +from ._option_keys import canonical_options + def cos_incidence_12(xy, sxrddetector): xy = np.asarray(xy) @@ -120,16 +122,25 @@ def calc_corrections( parallax=True, factor=1.0, ): - """samplesize must be a dict, sizes in m""" + """Region sizes projected from the sample onto the detector. + + :param samplesize: Mapping with ``sample_size_x``, ``sample_size_y`` and + ``sample_size_z`` **in meter**, as + :meth:`~orgui.app.QScanSelector.ROIAdvancedOptions.get_parameters` + returns. The legacy ``sizeX``/``sizeY``/``sizeZ`` spellings are + accepted with a deprecation warning. ``None`` uses ``roisize0`` + alone. + """ if np.all(roisize0 == np.array([0, 0])) and samplesize is None: raise ValueError("You must either provide the sample size or a minimum roisize") roisize_X_real = roisize0[0] * sxrddetector.detector.pixel2 roisize_Y_real = roisize0[1] * sxrddetector.detector.pixel1 if samplesize is not None: - sizeX = samplesize["sizeX"] - sizeY = samplesize["sizeY"] - sizeZ = samplesize["sizeZ"] + samplesize = canonical_options(samplesize) + sizeX = samplesize["sample_size_x"] + sizeY = samplesize["sample_size_y"] + sizeZ = samplesize["sample_size_z"] beamX, beamY = projected_beamsize(xy, sxrddetector, sizeX, sizeY, sizeZ) diff --git a/orgui/app/ReconstructionDialog.py b/orgui/app/ReconstructionDialog.py index e96d2cd..d6d821a 100644 --- a/orgui/app/ReconstructionDialog.py +++ b/orgui/app/ReconstructionDialog.py @@ -490,7 +490,7 @@ def _data_tab(self): self.use_polarization.setToolTip(shared_tooltip) for control, key, row, column in ( (self.use_pixel_mask, "mask", 0, 0), - (self.use_solid_angle, "solidAngle", 0, 1), + (self.use_solid_angle, "solid_angle", 0, 1), (self.use_polarization, "polarization", 1, 0), ): control.toggled.connect( @@ -619,7 +619,7 @@ def _sync_integration_options(self): options = selector.get_integration_options() for control, key in ( (self.use_pixel_mask, "mask"), - (self.use_solid_angle, "solidAngle"), + (self.use_solid_angle, "solid_angle"), (self.use_polarization, "polarization"), ): with qt.QSignalBlocker(control): diff --git a/orgui/app/_option_keys.py b/orgui/app/_option_keys.py new file mode 100644 index 0000000..a407a2b --- /dev/null +++ b/orgui/app/_option_keys.py @@ -0,0 +1,154 @@ +# /*########################################################################## +# +# Copyright (c) 2026 Timo Fuchs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Option-dictionary key names, and the legacy spellings they replaced. + +The integration options and the advanced region-of-interest options are +passed around as plain dictionaries, including by user batch scripts. Their +keys were originally spelled in mixed case (``solidAngle``, +``DetectorInclination``, ``sizeX``) while everything those options are stored +next to uses ``snake_case``. The names are now ``snake_case`` throughout, and +the old spellings keep working for one deprecation cycle. + +Both directions are covered, and they need different mechanisms: + +* a script that *passes* an old key is handled by :func:`canonical_options`, + which rewrites the mapping on the way in. Rewriting on read is not enough, + because ``for key in ddict`` and ``**ddict`` never reach ``__getitem__``. +* code that *reads* an old key off a returned dictionary is handled by + :class:`LegacyKeyDict`, which resolves the alias on lookup. + +Each legacy key warns twice on first use: a :exc:`DeprecationWarning` for +tooling, and one log record, because :exc:`DeprecationWarning` is invisible by +default and these dictionaries are mostly written in batch scripts whose only +output is a log. +""" + +import logging +import warnings + +logger = logging.getLogger(__name__) + +__all__ = [ + "LEGACY_KEYS", + "LegacyKeyDict", + "canonical_key", + "canonical_options", +] + +#: Legacy option-dictionary key -> the name that replaced it. +LEGACY_KEYS = { + # Integration options + "solidAngle": "solid_angle", + # Advanced region-of-interest options + "DetectorInclination": "detector_inclination", + "ProjectSampleSize": "project_sample_size", + "sizeX": "sample_size_x", + "sizeY": "sample_size_y", + "sizeZ": "sample_size_z", + "xoffset": "offset_x", + "yoffset": "offset_y", + "FittedBackground": "fitted_background", + "FittedBackgroundOrder": "fitted_background_order", +} + +#: Legacy keys already reported, so a loop over a scan warns once, not once +#: per frame. +_reported = set() + + +def _report(legacy): + """Warn once per legacy key, to both the warnings system and the log.""" + message = ( + f"The integration option key {legacy!r} is deprecated; use " + f"{LEGACY_KEYS[legacy]!r} instead. The old spelling will be removed " + f"in a future release." + ) + warnings.warn(message, DeprecationWarning, stacklevel=3) + if legacy not in _reported: + _reported.add(legacy) + logger.warning(message) + + +def canonical_key(key): + """Current spelling of one option key. + + :param str key: A current or legacy key. + :returns: The current spelling; unknown keys are returned unchanged, so + that a caller passing an option this version does not know about gets + the same "ignored" behaviour as before rather than an error. + :rtype: str + """ + if key in LEGACY_KEYS: + _report(key) + return LEGACY_KEYS[key] + return key + + +def canonical_options(mapping): + """Rewrite an option mapping onto the current key spellings. + + Applied at the entry of every setter, because a mapping is consumed by + iteration and unpacking as often as by lookup. + + :param mapping: Option dictionary, possibly using legacy keys. ``None`` + is treated as empty. + :returns: A new plain :class:`dict` with current keys. A legacy key and + its replacement in the same mapping is an error rather than a silent + precedence rule. + :rtype: dict + :raises ValueError: If a key is given under both spellings. + """ + result = {} + for key, value in dict(mapping or {}).items(): + name = canonical_key(key) + if name in result: + raise ValueError( + f"option {name!r} was given twice, once as {key!r}; pass only " + f"one spelling" + ) + result[name] = value + return result + + +class LegacyKeyDict(dict): + """Option dictionary that still answers to the legacy key spellings. + + Returned by the option getters so that existing code and scripts reading + ``options["solidAngle"]`` keep working, with a deprecation warning. Only + lookup is aliased: iteration, :meth:`keys` and unpacking expose the + current names, which is what makes the old spellings disappear from + anything that round-trips a whole dictionary. + """ + + def __getitem__(self, key): + return super().__getitem__(canonical_key(key)) + + def __contains__(self, key): + return super().__contains__(canonical_key(key)) + + def get(self, key, default=None): + return super().get(canonical_key(key), default) + + def pop(self, key, *default): + return super().pop(canonical_key(key), *default) diff --git a/orgui/app/orGUI.py b/orgui/app/orGUI.py index f638ddf..e0fe200 100644 --- a/orgui/app/orGUI.py +++ b/orgui/app/orGUI.py @@ -1048,14 +1048,14 @@ def intkeys_rocking(self, refldict, **kwargs): if size_exact is None: roioptions = self.scanSelector.roioptions.get_parameters() - if roioptions["DetectorInclination"] or roioptions["ProjectSampleSize"]: - if roioptions["ProjectSampleSize"]: + if roioptions["detector_inclination"] or roioptions["project_sample_size"]: + if roioptions["project_sample_size"]: size_exact = ROIutils.calc_corrections( xy, self.ubcalc.detectorCal, np.array([hsize, vsize]), roioptions, - roioptions["DetectorInclination"], + roioptions["detector_inclination"], roioptions["factor"], ) else: @@ -1064,7 +1064,7 @@ def intkeys_rocking(self, refldict, **kwargs): self.ubcalc.detectorCal, np.array([hsize, vsize]), None, - roioptions["DetectorInclination"], + roioptions["detector_inclination"], roioptions["factor"], ) @@ -1669,8 +1669,8 @@ def fill_counters(image, pixelavail, key, bkgkey): background_image = self.background_image has_bg_img = False roioptions = self.scanSelector.roioptions.get_parameters() - use_fitted_background = bool(roioptions.get("FittedBackground", False)) - fitted_background_order = int(roioptions.get("FittedBackgroundOrder", 1)) + use_fitted_background = bool(roioptions.get("fitted_background", False)) + fitted_background_order = int(roioptions.get("fitted_background_order", 1)) if use_fitted_background and not HAS_ACCEL: logger.warning( "Fitted local background requires the compiled ROI accelerator; " @@ -4971,7 +4971,7 @@ def _current_preview_mask(self, image_shape): def _apply_interpolated_bg_patch(self, image, mask, ckey, bgkeys): """Overwrite one center ROI in ``image`` with fitted background.""" roioptions = self.scanSelector.roioptions.get_parameters() - fit_order = int(roioptions.get("FittedBackgroundOrder", 1)) + fit_order = int(roioptions.get("fitted_background_order", 1)) patch, stats = _roi_sum_accel.interpolate_polybg_croi( image, mask, @@ -5920,8 +5920,8 @@ def integrateROI(self): has_bg_img = False roioptions = self.scanSelector.roioptions.get_parameters() - use_fitted_background = bool(roioptions.get("FittedBackground", False)) - fitted_background_order = int(roioptions.get("FittedBackgroundOrder", 1)) + use_fitted_background = bool(roioptions.get("fitted_background", False)) + fitted_background_order = int(roioptions.get("fitted_background_order", 1)) if use_fitted_background and not HAS_ACCEL: logger.warning( "Fitted local background requires the compiled ROI accelerator; " @@ -6915,15 +6915,15 @@ def intkey(self, coords): roioptions = self.scanSelector.roioptions.get_parameters() current_mode = self.scanSelector.scanstab.currentIndex() if ( - roioptions["DetectorInclination"] or roioptions["ProjectSampleSize"] + roioptions["detector_inclination"] or roioptions["project_sample_size"] ) and current_mode != 1: - if roioptions["ProjectSampleSize"]: + if roioptions["project_sample_size"]: size_exact = ROIutils.calc_corrections( coord_restr, self.ubcalc.detectorCal, np.array([hsize, vsize]), roioptions, - roioptions["DetectorInclination"], + roioptions["detector_inclination"], roioptions["factor"], ) else: @@ -6932,7 +6932,7 @@ def intkey(self, coords): self.ubcalc.detectorCal, np.array([hsize, vsize]), None, - roioptions["DetectorInclination"], + roioptions["detector_inclination"], roioptions["factor"], ) hsize = size_exact[0][0] diff --git a/orgui/app/test/test_config_data.py b/orgui/app/test/test_config_data.py index 212e8a2..f6a4e39 100644 --- a/orgui/app/test/test_config_data.py +++ b/orgui/app/test/test_config_data.py @@ -158,7 +158,7 @@ def test_enabled_pixel_repair_implies_mask_correction(): scanSelector=SimpleNamespace( get_integration_options=lambda: { "mask": False, - "solidAngle": False, + "solid_angle": False, "polarization": False, } ), diff --git a/orgui/app/test/test_option_keys.py b/orgui/app/test/test_option_keys.py new file mode 100644 index 0000000..6ef630f --- /dev/null +++ b/orgui/app/test/test_option_keys.py @@ -0,0 +1,113 @@ +"""Regression tests for the option-key deprecation shim. + +The integration and region-of-interest option dictionaries are part of the +scripting surface -- ``startup_setup.py``-style batch scripts write them by +hand -- so renaming their keys has to keep the old spellings working for a +deprecation cycle. See :mod:`orgui.app._option_keys`. +""" + +import logging +import warnings + +import pytest + +from orgui.app import _option_keys +from orgui.app._option_keys import ( + LEGACY_KEYS, + LegacyKeyDict, + canonical_key, + canonical_options, +) + + +@pytest.fixture(autouse=True) +def _forget_reported(): + """Each test sees a fresh once-per-key log state.""" + _option_keys._reported.clear() + yield + _option_keys._reported.clear() + + +def test_a_current_key_passes_through_silently(): + """The common path must not warn, or every run would be noisy.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + assert canonical_key("solid_angle") == "solid_angle" + assert canonical_options({"mask": True}) == {"mask": True} + + +def test_a_legacy_key_is_translated_and_warns(): + """Old scripts keep working, loudly enough to be noticed.""" + with pytest.deprecated_call(): + assert canonical_key("solidAngle") == "solid_angle" + + +def test_every_legacy_key_maps_to_a_distinct_current_name(): + """A mapping collision would silently merge two options.""" + assert len(set(LEGACY_KEYS.values())) == len(LEGACY_KEYS) + # No legacy key may also be a current name, or translation would loop. + assert not (set(LEGACY_KEYS) & set(LEGACY_KEYS.values())) + + +def test_an_unknown_key_is_left_alone(): + """Options this version does not know stay ignored, not fatal. + + ``set_integration_options`` has always skipped keys it has no branch for, + which is what lets an older orGUI read a newer configuration. + """ + assert canonical_key("something_new") == "something_new" + assert canonical_options({"something_new": 1}) == {"something_new": 1} + + +def test_the_whole_mapping_is_rewritten_on_the_way_in(): + """Setters consume mappings by iteration, which no alias can intercept.""" + with pytest.deprecated_call(): + got = canonical_options( + {"solidAngle": True, "sizeX": 5e-4, "mask": False} + ) + assert got == {"solid_angle": True, "sample_size_x": 5e-4, "mask": False} + assert list(got) == ["solid_angle", "sample_size_x", "mask"] + + +def test_both_spellings_at_once_is_an_error(): + """Silently preferring one would hide a real inconsistency.""" + with pytest.raises(ValueError, match="given twice"): + with pytest.deprecated_call(): + canonical_options({"solidAngle": True, "solid_angle": False}) + + +def test_reading_a_legacy_key_off_a_returned_dict_still_works(): + """Existing consumers index the getter's result directly.""" + options = LegacyKeyDict({"solid_angle": True, "sample_size_x": 5e-4}) + + with pytest.deprecated_call(): + assert options["solidAngle"] is True + with pytest.deprecated_call(): + assert options.get("sizeX") == 5e-4 + with pytest.deprecated_call(): + assert "solidAngle" in options + assert options["solid_angle"] is True + + +def test_iterating_a_returned_dict_exposes_only_current_names(): + """A whole-dictionary round trip must not carry the old names along.""" + options = LegacyKeyDict({"solid_angle": True}) + + assert list(options) == ["solid_angle"] + assert list(options.keys()) == ["solid_angle"] + assert dict(**options) == {"solid_angle": True} + + +def test_the_log_warns_once_per_key(caplog): + """DeprecationWarning is invisible by default; the log is not. + + A scan loop would otherwise emit one record per frame. + """ + with caplog.at_level(logging.WARNING, logger="orgui.app._option_keys"): + with pytest.deprecated_call(): + canonical_key("solidAngle") + canonical_key("solidAngle") + canonical_key("sizeX") + + records = [r for r in caplog.records if "deprecated" in r.message] + assert len(records) == 2, [r.message for r in records] diff --git a/orgui/app/test/test_reconstruction_dialog.py b/orgui/app/test/test_reconstruction_dialog.py index df011a9..02fca3e 100644 --- a/orgui/app/test/test_reconstruction_dialog.py +++ b/orgui/app/test/test_reconstruction_dialog.py @@ -663,7 +663,7 @@ def test_open_job_restores_all_editable_job_settings(tmp_path, monkeypatch): ) shared_options = { "mask": False, - "solidAngle": False, + "solid_angle": False, "polarization": False, } dialog.orgui.scanSelector = SimpleNamespace( @@ -684,7 +684,7 @@ def test_open_job_restores_all_editable_job_settings(tmp_path, monkeypatch): assert dialog.use_polarization.isChecked() assert shared_options == { "mask": True, - "solidAngle": True, + "solid_angle": True, "polarization": True, } assert not dialog.normalize_exposure.isChecked() @@ -721,7 +721,7 @@ def test_reconstruction_correction_switches_sync_with_integration_options( dialog = _dialog(tmp_path) shared_options = { "mask": True, - "solidAngle": False, + "solid_angle": False, "polarization": True, "advanced": {"unchanged": True}, } @@ -741,7 +741,7 @@ def test_reconstruction_correction_switches_sync_with_integration_options( assert shared_options == { "mask": True, - "solidAngle": True, + "solid_angle": True, "polarization": False, "advanced": {"unchanged": True}, } From 7b6e3a2cfc4c3764f416005955f1899966d12acc Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 13:56:55 -0400 Subject: [PATCH 12/33] feat!: store the integration settings in a typed NeXus layout BREAKING CHANGE: integration_corrections is now a group of typed datasets instead of a single json string. The json dataset is still read, but no longer written, so post-processing that parses it will not see new files. --- orgui/app/config_data.py | 310 ++++++++++++++-- orgui/app/peak1Dintegr.py | 22 +- orgui/app/test/test_config_nexus_layout.py | 337 ++++++++++++++++++ .../test_integration_options_roundtrip.py | 202 +++++++++++ orgui/app/test/test_peak1Dintegr.py | 91 +++++ 5 files changed, 932 insertions(+), 30 deletions(-) create mode 100644 orgui/app/test/test_config_nexus_layout.py create mode 100644 orgui/app/test/test_integration_options_roundtrip.py diff --git a/orgui/app/config_data.py b/orgui/app/config_data.py index aa7fc35..8b64279 100644 --- a/orgui/app/config_data.py +++ b/orgui/app/config_data.py @@ -31,6 +31,13 @@ SCHEMA_VERSION = 1 +#: Layout version of the integration_corrections group. Version 2 is the +#: typed layout that replaced a single JSON string. +CORRECTIONS_SCHEMA_VERSION = 2 + +#: Layout version of the roi_integration group. +ROI_SCHEMA_VERSION = 1 + def _json_value(value): if isinstance(value, dict): @@ -58,6 +65,12 @@ class CorrectionState: use_background: bool = False use_solid_angle: bool = False use_polarization: bool = False + # Tri-state on purpose: ``None`` is "this configuration predates the + # switch being stored", which must leave the GUI as it is rather than + # silently turning the correction off. + use_lorentz: bool | None = None + use_footprint: bool | None = None + use_normalization: bool | None = None repair_masked_pixels: bool = False repair_max_component_pixels: int | None = None repair_max_span: int | None = None @@ -260,6 +273,228 @@ def reflections_from_nxdict(nxdict): ] +#: Region-of-interest option group -> the unit attributes written beside its +#: values. Anything absent here is dimensionless. +_ROI_UNITS = { + "region": {"@unit": "px"}, + "advanced": {"@sample_size_unit": "m", "@offset_unit": "px"}, + "rocking_scan": {"@unit": "rlu"}, +} + + +def _nx_group(mapping, units=None): + """A NeXus subgroup from a flat mapping, skipping ``None`` values.""" + group = {"@NX_class": "NXcollection"} + group.update(units or {}) + for key, value in mapping.items(): + if value is None: + continue + group[key] = value + return group + + +def _plain(value): + """Strip the numpy and bytes wrappers that HDF5 hands back.""" + if isinstance(value, bytes): + return value.decode() + if isinstance(value, np.ndarray): + if value.shape == (): + return _plain(value[()]) + return [_plain(item) for item in value] + if isinstance(value, np.generic): + return value.item() + return value + + +def _read_group(nxdict, name): + """Values of one subgroup, without its NeXus bookkeeping keys.""" + group = nxdict.get(name) or {} + return { + key: _plain(value) + for key, value in group.items() + if not key.startswith("@") + } + + +def corrections_to_nxdict(state): + """Serialize a :class:`CorrectionState` as a typed NeXus group. + + Replaces the single opaque JSON string this used to be written as. Every + value is its own dataset, so a stored configuration can be read in an + HDF5 browser and units can sit beside the numbers that have them. + + ``None`` and empty sequences are written by *omission*: HDF5 has no null, + and absence already means "not recorded" to the reader. + + :param CorrectionState state: The state to serialize. + :rtype: dict + :raises ValueError: If ``uncertainty_provenance`` is not flat. + """ + if any( + isinstance(value, (dict, list, tuple)) + for value in state.uncertainty_provenance.values() + ): + raise ValueError( + "uncertainty_provenance must be a flat mapping of scalars; a " + "nested value cannot be written as a NeXus group" + ) + nxdict = { + "@NX_class": "NXcollection", + "@orgui_schema_version": CORRECTIONS_SCHEMA_VERSION, + "switches": _nx_group({ + "use_mask": state.use_mask, + "use_background": state.use_background, + "use_solid_angle": state.use_solid_angle, + "use_polarization": state.use_polarization, + "use_lorentz": state.use_lorentz, + "use_footprint": state.use_footprint, + "use_normalization": state.use_normalization, + }), + "normalization": _nx_group( + {"normalize_exposure": state.normalize_exposure} + ), + "pixel_repair": _nx_group({ + "enabled": state.repair_masked_pixels, + "max_component_pixels": state.repair_max_component_pixels, + "max_span": state.repair_max_span, + "radius": state.repair_radius, + "min_valid_neighbors": state.repair_min_valid_neighbors, + "use_pyfai_gaps": state.repair_use_pyfai_gaps, + "gap_size_px": state.repair_gap_size_px, + }), + "assets": _nx_group({ + "mask": state.mask_asset, + "background": state.background_asset, + "background_variance": state.background_variance_asset, + }), + } + if state.monitor_corrections: + nxdict["normalization"]["monitor_corrections"] = _string_array( + list(state.monitor_corrections) + ) + if state.excluded_frames: + nxdict["excluded_frames"] = np.asarray( + state.excluded_frames, dtype=np.int64 + ) + if state.uncertainty_provenance: + nxdict["uncertainty_provenance"] = _nx_group( + dict(state.uncertainty_provenance) + ) + return nxdict + + +def corrections_from_nxdict(nxdict): + """Rebuild a :class:`CorrectionState` from its NeXus group. + + Datasets this version does not know are ignored, so a configuration + written by a newer orGUI still loads. + + :param dict nxdict: The ``integration_corrections`` group. + :rtype: CorrectionState + """ + switches = _read_group(nxdict, "switches") + normalization = _read_group(nxdict, "normalization") + repair = _read_group(nxdict, "pixel_repair") + assets = _read_group(nxdict, "assets") + excluded = _plain(nxdict.get("excluded_frames")) + values = { + "use_mask": bool(switches.get("use_mask", False)), + "use_background": bool(switches.get("use_background", False)), + "use_solid_angle": bool(switches.get("use_solid_angle", False)), + "use_polarization": bool(switches.get("use_polarization", False)), + "repair_masked_pixels": bool(repair.get("enabled", False)), + "repair_use_pyfai_gaps": bool(repair.get("use_pyfai_gaps", True)), + "repair_gap_size_px": int(repair.get("gap_size_px", 1)), + "normalize_exposure": bool( + normalization.get("normalize_exposure", True) + ), + # Written with _string_array, i.e. a uint8 matrix, so it needs the + # matching reader rather than a plain tuple(). + "monitor_corrections": tuple( + _read_string_array(normalization.get("monitor_corrections", [])) + ), + "excluded_frames": tuple(int(v) for v in (excluded or ())), + "uncertainty_provenance": _read_group(nxdict, "uncertainty_provenance"), + } + for name in ("use_lorentz", "use_footprint", "use_normalization"): + if name in switches: + values[name] = bool(switches[name]) + for name, key in ( + ("repair_max_component_pixels", "max_component_pixels"), + ("repair_max_span", "max_span"), + ("repair_radius", "radius"), + ("repair_min_valid_neighbors", "min_valid_neighbors"), + ): + if key in repair: + values[name] = int(repair[key]) + for name, key in ( + ("mask_asset", "mask"), + ("background_asset", "background"), + ("background_variance_asset", "background_variance"), + ): + if key in assets: + values[name] = str(assets[key]) + return CorrectionState(**values) + + +def roi_to_nxdict(state): + """Serialize a :class:`ROIState` as a typed NeXus group. + + :param ROIState state: The settings to serialize. + :rtype: dict + """ + nxdict = { + "@NX_class": "NXcollection", + "@orgui_schema_version": ROI_SCHEMA_VERSION, + } + for name in ("region", "advanced", "rocking_scan"): + values = getattr(state, name) + if values: + nxdict[name] = _nx_group(dict(values), _ROI_UNITS[name]) + return nxdict + + +def roi_from_nxdict(nxdict): + """Rebuild a :class:`ROIState` from its NeXus group. + + :param dict nxdict: The ``roi_integration`` group, or ``None`` for a + configuration written before these settings were stored. + :rtype: ROIState + """ + nxdict = nxdict or {} + return ROIState( + region=_read_group(nxdict, "region"), + advanced=_read_group(nxdict, "advanced"), + rocking_scan=_read_group(nxdict, "rocking_scan"), + ) + + +@dataclass +class ROIState: + """Region-of-interest settings that decide what a scan integrates. + + Held as the same dictionaries :class:`~orgui.app.QScanSelector.QScanSelector` + already speaks, so a new control in the options dialog reaches the file + without a change here. The NeXus layout below is typed and carries units; + this is only the carrier. + + An empty dictionary means "not recorded", which is what every + configuration written before these settings were stored looks like. + """ + + #: Nominal sizes and the automatic-sizing switches, pixels. + region: dict = field(default_factory=dict) + #: The advanced options dialog: sample size in meter, offsets in pixels. + advanced: dict = field(default_factory=dict) + #: Rocking-scan ``s`` sampling, r.l.u. ``delta_s`` is the effective value + #: after the resolution clipping of ``onRoSChanged``, not what was typed. + rocking_scan: dict = field(default_factory=dict) + + def is_empty(self): + """True when nothing was recorded, so nothing should be restored.""" + return not (self.region or self.advanced or self.rocking_scan) + + @dataclass class ConfigData: """Physical application state persisted with scans and integrations.""" @@ -281,6 +516,7 @@ class ConfigData: refraction_index: float = 1.0 reference_reflections: list = field(default_factory=list) corrections: CorrectionState = field(default_factory=CorrectionState) + roi: ROIState = field(default_factory=ROIState) orgui: dict = field(default_factory=dict) @classmethod @@ -369,6 +605,7 @@ def from_gui(cls, gui): ub_calculator = HKLVlieg.UBCalculator(cell, ub_widget.ubCal.getEnergy()) ub_calculator.setU(ub_widget.ubCal.getU()) corrections = CorrectionState() + roi = ROIState() if hasattr(gui, "scanSelector"): options = gui.scanSelector.get_integration_options() repair = getattr(getattr(gui, "maskManager", None), "settings", None) @@ -380,8 +617,11 @@ def from_gui(cls, gui): use_mask=bool(options.get("mask", False)) or repair_enabled, use_background=getattr(gui, "background_image", None) is not None, - use_solid_angle=bool(options.get("solidAngle", False)), + use_solid_angle=bool(options.get("solid_angle", False)), use_polarization=bool(options.get("polarization", False)), + use_lorentz=bool(options.get("lorentz", False)), + use_footprint=bool(options.get("footprint", False)), + use_normalization=bool(options.get("normalization", False)), repair_masked_pixels=repair_enabled, repair_max_component_pixels=getattr( repair, "max_component_pixels", None @@ -411,6 +651,11 @@ def from_gui(cls, gui): ) ), ) + roi = ROIState( + region=dict(options.get("region", {})), + advanced=dict(options.get("advanced", {})), + rocking_scan=dict(options.get("rocking_scan", {})), + ) return cls( detector=ub_widget.detectorCal, unit_cell=cell, @@ -424,6 +669,7 @@ def from_gui(cls, gui): refraction_index=getattr(ub_widget, "n", 1.0), reference_reflections=reflections, corrections=corrections, + roi=roi, ) def apply_to_gui(self, gui): @@ -461,13 +707,26 @@ def apply_to_gui(self, gui): if hasattr(gui, "reflectionSel"): gui.reflectionSel.setReflections(self.reference_reflections) if hasattr(gui, "scanSelector"): - gui.scanSelector.set_integration_options( - { - "mask": self.corrections.use_mask, - "solidAngle": self.corrections.use_solid_angle, - "polarization": self.corrections.use_polarization, - } - ) + options = { + "mask": self.corrections.use_mask, + "solid_angle": self.corrections.use_solid_angle, + "polarization": self.corrections.use_polarization, + } + # Only switches this configuration actually recorded. A file + # written before they were stored leaves them as the user has + # them, rather than silently turning a correction off. + for name, value in ( + ("lorentz", self.corrections.use_lorentz), + ("footprint", self.corrections.use_footprint), + ("normalization", self.corrections.use_normalization), + ): + if value is not None: + options[name] = value + for name in ("region", "advanced", "rocking_scan"): + values = getattr(self.roi, name) + if values: + options[name] = dict(values) + gui.scanSelector.set_integration_options(options) gui.reconstruction_normalize_exposure = self.corrections.normalize_exposure gui.reconstruction_monitor_corrections = self.corrections.monitor_corrections if ( @@ -541,12 +800,10 @@ def to_nxdict(self, role="scan", source=None): "@wavelength_unit": "Angstrom", }, "refraction_index": self.refraction_index, - "integration_corrections": { - "@NX_class": "NXcollection", - "json": json.dumps( - self.corrections.to_dict(), sort_keys=True - ), - }, + "integration_corrections": corrections_to_nxdict( + self.corrections + ), + "roi_integration": roi_to_nxdict(self.roi), **self.orgui, }, } @@ -566,16 +823,18 @@ def from_nxdict(cls, nxdict): ub_calculator = HKLVlieg.UBCalculator(unit_cell, energy) ub_calculator.setU(np.asarray(nxdict["sample"]["orientation_matrix"])) diffrac = nxdict.get("orgui", {}).get("diffractometer", {}) - correction_json = ( - nxdict.get("orgui", {}) - .get("integration_corrections", {}) - .get("json", "{}") - ) - correction_json = np.asarray(correction_json) - if correction_json.shape == (): - correction_json = correction_json.item() - if isinstance(correction_json, bytes): - correction_json = correction_json.decode() + corrections_group = nxdict.get("orgui", {}).get( + "integration_corrections", {} + ) or {} + if "json" in corrections_group: + # Configurations written before the typed layout. Still read, so + # that existing databases keep loading; never written any more. + corrections = CorrectionState.from_dict( + json.loads(_as_text(_plain(corrections_group["json"])) or "{}") + ) + else: + corrections = corrections_from_nxdict(corrections_group) + roi = roi_from_nxdict(nxdict.get("orgui", {}).get("roi_integration")) return cls( detector=detector, unit_cell=unit_cell, @@ -590,7 +849,8 @@ def from_nxdict(cls, nxdict): nxdict.get("orgui", {}).get("refraction_index", 1.0) ), reference_reflections=reflections_from_nxdict(nxdict), - corrections=CorrectionState.from_dict(json.loads(correction_json)), + corrections=corrections, + roi=roi, ) def to_json_dict(self) -> dict[str, Any]: diff --git a/orgui/app/peak1Dintegr.py b/orgui/app/peak1Dintegr.py index 9421173..af4979a 100644 --- a/orgui/app/peak1Dintegr.py +++ b/orgui/app/peak1Dintegr.py @@ -55,7 +55,12 @@ import traceback from . import qutils -from .config_data import ConfigData, detector_from_nxdict +from .config_data import ( + ConfigData, + CorrectionState, + corrections_from_nxdict, + detector_from_nxdict, +) from .. import resources from .. import logger_utils from ..datautils.xrayutils.corrections import beamprofile @@ -1489,11 +1494,18 @@ def _rocking_solid_angle_mean(self, detector, scangroup, cnters, x, y): be established. :rtype: tuple """ + path = scangroup.name + "/configuration/orgui/integration_corrections" try: - raw = scangroup["configuration/orgui/integration_corrections/json"][()] - if isinstance(raw, bytes): - raw = raw.decode() - was_applied = bool(json.loads(str(raw)).get("use_solid_angle", False)) + group = h5todict(self.database.nxfile, path) + if "json" in group: + # Configurations written before the typed layout. + raw = group["json"] + if isinstance(raw, bytes): + raw = raw.decode() + state = CorrectionState.from_dict(json.loads(str(raw))) + else: + state = corrections_from_nxdict(group) + was_applied = bool(state.use_solid_angle) except Exception: logger.warning( "Cannot tell from this scan's stored configuration whether the " diff --git a/orgui/app/test/test_config_nexus_layout.py b/orgui/app/test/test_config_nexus_layout.py new file mode 100644 index 0000000..0d567a5 --- /dev/null +++ b/orgui/app/test/test_config_nexus_layout.py @@ -0,0 +1,337 @@ +"""Round-trip tests for the typed NeXus layout of the stored settings. + +The integration corrections used to be persisted as one opaque JSON string, +and the region-of-interest settings -- the advanced options, the ``s`` +sampling and the automatic-sizing switches -- were not persisted at all. A +saved dataset was therefore not reproducible: reloading its configuration +restored three of the seven correction switches and none of the region +settings. + +These tests pin the replacement: every field of :class:`CorrectionState` and +every region-of-interest setting survives +``state -> NeXus dict -> HDF5 file -> NeXus dict -> state``, and a +configuration written before the change still loads. +""" + +import dataclasses +import json + +import numpy as np +import pytest +from silx.io.dictdump import dicttonx, nxtodict + +from orgui.app.config_data import ( + CORRECTIONS_SCHEMA_VERSION, + CorrectionState, + ROIState, + corrections_from_nxdict, + corrections_to_nxdict, + roi_from_nxdict, + roi_to_nxdict, +) + + +def _through_file(nxdict, tmp_path, name="group"): + """Write one group to HDF5 and read it back, as a database would.""" + path = tmp_path / f"{name}.h5" + dicttonx({name: nxdict}, path) + return nxtodict(path)[name] + + +def _populated_corrections(): + """A correction state with every field away from its default.""" + return CorrectionState( + use_mask=True, + use_background=True, + use_solid_angle=True, + use_polarization=True, + use_lorentz=True, + use_footprint=True, + use_normalization=False, + repair_masked_pixels=True, + repair_max_component_pixels=4, + repair_max_span=3, + repair_radius=2, + repair_min_valid_neighbors=6, + repair_use_pyfai_gaps=False, + repair_gap_size_px=6, + normalize_exposure=False, + monitor_corrections=("mondio", "ic1"), + excluded_frames=(3, 7, 11), + mask_asset="assets/mask", + background_asset="assets/bg", + background_variance_asset="assets/bgvar", + uncertainty_provenance={"background": "measured"}, + ) + + +def test_every_correction_field_is_covered_by_the_layout(): + """Adding a field to the dataclass must not silently stop being saved. + + This is the guard that makes the round-trip test below meaningful: it + fails when a new field is added to :class:`CorrectionState` and left out + of the test fixture, rather than the field quietly never being written. + """ + populated = _populated_corrections() + default = CorrectionState() + unexercised = [ + entry.name + for entry in dataclasses.fields(CorrectionState) + if getattr(populated, entry.name) == getattr(default, entry.name) + ] + assert not unexercised, ( + f"these CorrectionState fields are still at their default in the " + f"round-trip fixture, so the test would not notice them being " + f"dropped: {unexercised}" + ) + + +def test_a_fully_populated_correction_state_round_trips(tmp_path): + """Every field survives state -> NeXus -> file -> NeXus -> state.""" + state = _populated_corrections() + + loaded = corrections_from_nxdict( + _through_file(corrections_to_nxdict(state), tmp_path, "corrections") + ) + + assert loaded == state + + +def test_a_default_correction_state_round_trips(tmp_path): + """The all-defaults case, where most datasets are absent entirely. + + ``None`` and empty sequences are encoded by omission, so this is the + path where the reader has to supply the defaults itself. + """ + state = CorrectionState() + nxdict = corrections_to_nxdict(state) + + # The optional values must genuinely not be in the file. + assert "excluded_frames" not in nxdict + assert "monitor_corrections" not in nxdict["normalization"] + assert "max_span" not in nxdict["pixel_repair"] + assert "use_lorentz" not in nxdict["switches"] + assert nxdict["assets"] == {"@NX_class": "NXcollection"} + + loaded = corrections_from_nxdict( + _through_file(nxdict, tmp_path, "corrections") + ) + assert loaded == state + + +def test_the_unrecorded_switches_stay_none(tmp_path): + """``None`` must not collapse to ``False`` on the way through a file. + + It is the difference between "this configuration says the Lorentz + correction was off" and "this configuration predates the switch being + stored", and only the first may change a loaded GUI. + """ + loaded = corrections_from_nxdict( + _through_file(corrections_to_nxdict(CorrectionState()), tmp_path) + ) + + assert loaded.use_lorentz is None + assert loaded.use_footprint is None + assert loaded.use_normalization is None + + +def test_a_nested_uncertainty_provenance_is_refused(): + """Rather than silently reintroducing a serialized blob.""" + state = CorrectionState(uncertainty_provenance={"a": {"b": 1}}) + + with pytest.raises(ValueError, match="flat mapping"): + corrections_to_nxdict(state) + + +def test_unknown_datasets_are_ignored(tmp_path): + """A configuration from a newer orGUI still loads.""" + nxdict = corrections_to_nxdict(CorrectionState(use_mask=True)) + nxdict["switches"]["use_something_new"] = True + nxdict["a_whole_new_group"] = {"@NX_class": "NXcollection", "value": 1} + + loaded = corrections_from_nxdict(_through_file(nxdict, tmp_path)) + + assert loaded.use_mask is True + + +def test_the_layout_is_browsable_and_versioned(tmp_path): + """The point of replacing the JSON string: values are real datasets.""" + nxdict = _through_file( + corrections_to_nxdict(_populated_corrections()), tmp_path + ) + + assert nxdict["@orgui_schema_version"] == CORRECTIONS_SCHEMA_VERSION + assert "json" not in nxdict + assert bool(nxdict["switches"]["use_solid_angle"]) is True + assert int(nxdict["pixel_repair"]["radius"]) == 2 + assert np.array_equal(np.asarray(nxdict["excluded_frames"]), [3, 7, 11]) + + +def test_a_legacy_json_configuration_still_reads(): + """Existing databases must keep loading; only writing changed.""" + legacy = CorrectionState( + use_mask=True, use_solid_angle=True, monitor_corrections=("mondio",) + ) + group = { + "@NX_class": "NXcollection", + "json": json.dumps(legacy.to_dict(), sort_keys=True), + } + + # The reader branch ConfigData.from_nxdict takes for such a file. + loaded = CorrectionState.from_dict(json.loads(group["json"])) + + assert loaded.use_mask is True + assert loaded.use_solid_angle is True + assert loaded.monitor_corrections == ("mondio",) + assert loaded.use_lorentz is None + + +def test_the_region_of_interest_settings_round_trip(tmp_path): + """Advanced options, region sizes and s sampling all survive.""" + state = ROIState( + region={ + "hsize": 20.0, + "vsize": 6.0, + "left": 20.0, + "right": 20.0, + "top": 5.0, + "bottom": 5.0, + "auto_hsize": True, + "auto_vsize": False, + }, + advanced={ + "detector_inclination": True, + "project_sample_size": True, + "offset_x": 1.5, + "offset_y": -2.5, + "sample_size_x": 5e-4, + "sample_size_y": 7e-3, + "sample_size_z": 5e-3, + "factor": 1.25, + "fitted_background": True, + "fitted_background_order": 2, + }, + rocking_scan={"delta_s": 0.0019001086, "max_s": 5.0}, + ) + + loaded = roi_from_nxdict(_through_file(roi_to_nxdict(state), tmp_path, "roi")) + + assert loaded.region == pytest.approx(state.region) + assert loaded.advanced == pytest.approx(state.advanced) + assert loaded.rocking_scan == pytest.approx(state.rocking_scan) + # delta_s is the value the auto-pinning settled on, to full precision. + assert loaded.rocking_scan["delta_s"] == pytest.approx( + 0.0019001086, rel=0, abs=1e-12 + ) + + +def test_the_region_of_interest_units_are_recorded(tmp_path): + """A reader should not have to guess metres from pixels.""" + state = ROIState( + region={"hsize": 20.0}, + advanced={"sample_size_x": 5e-4, "offset_x": 1.0}, + rocking_scan={"delta_s": 0.002}, + ) + + nxdict = roi_to_nxdict(state) + + assert nxdict["region"]["@unit"] == "px" + assert nxdict["advanced"]["@sample_size_unit"] == "m" + assert nxdict["advanced"]["@offset_unit"] == "px" + assert nxdict["rocking_scan"]["@unit"] == "rlu" + + +def test_an_absent_region_group_is_empty_not_fatal(): + """Every configuration written before this change has no such group.""" + state = roi_from_nxdict(None) + + assert state.is_empty() + assert state.region == {} + assert roi_to_nxdict(state) == { + "@NX_class": "NXcollection", + "@orgui_schema_version": 1, + } + + +def _fake_gui(options, captured): + """A stand-in exposing only what ConfigData reads and writes.""" + from types import SimpleNamespace + + from orgui.datautils.xrayutils import CTRcalc, DetectorCalibration, HKLVlieg + + cell = CTRcalc.UnitCell([3.0, 3.0, 5.0], [90.0, 90.0, 90.0], name="bulk") + ub = HKLVlieg.UBCalculator(cell, 15.0) + ub.defaultU_GID() + return SimpleNamespace( + ubcalc=SimpleNamespace( + detectorCal=DetectorCalibration.Detector2D_SXRD(), + crystal=cell, + ubCal=ub, + mu=0.0, + chi=0.0, + phi=0.0, + n=1.0, + ), + scanSelector=SimpleNamespace( + get_integration_options=lambda: dict(options), + set_integration_options=captured.update, + ), + ) + + +def test_the_gui_switches_and_region_settings_survive_from_gui_to_apply(): + """Closes the gap: reloading restored 3 of 7 switches and no region. + + ``from_gui`` must collect every switch and the region settings, and + ``apply_to_gui`` must put them all back. + """ + from orgui.app.config_data import ConfigData + + options = { + "mask": True, + "solid_angle": True, + "polarization": True, + "lorentz": True, + "footprint": True, + "normalization": True, + "region": {"hsize": 21.0, "auto_vsize": True}, + "advanced": {"detector_inclination": True, "sample_size_x": 5e-4}, + "rocking_scan": {"delta_s": 0.0019, "max_s": 5.0}, + } + captured = {} + config = ConfigData.from_gui(_fake_gui(options, captured)) + + assert config.corrections.use_lorentz is True + assert config.corrections.use_footprint is True + assert config.corrections.use_normalization is True + assert config.roi.region["hsize"] == 21.0 + assert config.roi.rocking_scan["delta_s"] == pytest.approx(0.0019) + + config.apply_to_gui(_fake_gui(options, captured)) + + for name in ("mask", "solid_angle", "polarization", + "lorentz", "footprint", "normalization"): + assert captured[name] is True, name + assert captured["region"]["hsize"] == 21.0 + assert captured["advanced"]["sample_size_x"] == pytest.approx(5e-4) + assert captured["rocking_scan"]["max_s"] == 5.0 + + +def test_an_old_configuration_does_not_touch_the_unrecorded_switches(): + """A file predating the switches must leave the GUI as the user has it.""" + from orgui.app.config_data import ConfigData + + captured = {} + config = ConfigData.from_gui(_fake_gui({"mask": True}, captured)) + config.corrections.use_lorentz = None + config.corrections.use_footprint = None + config.corrections.use_normalization = None + config.roi = ROIState() + + captured.clear() + config.apply_to_gui(_fake_gui({}, captured)) + + assert "lorentz" not in captured + assert "footprint" not in captured + assert "normalization" not in captured + assert "region" not in captured diff --git a/orgui/app/test/test_integration_options_roundtrip.py b/orgui/app/test/test_integration_options_roundtrip.py new file mode 100644 index 0000000..c2126e3 --- /dev/null +++ b/orgui/app/test/test_integration_options_roundtrip.py @@ -0,0 +1,202 @@ +"""The integration settings must survive a save and a reload, in full. + +Before the typed NeXus layout, reloading a configuration restored three of +the seven correction switches and none of the region-of-interest settings, so +a saved dataset could not be reproduced from what was stored with it. These +tests drive the real :class:`~orgui.app.QScanSelector.QScanSelector` widgets +rather than a stand-in, because the gap was in the wiring between the widgets +and :class:`~orgui.app.config_data.ConfigData`, not in either alone. +""" + +import numpy as np +import pytest +from silx.gui import qt + +from orgui.app.config_data import ROIState, roi_from_nxdict, roi_to_nxdict +from orgui.app.QScanSelector import QScanSelector + + +@pytest.fixture(scope="module") +def qapp(): + application = qt.QApplication.instance() + if application is None: + application = qt.QApplication([]) + return application + + +class _StubMainWindow(qt.QMainWindow): + """The smallest parent a selector will build against. + + ``QScanSelector.__init__`` reads two attributes off its parent -- the + plot the alpha slider drives and the current image legend -- and also + passes it to child dialogs as a Qt parent, so it has to be a real + widget rather than a namespace. + """ + + def __init__(self): + super().__init__() + from silx.gui.plot import Plot2D + + self.centralPlot = Plot2D(parent=self) + self.currentAddImageLabel = "image" + + +def _make_selector(): + parent = _StubMainWindow() + selector = QScanSelector(parent) + # Keep the parent alive for as long as the selector needs it. + selector._test_parent = parent + return selector + + +@pytest.fixture +def selector(qapp): + widget = _make_selector() + yield widget + widget.deleteLater() + + +#: Every switch off its default, so a dropped one is visible. +SWITCHES = { + "mask": True, + "solid_angle": True, + "polarization": True, + "lorentz": True, + "footprint": True, + "normalization": True, +} + +REGION = { + "hsize": 21.0, + "vsize": 7.0, + "left": 19.0, + "right": 18.0, + "top": 4.0, + "bottom": 3.0, + "auto_hsize": True, + "auto_vsize": True, +} + +ADVANCED = { + "detector_inclination": True, + "project_sample_size": True, + "offset_x": 1.5, + "offset_y": -2.5, + "sample_size_x": 5e-4, + "sample_size_y": 7e-3, + "sample_size_z": 5e-3, + "factor": 1.25, + "fitted_background": True, + "fitted_background_order": 2, +} + +ROCKING = {"delta_s": 0.0019, "max_s": 5.0} + + +def test_every_setting_round_trips_through_the_widgets(selector): + """Set everything, read it back, get the same thing.""" + selector.set_integration_options({ + **SWITCHES, + "region": REGION, + "advanced": ADVANCED, + "rocking_scan": ROCKING, + }) + + options = selector.get_integration_options() + + for name, value in SWITCHES.items(): + assert options[name] == value, name + assert options["region"] == pytest.approx(REGION) + assert options["advanced"] == pytest.approx(ADVANCED) + assert options["rocking_scan"] == pytest.approx(ROCKING) + + +def test_restoring_delta_s_does_not_re_clip_it(selector): + """The stored value is already the resolution-clipped one. + + Assigning it normally triggers ``onRoSChanged``, which solves for the + step that keeps rod points one pixel apart and writes the result back. + Re-running that against whatever scan is loaded would not restore what + was saved, so the setter blocks it. + """ + selector.set_integration_options({"rocking_scan": {"delta_s": 0.0019}}) + + assert selector.roscanDeltaS.value() == pytest.approx(0.0019) + + +def test_the_settings_survive_a_nexus_group(selector, tmp_path): + """Widgets -> ROIState -> NeXus -> ROIState -> widgets is the identity.""" + from silx.io.dictdump import dicttonx, nxtodict + + selector.set_integration_options({ + "region": REGION, "advanced": ADVANCED, "rocking_scan": ROCKING, + }) + options = selector.get_integration_options() + state = ROIState( + region=dict(options["region"]), + advanced=dict(options["advanced"]), + rocking_scan=dict(options["rocking_scan"]), + ) + + path = tmp_path / "roi.h5" + dicttonx({"roi": roi_to_nxdict(state)}, path) + restored = roi_from_nxdict(nxtodict(path)["roi"]) + + fresh = _make_selector() + try: + fresh.set_integration_options({ + "region": restored.region, + "advanced": restored.advanced, + "rocking_scan": restored.rocking_scan, + }) + after = fresh.get_integration_options() + finally: + fresh.deleteLater() + + assert after["region"] == pytest.approx(options["region"]) + assert after["advanced"] == pytest.approx(options["advanced"]) + assert after["rocking_scan"] == pytest.approx(options["rocking_scan"]) + + +def test_a_legacy_option_dictionary_is_still_accepted(selector): + """``startup_setup.py``-style scripts keep working for a cycle.""" + with pytest.deprecated_call(): + selector.set_integration_options({ + "solidAngle": True, + "advanced": { + "DetectorInclination": True, + "ProjectSampleSize": True, + "xoffset": 0.0, + "yoffset": 0.0, + "sizeX": 5e-4, + "sizeY": 7e-3, + "sizeZ": 5e-3, + "factor": 1.0, + }, + }) + + options = selector.get_integration_options() + assert options["solid_angle"] is True + assert options["advanced"]["detector_inclination"] is True + assert options["advanced"]["sample_size_y"] == pytest.approx(7e-3) + # and the legacy spelling still reads off the result, with a warning + with pytest.deprecated_call(): + assert options["solidAngle"] is True + + +def test_an_unknown_option_is_ignored(selector): + """What lets an older orGUI open a newer configuration.""" + selector.set_integration_options({"mask": True, "from_the_future": 1}) + + assert selector.get_integration_options()["mask"] is True + + +def test_sample_sizes_are_stored_in_meter(selector): + """The widgets show micrometer; the dictionary and the file use meter.""" + selector.set_integration_options({"advanced": {**ADVANCED, + "sample_size_y": 7e-3}}) + + assert selector.get_integration_options()["advanced"][ + "sample_size_y" + ] == pytest.approx(7e-3) + assert np.isclose(selector.roioptions._sizeYsample.value(), 7000.0) diff --git a/orgui/app/test/test_peak1Dintegr.py b/orgui/app/test/test_peak1Dintegr.py index 1b3d033..0964521 100644 --- a/orgui/app/test/test_peak1Dintegr.py +++ b/orgui/app/test/test_peak1Dintegr.py @@ -581,3 +581,94 @@ def test_a_scan_without_a_stored_detector_gives_none(): assert RockingPeakIntegrator._stored_detector( integrator, handle["/61.1"] ) is None + + +def _scan_with_corrections(folder, corrections_group): + """Write one scan whose stored configuration holds ``corrections_group``.""" + from silx.io.dictdump import dicttonx + + path = os.path.join(folder, "scan.h5") + dicttonx( + {"configuration": {"orgui": { + "integration_corrections": corrections_group}}}, + path, + h5path="/61.1", + update_mode="add", + ) + return path + + +def _solid_angle_detector(): + """A calibrated geometry the solid-angle correction can be measured on.""" + pyFAI = pytest.importorskip("pyFAI") + from orgui.datautils.xrayutils import DetectorCalibration + + detector = DetectorCalibration.Detector2D_SXRD() + detector.detector = pyFAI.detectors.Detector( + pixel1=172e-6, pixel2=172e-6, max_shape=(619, 487) + ) + detector.dist = 0.5 + detector.poni1, detector.poni2 = 0.05, 0.04 + detector.rot1 = detector.rot2 = detector.rot3 = 0.0 + detector.set_energy(15.0) + return detector + + +@pytest.mark.parametrize("applied_at_extraction", [True, False]) +def test_the_typed_corrections_group_is_read_back(applied_at_extraction): + """The F6 compensation must survive the layout it is stored in. + + Whether the solid-angle correction was applied to the intensity is read + from the configuration written at extraction time. When that group + changed from a single JSON string to typed datasets, this reader kept + parsing the JSON and silently stopped compensating -- the unit tests + passed because the layout and the reduction were only ever tested apart. + So this writes the group with the *current* writer and reads it back + through the reducer. + """ + from orgui.app.config_data import CorrectionState, corrections_to_nxdict + + detector = _solid_angle_detector() + state = CorrectionState(use_solid_angle=applied_at_extraction) + cnters = {"hsize": np.full(3, 20.0), "vsize": np.full(3, 5.0)} + + with tempfile.TemporaryDirectory() as folder: + path = _scan_with_corrections(folder, corrections_to_nxdict(state)) + with h5py.File(path, "r") as handle: + integrator = SimpleNamespace( + database=SimpleNamespace(nxfile=handle, config_target=None) + ) + mean, compensated = RockingPeakIntegrator._rocking_solid_angle_mean( + integrator, detector, handle["/61.1"], cnters, + x=np.full(3, 240.0), y=np.full(3, 300.0), + ) + + assert compensated is applied_at_extraction + if applied_at_extraction: + assert mean is not None + assert np.all(np.isfinite(mean)) and np.all(mean > 0.0) + else: + assert mean is None + + +def test_a_legacy_json_corrections_group_is_still_read(): + """Databases written before the typed layout must keep reducing.""" + import json + + detector = _solid_angle_detector() + cnters = {"hsize": np.full(2, 20.0), "vsize": np.full(2, 5.0)} + legacy = {"json": json.dumps({"use_solid_angle": True})} + + with tempfile.TemporaryDirectory() as folder: + path = _scan_with_corrections(folder, legacy) + with h5py.File(path, "r") as handle: + integrator = SimpleNamespace( + database=SimpleNamespace(nxfile=handle, config_target=None) + ) + mean, compensated = RockingPeakIntegrator._rocking_solid_angle_mean( + integrator, detector, handle["/61.1"], cnters, + x=np.full(2, 240.0), y=np.full(2, 300.0), + ) + + assert compensated is True + assert mean is not None and np.all(mean > 0.0) From f025b73dd62d432853631869c882afd24f3dbf4e Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 13:57:01 -0400 Subject: [PATCH 13/33] feat(phys): record C_solid_angle with the integrated intensities --- orgui/app/orGUI.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/orgui/app/orGUI.py b/orgui/app/orGUI.py index e0fe200..08cc7b9 100644 --- a/orgui/app/orGUI.py +++ b/orgui/app/orGUI.py @@ -6711,6 +6711,10 @@ def sumImage(i): "C_flux_on_sample": factors1.get("C_flux_on_sample"), "C_illum_area": factors1.get("C_illum_area"), "C_norm": factors1.get("C_norm"), + # Divided back out of F2_hkl rather than applied to it + # (finding F6); recorded so a saved rod can be put + # back on the intensity scale. + "C_solid_angle": factors1.get("C_solid_angle"), }, "pixelcoord": { "@NX_class": "NXdetector", @@ -6771,6 +6775,10 @@ def sumImage(i): "C_flux_on_sample": factors2.get("C_flux_on_sample"), "C_illum_area": factors2.get("C_illum_area"), "C_norm": factors2.get("C_norm"), + # Divided back out of F2_hkl rather than applied to it + # (finding F6); recorded so a saved rod can be put + # back on the intensity scale. + "C_solid_angle": factors2.get("C_solid_angle"), }, "pixelcoord": { "@NX_class": "NXdetector", From 68998fcb137cf5c509e69bb62493430b5f4cfea7 Mon Sep 17 00:00:00 2001 From: "T. Fuchs" Date: Fri, 11 Sep 2026 16:59:42 -0400 Subject: [PATCH 14/33] docs: record the real-data validation and the stored integration settings --- CHANGELOG.md | 50 ++++++++ doc/design/ctr_structure_factor_handover.md | 48 ++++++-- doc/design/ctr_structure_factor_scale.md | 128 +++++++++++++++++--- doc/source/release_notes.rst | 10 +- orgui/app/config_data.py | 8 +- 5 files changed, 218 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901d048..71ac0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,32 @@ Scientific and analysis additions: a backend that declares ``exposure_time`` in ``auxillary_counters``; a missing counter is skipped and recorded rather than failing the integration. Existing configuration files load unchanged. + This equivalence has now been checked on real data as well as in simulation: + on a LaNiO3 rod measured both ways, the two modes agree to a median 1.03 + along the rod, with the remaining spread explained by the resolution + difference between them rather than by a normalization. At a Bragg peak on + the same rod they differ by a factor of five, which is expected — a + stationary region cannot collect a peak much wider than itself. + +- **The settings that decide what was integrated are now stored with every + integration.** A reduction could previously not be reproduced from its own + output: the background ROI margins, the automatic-sizing switches, the + projected sample size, and the effective ``delta_s`` of a rocking scan were + not written anywhere. They are now saved under + ``configuration/orgui/roi_integration`` as typed groups carrying their + units — ``region`` (sizes, margins, automatic sizing), ``advanced`` (sample + size in meter, offsets, the inclination and projection switches) and + ``rocking_scan`` (``delta_s`` and ``max_s``, with ``delta_s`` stored as the + value actually used after the resolution clipping, not as typed). The + correction switches move the same way: ``integration_corrections`` is now a + group of typed datasets instead of one opaque JSON string, so a stored + configuration can be read in any HDF5 browser. Databases written with the + JSON layout are still read. The Lorentz, footprint and normalization + switches are recorded alongside the others, and a configuration that predates + them leaves those controls as the user has them rather than silently + switching a correction off. ``C_solid_angle`` is now saved beside + ``F2_hkl``, because the structure factor divides it back out and a saved rod + cannot otherwise be returned to the intensity scale. - **All correction factors collected into one package.** Every factor between detector counts and a structure factor now lives in @@ -742,6 +768,30 @@ A ***critical bug*** was fixed that affects bulk CTR calculations: exposure bounds already read each segment's own arm, and they now agree with the arm the rest of the program sees. +- **Reducing a rocking scan now uses the detector geometry stored with that + scan**, instead of whatever calibration the application happens to hold. + *This changes saved numbers for any reduction run against a different + calibration than the one the curves were extracted with.* The out-of-plane + acceptance and the solid-angle compensation are properties of the geometry + the data was taken with, so reducing from a script that has not loaded the + matching configuration, or after another calibration was opened, silently + produced a wrongly scaled ``F2_hkl``: on a real scan it scaled every + acceptance by 2.3, with nothing in the output to show for it. A scan that + stores no detector geometry warns and is left on the acceptance-blind scale + rather than using the wrong one. + +- **Integrating a scan whose rod leaves the detector no longer fails.** Frames + where the rod never reaches the detector carry region positions that are not + finite; the solid-angle and polarization corrections rejected those with + ``OverflowError`` or ``ValueError`` and aborted the integration. They now + yield a neutral factor for such frames, which carry no counts anyway. A + region size that is finite but not positive is still an error. + +- **The command-line interface starts again.** ``--cli`` selected the Qt + ``minimal`` platform plugin, which has no font database, so loading the + icon font raised ``FontError`` before any batch script could run. It now + uses ``offscreen``, and an explicitly set ``QT_QPA_PLATFORM`` is respected. + GUI changes: - The "Scan data" and "Reciprocal space navigation" panels no longer reserve diff --git a/doc/design/ctr_structure_factor_handover.md b/doc/design/ctr_structure_factor_handover.md index 78da839..43752b1 100644 --- a/doc/design/ctr_structure_factor_handover.md +++ b/doc/design/ctr_structure_factor_handover.md @@ -1,17 +1,20 @@ # CTR structure-factor scale: implementation status and handover -> **Status as of 2026-09-10.** Branch `claude/ctr-structure-factor-9633bc`, -> seven commits ahead of `master`, nothing pushed. +> **Status as of 2026-09-11.** Branch `claude/ctr-structure-factor-9633bc`, +> thirteen commits ahead of `master`, nothing pushed. > > The physics analysis is complete and quantified, and the reduction is now > **wired in**: a rocking scan and a stationary scan of the same rod come out > with the same `F2_hkl`, asserted by > `test_scan_mode_equivalence.py::test_rocking_and_stationary_paths_agree`. -> This **changed saved numbers in both modes**. -> [#82](https://github.com/tifuchs/orGUI/issues/82) is closed up to the -> real-data check of section 6 and `C_det` (F7), the one mechanism simulation -> cannot test. [#15](https://github.com/tifuchs/orGUI/issues/15) still needs -> the two absolute-scale inputs of section 6. +> This **changed saved numbers in both modes**. The **real-data check has now +> been done** — LaNiO3 scan 61, both modes run from the raw images, agreeing +> to a median 1.033 along the CTR; see +> [`ctr_structure_factor_scale.md`](ctr_structure_factor_scale.md) section +> 5.1. [#82](https://github.com/tifuchs/orGUI/issues/82) is closed up to +> `C_det` (F7), which that check bounds but does not model. +> [#15](https://github.com/tifuchs/orGUI/issues/15) still needs the two +> absolute-scale inputs of section 6. > > This document is the handover: what exists, how to run it, what to do next, > and which of my predictions turned out wrong. The physics itself is in @@ -249,8 +252,12 @@ be left behind cannot come back unnoticed. paths; the reciprocal-space reconstruction still evaluates the polarization per pixel at the calibrated position. * **`C_det` (F7)** is the only mechanism that can still break mode equivalence - after the wiring, and it cannot be validated on simulated data. It needs the - real-data overlap comparison. + after the wiring, and it cannot be validated on simulated data. The + real-data overlap comparison has now been run and **bounds** it: not + detectable against 3 % scatter along a CTR whose regions cover the peak, a + factor of 5 at the Bragg peak on the same rod. Modelling it is still open, + and the bound is an upper limit for one rod on one sample, not a general + result. ## 7. Predictions that turned out wrong @@ -313,3 +320,26 @@ pytest orgui/app/test/test_scan_mode_equivalence.py These five do not need the native extension. Everything else in the suite may, so use section 3 before concluding anything from a failure. + +The real-data check of `ctr_structure_factor_scale.md` section 5.1 needs data +that is not in the repository (LaNiO3 scan 61). To repeat it on another pair +of scans, run orGUI with `--nogui -i