diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index f1d8e46..8be61b1 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -16,15 +16,21 @@ concurrency: env: CIBW_SKIP: "*-musllinux_*" CIBW_BUILD_FRONTEND: "build" - CIBW_CONFIG_SETTINGS_WINDOWS: "setup-args=--vsenv" + CIBW_CONFIG_SETTINGS: "setup-args=-Dstatic_xxhash=true" + CIBW_CONFIG_SETTINGS_WINDOWS: 'setup-args="--vsenv -Dstatic_xxhash=true"' CIBW_TEST_ENVIRONMENT: "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 MPLBACKEND=Agg QT_QPA_PLATFORM=minimal" CIBW_TEST_REQUIRES: pytest + CIBW_TEST_EXTRAS: extendedfilesupport,symmetry CIBW_TEST_COMMAND: >- python -c "from orgui.datautils.xrayutils import CTRuc; assert CTRuc.HAS_CPP_ACCEL; CTRuc.set_accel_backend('cpp'); print('CTR_ACCEL_BACKEND=', CTRuc.CTR_ACCEL_BACKEND)" && pytest --pyargs orgui.datautils.xrayutils.test.test_CTRcalc + orgui.datautils.xrayutils.test.test_CTRsymmetry + orgui.datautils.xrayutils.test.test_CTRresolution + orgui.datautils.xrayutils.test.test_CTRoptical_profile + orgui.datautils.xrayutils.test.test_scattering_factor_cache CIBW_TEST_SKIP: "*-manylinux_aarch64" jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb7771f..c83738b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,15 +121,21 @@ jobs: CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" CIBW_SKIP: "*-musllinux_*" CIBW_BUILD_FRONTEND: "build" - CIBW_CONFIG_SETTINGS_WINDOWS: "setup-args=--vsenv" + CIBW_CONFIG_SETTINGS: "setup-args=-Dstatic_xxhash=true" + CIBW_CONFIG_SETTINGS_WINDOWS: 'setup-args="--vsenv -Dstatic_xxhash=true"' CIBW_TEST_ENVIRONMENT: "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 MPLBACKEND=Agg QT_QPA_PLATFORM=minimal" CIBW_TEST_REQUIRES: pytest + CIBW_TEST_EXTRAS: extendedfilesupport,symmetry CIBW_TEST_COMMAND: >- python -c "from orgui.datautils.xrayutils import CTRuc; assert CTRuc.HAS_CPP_ACCEL; CTRuc.set_accel_backend('cpp'); print('CTR_ACCEL_BACKEND=', CTRuc.CTR_ACCEL_BACKEND)" && pytest --pyargs orgui.datautils.xrayutils.test.test_CTRcalc + orgui.datautils.xrayutils.test.test_CTRsymmetry + orgui.datautils.xrayutils.test.test_CTRresolution + orgui.datautils.xrayutils.test.test_CTRoptical_profile + orgui.datautils.xrayutils.test.test_scattering_factor_cache CIBW_TEST_SKIP: "*-manylinux_aarch64" with: output-dir: wheelhouse diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea4603c..5fcb393 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,7 @@ jobs: run: python -m pip install --upgrade pip pytest - name: Install package - run: python -m pip install ".[speedup]" + run: python -m pip install ".[speedup,extendedfilesupport,symmetry]" - name: Run xrayutils tests run: >- @@ -63,3 +63,7 @@ jobs: orgui.datautils.xrayutils.test.test_HKLcalc orgui.datautils.xrayutils.test.test_DetectorCalibration orgui.datautils.xrayutils.test.test_CTRcalc + orgui.datautils.xrayutils.test.test_CTRsymmetry + orgui.datautils.xrayutils.test.test_CTRresolution + orgui.datautils.xrayutils.test.test_CTRoptical_profile + orgui.datautils.xrayutils.test.test_scattering_factor_cache diff --git a/.gitignore b/.gitignore index 93b0e18..15a3129 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ __pycache__/ dist/ build/ +subprojects/xxHash-0.8.2/ +subprojects/.wraplock orgui.egg-info/ orgui/_version.py benchmarks/roi_sum_results.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b64bfa1..980a086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,70 @@ This is the changelog for the software orGUI, written by Timo Fuchs +## [Unreleased] (2026-07-19) + +Scientific and analysis additions: + +- Added interface-based optical ``delta``/``beta`` profiles and layered + wavefield calculations for s- and p-polarized X-rays. Scalar and + multidimensional angle inputs are supported, and specular reflectivity can + be calculated for s, p, or unpolarized incidence. This provides the optical + wavefield foundation for future distorted-wave Born approximation (DWBA) + calculations; the full distorted-wave scattering amplitude is not included + yet. +- Added optional CTR intensity-resolution modeling with constant or + gamma-dependent box and Gaussian functions. Calculations can convolve + irregular existing L points or sample the crystal structure factor with + deterministic quadrature. +- Added Poisson-distributed surface occupancies and coherent out-of-plane + epitaxy/strain coupling for film-interface models. +- Added py3Dmol atom-sphere rendering for Jupyter notebooks. ``plot3d`` now + selects py3Dmol automatically in a notebook, can be directed to either + py3Dmol or Mayavi explicitly, and can incrementally add unit cells to a + shared viewer. Covalent radii are interpreted consistently by both backends + and can be adjusted with the dimensionless ``radius_scale`` parameter. +- ``PoissonSurface``'s basis now requires three parameters (``W``, ``alpha``, + ``offset``) instead of two. The previous two-parameter form was only ever + used in test code, so no migration path is provided; a saved ``.xtal``/ + ``.xpr`` file with a two-parameter ``PoissonSurface`` basis will fail to + load. +- Wyckoff-parameterized fit values are now interpreted as absolute atomic + positions instead of deltas relative to the Wyckoff position. Wyckoff + parameter fitting was development-only and never part of a release, so no + migration is provided. + +Scientific correctness and performance fixes: + +- Optical profiles now preserve areal optical content under surface-normal + strain, keep ionic forward scattering factors for charged species, avoid + merging layers beyond the requested z tolerance, and remain finite at the + exact p-polarized critical-angle limit. +- Optical reflectivity and full wavefield calculations now evaluate angle + arrays with NumPy-vectorized kernels while preserving the caller's input + shape. +- Corrected film and epitaxy-interface anchoring, stacking, and support + ownership across strained interfaces. +- Fixed missing support in one-dimensional electron-density calculations and + corrected atomic-coordinate stacking when splitting unit cells into layers. +- Added a C++ electron-density backend and bounded caches for atomic form + factors, anomalous scattering factors, and accelerated form-factor lookup. + +A ***critical bug*** was fixed that affects bulk CTR calculations: + +- ``UnitCell.F_bulk``'s semi-infinite geometric lattice sum used the raw, + untransformed ``l`` index instead of the index converted by + ``refHKLTransform`` when computing the out-of-plane attenuation phase. This + was only correct for the default case where a component uses its own bulk + cell as the reference (no ``reference_uc`` set); any explicit + ``reference_uc`` whose out-of-plane reciprocal axis differs from the + bulk's — including a plain scale difference between the reference and bulk + out-of-plane axis length, not only a rotated or reindexed reference — gave + incorrect bulk structure-factor amplitudes. This bug was present in both + the accelerated (numba/C++) and plain-Python code paths in all previous + released versions, up to and including v1.5.0. See the CTR structure-factor + documentation for details. + + ## [1.5.0] (2026-06-07) [532b60b](https://github.com/tifuchs/orGUI/commit/532b60bab3073ae9f0ff063a0e119aa9e9957857)...[c574bdf](https://github.com/tifuchs/orGUI/commit/c574bdf0bd5af6fa8258d108e7355c579bfef857) diff --git a/THIRD_PARTY_LICENSES/pybind11-BSD-3-Clause.txt b/THIRD_PARTY_LICENSES/pybind11-BSD-3-Clause.txt new file mode 100644 index 0000000..e466b0d --- /dev/null +++ b/THIRD_PARTY_LICENSES/pybind11-BSD-3-Clause.txt @@ -0,0 +1,29 @@ +Copyright (c) 2016 Wenzel Jakob , All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Please also refer to the file .github/CONTRIBUTING.md, which clarifies licensing of +external contributions to this project including patches, pull requests, etc. diff --git a/THIRD_PARTY_LICENSES/xxHash-BSD-2-Clause.txt b/THIRD_PARTY_LICENSES/xxHash-BSD-2-Clause.txt new file mode 100644 index 0000000..e4c5da7 --- /dev/null +++ b/THIRD_PARTY_LICENSES/xxHash-BSD-2-Clause.txt @@ -0,0 +1,26 @@ +xxHash Library +Copyright (c) 2012-2021 Yann Collet +All rights reserved. + +BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php) + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/benchmarks/ctr_zdensity_accel.ipynb b/benchmarks/ctr_zdensity_accel.ipynb new file mode 100644 index 0000000..4dd80e3 --- /dev/null +++ b/benchmarks/ctr_zdensity_accel.ipynb @@ -0,0 +1,208 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "density-title", + "metadata": {}, + "source": [ + "# Unit-Cell Electron-Density C++ Benchmark\n", + "\n", + "Benchmark `UnitCell.zDensity_G` for the original NumPy implementation and the C++ kernel. The C++ kernel is intentionally the only accelerated path for this calculation; special electron-density callback models retain the Python/SciPy implementation.\n", + "\n", + "Timings exclude one warmup call and measure the standard atomic-density expression only." + ] + }, + { + "cell_type": "markdown", + "id": "density-setup", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Start Jupyter from `benchmarks/` with an installed `orgui` package. The benchmark uses a synthetic unit cell with two coherent domains, so it does not depend on external files." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "density-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import statistics\n", + "import time\n", + "\n", + "import numpy as np\n", + "from matplotlib import pyplot as plt\n", + "\n", + "from orgui import __version__, get_build_config\n", + "from orgui.datautils.xrayutils import CTRcalc, CTRuc\n", + "\n", + "\n", + "print(\"orGUI version\", __version__)\n", + "print(get_build_config())\n", + "print(f\"C++ density acceleration available: {CTRuc.HAS_CPP_ACCEL}\")" + ] + }, + { + "cell_type": "markdown", + "id": "density-helpers-title", + "metadata": {}, + "source": [ + "## Benchmark Helpers\n", + "\n", + "The profile coordinate `z` is in Angstrom. `h` and `k` are reference-frame reciprocal coordinates in r.l.u.; the result is complex electron density in electrons per Angstrom cubed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "density-helpers", + "metadata": {}, + "outputs": [], + "source": [ + "def make_cell():\n", + " cell = CTRcalc.UnitCell([3.0, 4.0, 5.0], [90.0, 90.0, 90.0])\n", + " cell.addAtom(\"C\", [0.2, 0.3, 0.4], 0.12, 0.18, 0.8)\n", + " cell.addAtom(\"O\", [0.7, 0.6, 0.1], 0.21, 0.24, 0.5)\n", + " cell.setEnergy(10000.0)\n", + " shifted_domain = np.vstack((np.identity(3).T, [0.1, -0.2, 0.15])).T\n", + " cell.coherentDomainMatrix = [cell.coherentDomainMatrix[0], shifted_domain]\n", + " cell.coherentDomainOccupancy = [0.65, 0.35]\n", + " return cell\n", + "\n", + "\n", + "def time_call(func, repeats=9, warmups=1):\n", + " for _ in range(warmups):\n", + " func()\n", + " timings = []\n", + " for _ in range(repeats):\n", + " start = time.perf_counter()\n", + " func()\n", + " timings.append(time.perf_counter() - start)\n", + " return statistics.median(timings)\n", + "\n", + "\n", + "def format_seconds(value):\n", + " if value < 1e-3:\n", + " return f\"{value * 1e6:.1f} us\"\n", + " return f\"{value * 1e3:.2f} ms\"" + ] + }, + { + "cell_type": "markdown", + "id": "density-correctness-title", + "metadata": {}, + "source": [ + "## Correctness Check\n", + "\n", + "Before timing, verify that the C++ kernel matches the NumPy reference for a multi-domain cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "density-correctness", + "metadata": {}, + "outputs": [], + "source": [ + "if not CTRuc.HAS_CPP_ACCEL:\n", + " raise RuntimeError(\"Build or install orGUI with the C++ extension to run this benchmark.\")\n", + "\n", + "cell = make_cell()\n", + "z = np.linspace(-2.0, 7.0, 2_000)\n", + "CTRuc.set_accel_backend(\"numpy\")\n", + "reference = cell.zDensity_G(z, 0.37, -0.22)\n", + "CTRuc.set_accel_backend(\"cpp\")\n", + "accelerated = cell.zDensity_G(z, 0.37, -0.22)\n", + "np.testing.assert_allclose(accelerated, reference, rtol=1e-12, atol=1e-12)\n", + "print(\"C++ zDensity_G matches NumPy for the multi-domain test cell.\")" + ] + }, + { + "cell_type": "markdown", + "id": "density-benchmark-title", + "metadata": {}, + "source": [ + "## Benchmark Electron-Density Profiles\n", + "\n", + "Use the median runtime across repeated evaluations. The profile lengths span interactive plotting through dense optical-profile grids." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "density-benchmark", + "metadata": {}, + "outputs": [], + "source": [ + "cell = make_cell()\n", + "results = []\n", + "for points in (200, 2_000, 20_000, 200_000):\n", + " z = np.linspace(-2.0, 7.0, points)\n", + " timings = {}\n", + " for backend in (\"numpy\", \"cpp\"):\n", + " CTRuc.set_accel_backend(backend)\n", + " timings[backend] = time_call(lambda: cell.zDensity_G(z, 0.37, -0.22))\n", + " results.append({\n", + " \"points\": points,\n", + " \"numpy_s\": timings[\"numpy\"],\n", + " \"cpp_s\": timings[\"cpp\"],\n", + " \"speedup\": timings[\"numpy\"] / timings[\"cpp\"],\n", + " })\n", + "\n", + "print(f\"{'points':>10} {'numpy median':>16} {'cpp median':>16} {'cpp speedup':>13}\")\n", + "for row in results:\n", + " print(\n", + " f\"{row['points']:10d} {format_seconds(row['numpy_s']):>16} \"\n", + " f\"{format_seconds(row['cpp_s']):>16} {row['speedup']:12.2f}x\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "density-results-title", + "metadata": {}, + "source": [ + "## Benchmark Results\n", + "\n", + "The logarithmic scale shows how the native kernel changes runtime as the number of sampled profile points grows." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "density-plot", + "metadata": {}, + "outputs": [], + "source": [ + "labels = [f\"{row['points']:,}\" for row in results]\n", + "x = np.arange(len(results))\n", + "width = 0.36\n", + "fig, ax = plt.subplots(figsize=(8, 4.5))\n", + "ax.bar(x - width / 2, [row['numpy_s'] for row in results], width, label=\"NumPy\")\n", + "ax.bar(x + width / 2, [row['cpp_s'] for row in results], width, label=\"C++\")\n", + "ax.set_yscale(\"log\")\n", + "ax.set_xticks(x, labels)\n", + "ax.set_xlabel(\"z profile points\")\n", + "ax.set_ylabel(\"Median runtime [s]\")\n", + "ax.grid(axis=\"y\", which=\"both\", alpha=0.25)\n", + "ax.legend()\n", + "fig.tight_layout()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/benchmarks/verify_ctr_numba_host.py b/benchmarks/verify_ctr_numba_host.py index b029107..8aa2e81 100644 --- a/benchmarks/verify_ctr_numba_host.py +++ b/benchmarks/verify_ctr_numba_host.py @@ -69,6 +69,17 @@ def median_time(func, repeats: int) -> float: return statistics.median(timings) +def median_cold_cache_time(func, repeats: int) -> float: + """Return median C++ timing with the cache cleared before each call.""" + timings = [] + for _ in range(repeats): + CTRuc.clear_form_factor_cache() + start = time.perf_counter() + func() + timings.append(time.perf_counter() - start) + return statistics.median(timings) + + def load_crystal(): """Load the bundled CTR regression crystal.""" xpr_path = FIXTURE_DIR / "0V12_calculated.xpr" @@ -178,6 +189,40 @@ def run_benchmark(repeats: int = 5) -> None: ) print(text) + if CTRuc.HAS_CPP_ACCEL: + set_backend("cpp") + + def cache_func(): + """Evaluate the cacheable canonical unit-cell amplitude.""" + return xtal.uc_bulk.F_uc(h, k, l_values) + + old_budget = CTRuc.form_factor_cache_stats()["budget_bytes"] + try: + CTRuc.clear_form_factor_cache() + CTRuc.reset_form_factor_cache_stats() + CTRuc.set_form_factor_cache_budget(0) + uncached = median_time(cache_func, repeats) + CTRuc.set_form_factor_cache_budget(old_budget) + cold = median_cold_cache_time(cache_func, repeats) + CTRuc.clear_form_factor_cache() + cache_func() + warm = median_time(cache_func, repeats) + stats = CTRuc.form_factor_cache_stats() + finally: + CTRuc.clear_form_factor_cache() + CTRuc.set_form_factor_cache_budget(old_budget) + expected = CTRuc.form_factor_cache_expected_bytes( + n_points, + stats["species_entries"], + ) + print( + "F_uc cache " + f"uncached={uncached:.6g}s cold={cold:.6g}s warm={warm:.6g}s " + f"hits={stats['hits']} misses={stats['misses']} " + f"evictions={stats['evictions']} resident={stats['resident_bytes']}B " + f"expected={expected}B" + ) + def main() -> None: """Print environment information and run the benchmark.""" diff --git a/doc/plot_epitaxy_strain_coupling_offset_density.py b/doc/plot_epitaxy_strain_coupling_offset_density.py new file mode 100644 index 0000000..4b8a269 --- /dev/null +++ b/doc/plot_epitaxy_strain_coupling_offset_density.py @@ -0,0 +1,358 @@ +"""Generate the RuO2/TiO2 strain-coupling documentation figure.""" + +from __future__ import annotations + +import copy +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from orgui.datautils.xrayutils import CTRcalc + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +MODEL_PATH = ( + REPOSITORY_ROOT + / "examples" + / "CTR" + / "RuO2_TiO2_Poisson_etching.xtal" +) +OUTPUT_PATH = ( + REPOSITORY_ROOT + / "doc" + / "source" + / "_static" + / "epitaxy_strain_coupling_offset_density.png" +) + +FILM_THICKNESS_ANGSTROM = 150.0 +INTERFACE_WIDTH_ANGSTROM = 10.0 +TAIL_PROBABILITY = 1e-4 +OFFSETS = (0.0, 0.02, 0.17) +STRAIN_COUPLINGS = (0.0, 0.5, 1.0) +Z = np.linspace(-52.0, 60.0, 6500) + +COLORS = { + "total": "#3f454c", + "bottom": "#2878b5", + "top": "#e1812c", + "displacement": "#6f4c9b", +} + + +def _prepare_template(): + template = CTRcalc.SXRDCrystal.fromFile(MODEL_PATH) + interface = template["TiO2toRuO2"] + film = template["RuO2"] + width_cells = INTERFACE_WIDTH_ANGSTROM / interface.uc_bottom.a[2] + layer_spacing = film.unitcell.a[2] / len(film.layers) + film_layers = int(round(FILM_THICKNESS_ANGSTROM / layer_spacing)) + return template, width_cells, film_layers + + +def _make_model( + template, + width_cells, + film_layers, + strain_coupling, + offset, +): + bulk = copy.deepcopy(template["bulk"]) + interface = copy.deepcopy(template["TiO2toRuO2"]) + film = copy.deepcopy(template["RuO2"]) + + interface.profile = CTRcalc.SkellamProfile( + width=width_cells, + asymmetry=0.0, + tail_probability=TAIL_PROBABILITY, + ) + interface.basis[:] = [width_cells, 0.0, strain_coupling, offset] + interface.basis_0[:] = interface.basis + + film.basis[0] = film_layers + film.basis_0[:] = film.basis + + model = CTRcalc.SXRDCrystal( + bulk, + interface, + film, + stacking=np.array([1, 2]), + atten=template.atten, + ) + model.apply_stacking() + return model, bulk, interface, film + + +def _densities(bulk, interface, film): + bottom = np.real(bulk.zDensity_G_asbulk(Z, 0, 0)) + bottom += sum( + np.real(layer.zDensity_G(Z, 0, 0)) + for layer in interface.bottom_layers + ) + top = sum( + np.real(layer.zDensity_G(Z, 0, 0)) + for layer in interface.top_layers + ) + top += np.real(film.zDensity_G(Z, 0, 0)) + return bottom, top, bottom + top + + +def _shared_displacement(reference, shifted, c_bulk): + """Return lower-material addition positions and offset displacements.""" + positions = [] + displacements = [] + occupancies = [] + for base_layer, shifted_layer in zip( + reference.bottom_layers, + shifted.bottom_layers, + ): + domain_occupancy = np.asarray( + base_layer.coherentDomainOccupancy, + dtype=float, + ) + for index in np.flatnonzero(domain_occupancy > 0.0): + base_matrix = base_layer.coherentDomainMatrix[index] + shifted_matrix = shifted_layer.coherentDomainMatrix[index] + positions.append(base_matrix[2, 3] * c_bulk) + displacements.append( + (shifted_matrix[2, 3] - base_matrix[2, 3]) * c_bulk + ) + occupancies.append(domain_occupancy[index]) + + positions = np.asarray(positions) + displacements = np.asarray(displacements) + occupancies = np.asarray(occupancies) + order = np.argsort(positions) + return positions[order], displacements[order], occupancies[order] + + +def _annotate_offset(ax, delta): + start_x = -28.0 + end_x = 28.0 + arrow_y = 3.02 + ax.annotate( + "", + xy=(end_x, arrow_y), + xytext=(start_x, arrow_y), + arrowprops={ + "arrowstyle": "->", + "color": COLORS["displacement"], + "linewidth": 1.7, + }, + annotation_clip=False, + ) + ax.text( + (start_x + end_x) / 2.0, + arrow_y + 0.15, + rf"deep bulk $\longrightarrow$ film: " + rf"$\Delta={delta:.2f}\,\mathrm{{\AA}}$", + color=COLORS["displacement"], + ha="center", + va="bottom", + fontsize=9, + ) + + +def _plot(): + template, width_cells, film_layers = _prepare_template() + c_bulk = template["bulk"].a[2] + data = {} + interfaces = {} + + for offset in OFFSETS: + for strain_coupling in STRAIN_COUPLINGS: + _, bulk, interface, film = _make_model( + template, + width_cells, + film_layers, + strain_coupling, + offset, + ) + data[offset, strain_coupling] = _densities( + bulk, + interface, + film, + ) + interfaces[offset, strain_coupling] = interface + + density_max = max( + np.max(total) + for bottom, top, total in data.values() + ) + density_scale = 0.82 / density_max + row_baselines = dict(zip(STRAIN_COUPLINGS, (0.0, 1.0, 2.0))) + + figure = plt.figure(figsize=(18.0, 8.2), layout="constrained") + grid = figure.add_gridspec(2, 3, height_ratios=(4.4, 1.15)) + density_axes = [] + displacement_axes = [] + + for column, offset in enumerate(OFFSETS): + density_ax = figure.add_subplot(grid[0, column]) + displacement_ax = figure.add_subplot( + grid[1, column], + sharex=density_ax, + ) + density_axes.append(density_ax) + displacement_axes.append(displacement_ax) + + for strain_coupling in STRAIN_COUPLINGS: + bottom, top, total = data[offset, strain_coupling] + baseline = row_baselines[strain_coupling] + density_ax.plot( + Z, + baseline + density_scale * total, + color=COLORS["total"], + linewidth=1.45, + label=( + "total" + if column == 0 and strain_coupling == 0.0 + else None + ), + zorder=3, + ) + density_ax.plot( + Z, + baseline + density_scale * bottom, + color=COLORS["bottom"], + linewidth=1.0, + alpha=0.92, + label=( + r"TiO$_2$ contribution" + if column == 0 and strain_coupling == 0.0 + else None + ), + zorder=2, + ) + density_ax.plot( + Z, + baseline + density_scale * top, + color=COLORS["top"], + linewidth=1.0, + alpha=0.92, + label=( + r"RuO$_2$ contribution" + if column == 0 and strain_coupling == 0.0 + else None + ), + zorder=2, + ) + density_ax.text( + -50.0, + baseline + 0.09, + rf"$\kappa={strain_coupling:g}$", + ha="left", + va="bottom", + fontsize=10, + bbox={ + "boxstyle": "round,pad=0.18", + "facecolor": "white", + "edgecolor": "none", + "alpha": 0.88, + }, + zorder=5, + ) + + delta = offset * c_bulk + _annotate_offset(density_ax, delta) + density_ax.axvline( + 0.0, + color="#777777", + linestyle=":", + linewidth=0.9, + zorder=1, + ) + density_ax.set_xlim(Z[0], Z[-1]) + density_ax.set_ylim(-0.08, 3.38) + density_ax.set_yticks([0.0, 1.0, 2.0]) + density_ax.set_yticklabels([]) + density_ax.set_title( + rf"$o={offset:g}$" + + "\n" + + rf"$\Delta=o\,c_{{\mathrm{{TiO_2}}}}={delta:.2f}\,$Å", + fontsize=12, + ) + density_ax.grid(axis="x", alpha=0.15) + density_ax.tick_params(axis="x", labelbottom=False) + + for strain_coupling in STRAIN_COUPLINGS: + reference = interfaces[0.0, strain_coupling] + shifted = interfaces[offset, strain_coupling] + positions, displacement, occupancy = _shared_displacement( + reference, + shifted, + c_bulk, + ) + visible = occupancy >= TAIL_PROBABILITY + displacement_ax.plot( + positions[visible], + displacement[visible], + marker="o", + markersize=2.7, + linewidth=1.2, + color=COLORS["displacement"], + alpha=0.35 + 0.6 * strain_coupling, + label=( + rf"$\kappa={strain_coupling:g}$" + if column == 2 + else None + ), + ) + plateau = strain_coupling * delta + displacement_ax.text( + 58.0, + plateau + 0.025, + rf"${plateau:.2f}$", + color=COLORS["displacement"], + ha="right", + va="bottom", + fontsize=8.5, + ) + + displacement_ax.axhline(0.0, color="#777777", linewidth=0.7) + displacement_ax.axvline( + 0.0, + color="#777777", + linestyle=":", + linewidth=0.9, + ) + displacement_ax.set_ylim(-0.06, 1.24) + displacement_ax.set_xlabel(r"$z$ / Å") + displacement_ax.grid(alpha=0.15) + + density_axes[0].set_ylabel( + r"Vertically separated $\rho_{00}(z)$" + "\n" + r"(common density scale)" + ) + displacement_axes[0].set_ylabel( + r"Shared offset" + "\n" + r"$u_C(z)$ / Å" + ) + density_axes[0].legend( + loc="upper right", + frameon=False, + fontsize=9, + ) + displacement_axes[-1].legend( + loc="upper left", + frameon=False, + fontsize=8.5, + ) + figure.suptitle( + r"RuO$_2$/TiO$_2$: density and occupancy-mediated " + r"interface expansion" + "\n" + r"15 nm RuO$_2$ film; 1 nm interface width", + fontsize=15, + ) + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + figure.savefig(OUTPUT_PATH, dpi=180, bbox_inches="tight") + plt.close(figure) + + +if __name__ == "__main__": + _plot() diff --git a/doc/source/_static/epitaxy_strain_coupling_offset_density.png b/doc/source/_static/epitaxy_strain_coupling_offset_density.png new file mode 100644 index 0000000..f07f08c Binary files /dev/null and b/doc/source/_static/epitaxy_strain_coupling_offset_density.png differ diff --git a/doc/source/_static/poisson_surface_occupancies.svg b/doc/source/_static/poisson_surface_occupancies.svg new file mode 100644 index 0000000..f75b6cb --- /dev/null +++ b/doc/source/_static/poisson_surface_occupancies.svg @@ -0,0 +1,69 @@ + + Poisson surface occupancy decomposition + Stacked bars show covered Film occupancy and exposed surface occupancy for structural-layer offsets minus one through five. + + + + + + + + + + + 0 + 0.25 + 0.50 + 0.75 + 1.00 + occupancy + z / structural layers + + + + + + + + + + + + + + + + + + + + + + −1 + 0 + 1 + 2 + 3 + 4 + 5 + + + covered Film, cᵢ + + exposed surface, sᵢ + diff --git a/doc/source/api.rst b/doc/source/api.rst index ff93af9..124716f 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -5,6 +5,7 @@ API :maxdepth: 1 ctr_structure_factors + ctr_resolution wyckoff_fitting GUI API diff --git a/doc/source/benchmarks/.virtual_documents/ctr_accel_backends.ipynb b/doc/source/benchmarks/.virtual_documents/ctr_accel_backends.ipynb new file mode 100644 index 0000000..f3863ce --- /dev/null +++ b/doc/source/benchmarks/.virtual_documents/ctr_accel_backends.ipynb @@ -0,0 +1,320 @@ + + + + + + + + + +from pathlib import Path +import statistics +import time + +import numpy as np + + +BENCHMARK_DIR = Path.cwd().resolve() +FIXTURE_CANDIDATES = ( + BENCHMARK_DIR / "fixtures", + BENCHMARK_DIR.parents[2] / "benchmarks" / "fixtures", +) +FIXTURE_DIR = next((path for path in FIXTURE_CANDIDATES if path.is_dir()), None) +if FIXTURE_DIR is None: + raise FileNotFoundError( + "Could not find benchmark fixtures. Run from benchmarks/ or execute " + "this notebook from doc/source/benchmarks/." + ) +XPR_PATH = FIXTURE_DIR / "0V12_calculated.xpr" + +if (BENCHMARK_DIR / "orgui").is_dir(): + raise RuntimeError( + "Notebook is running from the repository root. Start Jupyter from " + "benchmarks/ to benchmark the installed package." + ) + +print(f"Benchmark directory: {BENCHMARK_DIR}") +print(f"Fixture: {XPR_PATH}") + + + +!hostname + + + +from orgui import __version__, get_build_config +print("orGUI version", __version__) +print(get_build_config()) + + + +from orgui.datautils import util +from orgui.datautils.xrayutils import CTRcalc, CTRuc + + +AVAILABLE_BACKENDS = ["numpy"] +if CTRuc.HAS_CPP_ACCEL: + AVAILABLE_BACKENDS.append("cpp") +if CTRuc.ctr_numba_accel_available(): + AVAILABLE_BACKENDS.append("numba") + +print(f"Default CTR acceleration backend: {CTRuc.CTR_ACCEL_BACKEND}") +print(f"Available CTR acceleration backends: {AVAILABLE_BACKENDS}") + + + + + + +def set_backend(backend): + CTRuc.set_accel_backend(backend) + + +def load_crystal(): + xtal = CTRcalc.SXRDCrystal.fromFile(XPR_PATH) + pt100 = CTRcalc.UnitCell( + [3.9242, 3.9242, 3.9242], + [90.0, 90.0, 90.0], + ) + xtal.setGlobalReferenceUnitCell( + pt100, + util.z_rotation(np.deg2rad(45.0)), + ) + return xtal + + +def make_rod(n_points): + l_values = np.ascontiguousarray( + np.linspace(0.05, 7.0, n_points), + dtype=np.float64, + ) + zeros = np.zeros_like(l_values) + return zeros, zeros, l_values + + +def time_call(func, repeats=7, warmups=1): + for _ in range(warmups): + func() + + timings = [] + for _ in range(repeats): + start = time.perf_counter() + func() + timings.append(time.perf_counter() - start) + + return { + "min_s": min(timings), + "median_s": statistics.median(timings), + "mean_s": statistics.mean(timings), + "repeats": repeats, + } + + +def format_seconds(value): + if value < 1e-3: + return f"{value * 1e6:8.1f} us" + if value < 1: + return f"{value * 1e3:8.2f} ms" + return f"{value:8.3f} s" + + +def cold_cache_time(func, repeats=7): + """Return a median with the global cache cleared before every call.""" + timings = [] + for _ in range(repeats): + CTRuc.clear_form_factor_cache() + start = time.perf_counter() + func() + timings.append(time.perf_counter() - start) + return statistics.median(timings) + + + + + + + +xtal = load_crystal() +h, k, l = make_rod(2_000) + +set_backend("numpy") +numpy_uc = xtal.uc_bulk.F_uc(h, k, l) +numpy_xtal = xtal.F(h, k, l) + +for backend in AVAILABLE_BACKENDS: + if backend == "numpy": + continue + set_backend(backend) + backend_uc = xtal.uc_bulk.F_uc(h, k, l) + backend_xtal = xtal.F(h, k, l) + np.testing.assert_allclose(backend_uc, numpy_uc, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(backend_xtal, numpy_xtal, rtol=1e-10, atol=1e-10) + +print("Available accelerated backends match NumPy for UnitCell.F_uc and SXRDCrystal.F") + + + + + + +def benchmark_structure_factor(n_points, repeats=7): + xtal = load_crystal() + h, k, l = make_rod(n_points) + + def run_unitcell(backend): + set_backend(backend) + return lambda: xtal.uc_bulk.F_uc(h, k, l) + + def run_crystal(backend): + set_backend(backend) + return lambda: xtal.F(h, k, l) + + rows = [] + for label, factory in ( + ("UnitCell.F_uc", run_unitcell), + ("SXRDCrystal.F", run_crystal), + ): + stats = { + backend: time_call(factory(backend), repeats=repeats) + for backend in AVAILABLE_BACKENDS + } + row = { + "benchmark": label, + "points": n_points, + } + for backend, backend_stats in stats.items(): + row[f"{backend}_median_s"] = backend_stats["median_s"] + row[f"{backend}_min_s"] = backend_stats["min_s"] + for backend in AVAILABLE_BACKENDS: + if backend == "numpy": + continue + row[f"{backend}_speedup"] = ( + row["numpy_median_s"] / row[f"{backend}_median_s"] + ) + rows.append(row) + return rows + + +results = [] +for n_points in (200, 2_000, 20_000): + results.extend(benchmark_structure_factor(n_points)) + +header = f"{'benchmark':<18} {'points':>8}" +for backend in AVAILABLE_BACKENDS: + header += f" {backend + ' median':>15}" +for backend in AVAILABLE_BACKENDS: + if backend != "numpy": + header += f" {backend + ' speedup':>14}" +print(header) +for row in results: + line = f"{row['benchmark']:<18} {row['points']:>8}" + for backend in AVAILABLE_BACKENDS: + line += f" {format_seconds(row[f'{backend}_median_s']):>15}" + for backend in AVAILABLE_BACKENDS: + if backend != "numpy": + line += f" {row[f'{backend}_speedup']:>13.2f}x" + print(line) + + + + + + +from matplotlib import pyplot as plt + +labels = [f"{row['benchmark']}\n{row['points']} pts" for row in results] +x = np.arange(len(labels)) +width = min(0.8 / len(AVAILABLE_BACKENDS), 0.28) +offsets = (np.arange(len(AVAILABLE_BACKENDS)) - (len(AVAILABLE_BACKENDS) - 1) / 2) * width + +fig, ax = plt.subplots(figsize=(11, 4.5)) +for offset, backend in zip(offsets, AVAILABLE_BACKENDS): + ax.bar( + x + offset, + [row[f"{backend}_median_s"] for row in results], + width, + label=backend, + ) + +ax.set_ylabel("Median runtime [s]") +ax.set_yscale("log") +ax.set_xticks(x) +ax.set_xticklabels(labels, rotation=30, ha="right") +ax.legend(title="Backend") +ax.grid(axis="y", which="both", alpha=0.25) +fig.tight_layout() + + + +def benchmark_form_factor_cache(n_points, repeats=7): + if not CTRuc.HAS_CPP_ACCEL: + return None + + xtal = load_crystal() + h, k, l = make_rod(n_points) + + def run_unitcell(): + return xtal.uc_bulk.F_uc(h, k, l) + + set_backend("cpp") + original_budget = CTRuc.form_factor_cache_stats()["budget_bytes"] + CTRuc.clear_form_factor_cache() + CTRuc.reset_form_factor_cache_stats() + try: + CTRuc.set_form_factor_cache_budget(0) + uncached = time_call(run_unitcell, repeats=repeats)["median_s"] + + CTRuc.set_form_factor_cache_budget(original_budget) + cold = cold_cache_time(run_unitcell, repeats=repeats) + + CTRuc.clear_form_factor_cache() + run_unitcell() + warm = time_call(run_unitcell, repeats=repeats, warmups=0)["median_s"] + stats = CTRuc.form_factor_cache_stats() + finally: + CTRuc.clear_form_factor_cache() + CTRuc.set_form_factor_cache_budget(original_budget) + + return { + "points": n_points, + "uncached_median_s": uncached, + "cold_median_s": cold, + "warm_median_s": warm, + "warm_speedup": uncached / warm, + "resident_bytes": stats["resident_bytes"], + "expected_bytes": CTRuc.form_factor_cache_expected_bytes( + n_points, stats["species_entries"] + ), + "hits": stats["hits"], + "misses": stats["misses"], + "evictions": stats["evictions"], + } + + +cache_results = [ + benchmark_form_factor_cache(n_points) + for n_points in (2_000, 20_000, 200_000) +] +cache_results = [result for result in cache_results if result is not None] + +if not cache_results: + print("C++ extension unavailable; cache benchmark skipped.") +else: + print( + f"{'points':>8} {'uncached':>14} {'cold':>14} {'warm':>14} " + f"{'speedup':>9} {'hits':>7} {'misses':>7} {'evictions':>10} " + f"{'resident':>12} {'expected':>12}" + ) + for row in cache_results: + print( + f"{row['points']:8d} " + f"{format_seconds(row['uncached_median_s']):>14} " + f"{format_seconds(row['cold_median_s']):>14} " + f"{format_seconds(row['warm_median_s']):>14} " + f"{row['warm_speedup']:8.2f}x " + f"{row['hits']:7d} {row['misses']:7d} {row['evictions']:10d} " + f"{row['resident_bytes']:11d}B {row['expected_bytes']:11d}B" + ) + + + diff --git a/doc/source/benchmarks/.virtual_documents/ctr_zdensity_accel.ipynb b/doc/source/benchmarks/.virtual_documents/ctr_zdensity_accel.ipynb new file mode 100644 index 0000000..2459620 --- /dev/null +++ b/doc/source/benchmarks/.virtual_documents/ctr_zdensity_accel.ipynb @@ -0,0 +1,113 @@ + + + + + + +import statistics +import time + +import numpy as np +from matplotlib import pyplot as plt + +from orgui import __version__, get_build_config +from orgui.datautils.xrayutils import CTRcalc, CTRuc + + +print("orGUI version", __version__) +print(get_build_config()) +print(f"C++ density acceleration available: {CTRuc.HAS_CPP_ACCEL}") + + + + + +def make_cell(): + cell = CTRcalc.UnitCell([3.0, 4.0, 5.0], [90.0, 90.0, 90.0]) + cell.addAtom("C", [0.2, 0.3, 0.4], 0.12, 0.18, 0.8) + cell.addAtom("O", [0.7, 0.6, 0.1], 0.21, 0.24, 0.5) + cell.setEnergy(10000.0) + shifted_domain = np.vstack((np.identity(3).T, [0.1, -0.2, 0.15])).T + cell.coherentDomainMatrix = [cell.coherentDomainMatrix[0], shifted_domain] + cell.coherentDomainOccupancy = [0.65, 0.35] + return cell + + +def time_call(func, repeats=9, warmups=1): + for _ in range(warmups): + func() + timings = [] + for _ in range(repeats): + start = time.perf_counter() + func() + timings.append(time.perf_counter() - start) + return statistics.median(timings) + + +def format_seconds(value): + if value < 1e-3: + return f"{value * 1e6:.1f} us" + return f"{value * 1e3:.2f} ms" + + + + + +if not CTRuc.HAS_CPP_ACCEL: + raise RuntimeError("Build or install orGUI with the C++ extension to run this benchmark.") + +cell = make_cell() +z = np.linspace(-2.0, 7.0, 2_000) +CTRuc.set_accel_backend("numpy") +reference = cell.zDensity_G(z, 0.37, -0.22) +CTRuc.set_accel_backend("cpp") +accelerated = cell.zDensity_G(z, 0.37, -0.22) +np.testing.assert_allclose(accelerated, reference, rtol=1e-12, atol=1e-12) +print("C++ zDensity_G matches NumPy for the multi-domain test cell.") + + + + + +cell = make_cell() +results = [] +for points in (200, 2_000, 20_000, 200_000): + z = np.linspace(-2.0, 7.0, points) + timings = {} + for backend in ("numpy", "cpp"): + CTRuc.set_accel_backend(backend) + timings[backend] = time_call(lambda: cell.zDensity_G(z, 0.37, -0.22)) + results.append({ + "points": points, + "numpy_s": timings["numpy"], + "cpp_s": timings["cpp"], + "speedup": timings["numpy"] / timings["cpp"], + }) + +print(f"{'points':>10} {'numpy median':>16} {'cpp median':>16} {'cpp speedup':>13}") +for row in results: + print( + f"{row['points']:10d} {format_seconds(row['numpy_s']):>16} " + f"{format_seconds(row['cpp_s']):>16} {row['speedup']:12.2f}x" + ) + + + + + +labels = [f"{row['points']:,}" for row in results] +x = np.arange(len(results)) +width = 0.36 +fig, ax = plt.subplots(figsize=(8, 4.5)) +ax.bar(x - width / 2, [row['numpy_s'] for row in results], width, label="NumPy") +ax.bar(x + width / 2, [row['cpp_s'] for row in results], width, label="C++") +ax.set_yscale("log") +ax.set_xticks(x, labels) +ax.set_xlabel("z profile points") +ax.set_ylabel("Median runtime [s]") +ax.grid(axis="y", which="both", alpha=0.25) +ax.legend() +fig.tight_layout() + + + diff --git a/doc/source/benchmarks/.virtual_documents/roi_sum_accel.ipynb b/doc/source/benchmarks/.virtual_documents/roi_sum_accel.ipynb new file mode 100644 index 0000000..d5df082 --- /dev/null +++ b/doc/source/benchmarks/.virtual_documents/roi_sum_accel.ipynb @@ -0,0 +1,516 @@ + + + + + + + + + +from pathlib import Path +from types import SimpleNamespace +import concurrent.futures +import platform +import statistics +import tempfile +import time + +import numpy as np + + +BENCHMARK_DIR = Path.cwd().resolve() +if (BENCHMARK_DIR / "orgui").is_dir(): + raise RuntimeError( + "Notebook is running from the repository root. Start Jupyter from " + "benchmarks/ to benchmark the installed package." + ) + +import benchmark_roi_sum_accel as roi_bench + +print(f"Benchmark directory: {BENCHMARK_DIR}") +print(f"Benchmark module: {Path(roi_bench.__file__).resolve()}") + + + +!hostname + + + +from orgui import __version__, get_build_config + +print("orGUI version", __version__) +print(get_build_config()) +print("Python", platform.python_version()) +print("NumPy", np.__version__) + + + +cpp_backend = roi_bench.import_cpp_backend() +backends = { + "numpy": roi_bench.wrap_numpy_backend(), + "cpp": roi_bench.wrap_backend(cpp_backend), +} +numba_backend = roi_bench.try_build_numba_backend() +if numba_backend is not None: + backends["numba"] = roi_bench.wrap_backend(SimpleNamespace(**numba_backend)) + +print("Available ROI backends:", list(backends)) +print("C++ backend:", getattr(cpp_backend, "__file__", "installed module")) +polybg_func = cpp_backend.processImage_polybg_Carr + + + + + + +POLYBG_ORDER = 2 +POLYBG_BACKEND = f"cpp_polybg_order{POLYBG_ORDER}" + + +def make_args(**overrides): + values = { + "seed": 12345, + "repeats": 10, + "rois": 50, + "shape": roi_bench.PILATUS_6M_SHAPE, + "roi_min": 10, + "roi_max": 100, + "bg_min": 5, + "bg_max": 50, + "signal_mean": 100.0, + "background_min": 5.0, + "background_max": 50.0, + "mask_fraction": 0.01, + "disk_images": 0, + "threads": 1, + "disk_dir": None, + "json": None, + "no_numba": False, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def format_seconds(value): + if value < 1e-3: + return f"{value * 1e6:8.1f} us" + if value < 1: + return f"{value * 1e3:8.2f} ms" + return f"{value:8.3f} s" + + +def print_table(rows, columns): + widths = {key: max(len(str(label)), *(len(str(row[key])) for row in rows)) for key, label in columns} + print(" ".join(str(label).rjust(widths[key]) for key, label in columns)) + for row in rows: + print(" ".join(str(row[key]).rjust(widths[key]) for key, _ in columns)) + + +def run_one_polybg_backend(func, base_image, mask, correction, rois, order=POLYBG_ORDER): + image = base_image.copy(order="C") + all_counters = np.zeros((rois[0].shape[0], 4), dtype=np.float64) + correction_counters = np.zeros_like(all_counters) + start = time.perf_counter() + func(image, mask, correction, *rois, all_counters, correction_counters, order) + duration = time.perf_counter() - start + return duration, (all_counters, correction_counters) + + +def benchmark_polybg_backend(func, base_image, mask, correction, rois, repeats, order=POLYBG_ORDER): + warmup_duration, counters = run_one_polybg_backend(func, base_image, mask, correction, rois, order) + timings = [] + for _ in range(repeats): + duration, counters = run_one_polybg_backend(func, base_image, mask, correction, rois, order) + timings.append(duration) + return { + "name": POLYBG_BACKEND, + "warmup_seconds": warmup_duration, + "median_seconds": statistics.median(timings), + "min_seconds": min(timings), + "max_seconds": max(timings), + "counters": counters, + } + + +def integrate_polybg_image(func, image, mask, correction, rois, order=POLYBG_ORDER): + all_counters = np.zeros((rois[0].shape[0], 4), dtype=np.float64) + correction_counters = np.zeros_like(all_counters) + func(image, mask, correction, *rois, all_counters, correction_counters, order) + return all_counters, correction_counters + + +def process_polybg_disk_image(path, func, mask, correction, rois, order=POLYBG_ORDER): + image = np.load(path) + return integrate_polybg_image(func, image, mask, correction, rois, order) + + +def background_pixel_counts(mask, rois): + counts = np.zeros(rois[0].shape[0], dtype=np.int64) + for roi_group in rois[1:]: + for i, roi in enumerate(roi_group): + ys = slice(roi[1, 0], roi[1, 1]) + xs = slice(roi[0, 0], roi[0, 1]) + counts[i] += np.count_nonzero(~mask[ys, xs]) + return counts + + +base_args = make_args() +print("Detector Pilatus 6M") +print("shape", base_args.shape) +print("roi_size_px", (base_args.roi_min, base_args.roi_max)) +print("background_strip_px", (base_args.bg_min, base_args.bg_max)) +print("background_counts", (base_args.background_min, base_args.background_max)) +print("mask_fraction", base_args.mask_fraction) +print("repeats", base_args.repeats) +print("fitted_background_order", POLYBG_ORDER) + + + + + + +args = make_args(rois=20, repeats=1) +image, background, mask, correction, rois = roi_bench.make_inputs(args) +reference = None +for name, func in backends.items(): + result = roi_bench.benchmark_backend( + name, + func, + image, + background, + mask, + correction, + rois, + repeats=1, + ) + if name == "numpy": + reference = result["counters"] + else: + roi_bench.check_results(reference, result["counters"], name) + +_, polybg_counters = run_one_polybg_backend(polybg_func, image, mask, correction, rois) +np.testing.assert_allclose(polybg_counters[0][:, 1], reference[0][:, 1]) +np.testing.assert_allclose(polybg_counters[0][:, 3], reference[0][:, 3]) +assert np.isfinite(polybg_counters[0]).all() +assert np.isfinite(polybg_counters[1]).all() + +print("Available accelerated ROI strip-background backends match NumPy counters") +print("C++ second-order fitted-background path returned finite counters with matching pixel counts") + + + + + + +ROI_COUNTS = (1, 5, 20, 50, 100, 500) +roi_results = [] + +for n_rois in ROI_COUNTS: + args = make_args(rois=n_rois) + image, background, mask, correction, rois = roi_bench.make_inputs(args) + bg_counts = background_pixel_counts(mask, rois) + bg_total_px = int(bg_counts.sum()) + bg_median_px = float(np.median(bg_counts)) + bg_min_px = int(bg_counts.min()) + bg_max_px = int(bg_counts.max()) + + reference = None + row_results = {} + for name, func in backends.items(): + result = roi_bench.benchmark_backend( + name, + func, + image, + background, + mask, + correction, + rois, + repeats=args.repeats, + ) + if name == "numpy": + reference = result["counters"] + else: + roi_bench.check_results(reference, result["counters"], f"{name} {n_rois} ROIs") + counters = result.pop("counters") + result["counter_checksum"] = float(sum(np.nansum(counter) for counter in counters)) + row_results[name] = result + + polybg_result = benchmark_polybg_backend( + polybg_func, + image, + mask, + correction, + rois, + repeats=args.repeats, + ) + polybg_counters = polybg_result.pop("counters") + polybg_result["counter_checksum"] = float(sum(np.nansum(counter) for counter in polybg_counters)) + row_results[POLYBG_BACKEND] = polybg_result + + numpy_median = row_results["numpy"]["median_seconds"] + for name, result in row_results.items(): + roi_results.append( + { + "rois": n_rois, + "backend": name, + "median_s": result["median_seconds"], + "min_s": result["min_seconds"], + "warmup_s": result["warmup_seconds"], + "speedup_vs_numpy": numpy_median / result["median_seconds"], + "bg_total_px": bg_total_px, + "bg_median_px": bg_median_px, + "bg_min_px": bg_min_px, + "bg_max_px": bg_max_px, + } + ) + +summary_rows = [ + { + "rois": row["rois"], + "backend": row["backend"], + "bg_total": row["bg_total_px"], + "bg_median": f"{row['bg_median_px']:.0f}", + "bg_range": f"{row['bg_min_px']}-{row['bg_max_px']}", + "median": format_seconds(row["median_s"]).strip(), + "min": format_seconds(row["min_s"]).strip(), + "speedup": f"{row['speedup_vs_numpy']:.2f}x", + } + for row in roi_results +] +print_table( + summary_rows, + ( + ("rois", "ROIs"), + ("backend", "backend"), + ("bg_total", "bg px total"), + ("bg_median", "bg px/ROI median"), + ("bg_range", "bg px/ROI range"), + ("median", "median"), + ("min", "min"), + ("speedup", "speedup"), + ), +) + + + + + + +from matplotlib import pyplot as plt + +fig, axes = plt.subplots(1, 2, figsize=(12, 4.5)) +ax_time, ax_speedup = axes +plot_backends = list(backends) + [POLYBG_BACKEND] + +for backend in plot_backends: + rows = [row for row in roi_results if row["backend"] == backend] + ax_time.plot( + [row["rois"] for row in rows], + [row["median_s"] for row in rows], + marker="o", + label=backend, + ) + if backend != "numpy": + ax_speedup.plot( + [row["rois"] for row in rows], + [row["speedup_vs_numpy"] for row in rows], + marker="o", + label=backend, + ) + +ax_time.set_title("Runtime by ROI count") +ax_time.set_xlabel("ROIs") +ax_time.set_ylabel("Median runtime per image [s]") +ax_time.set_xscale("log") +ax_time.set_yscale("log") +ax_time.set_xticks(ROI_COUNTS) +ax_time.get_xaxis().set_major_formatter(plt.ScalarFormatter()) +ax_time.grid(which="both", alpha=0.25) +ax_time.legend(title="Backend") + +ax_speedup.set_title("Acceleration vs NumPy") +ax_speedup.set_xlabel("ROIs") +ax_speedup.set_ylabel("Median speedup [x]") +ax_speedup.set_xscale("log") +ax_speedup.set_xticks(ROI_COUNTS) +ax_speedup.get_xaxis().set_major_formatter(plt.ScalarFormatter()) +ax_speedup.grid(which="both", alpha=0.25) +ax_speedup.legend(title="Backend") + +fig.tight_layout() + + + + + + +THREAD_COUNTS = (1, 2, 4, 8, 16) +DISK_IMAGES = 200 +THREAD_ROIS = 100 +thread_results = [] + +thread_args = make_args(rois=THREAD_ROIS, disk_images=DISK_IMAGES) +image, background, mask, correction, rois = roi_bench.make_inputs(thread_args) +bg_counts = background_pixel_counts(mask, rois) +thread_bg_total_px = int(bg_counts.sum()) +thread_bg_median_px = float(np.median(bg_counts)) +thread_bg_min_px = int(bg_counts.min()) +thread_bg_max_px = int(bg_counts.max()) + +with tempfile.TemporaryDirectory() as tmp: + disk_dir = Path(tmp) + image_paths = roi_bench.make_disk_images(thread_args, disk_dir) + print(f"Disk image directory: {disk_dir}") + print("Disk image format: .npy float64") + print(f"Background pixels total per image: {thread_bg_total_px}") + print( + "Background pixels per ROI: " + f"median {thread_bg_median_px:.0f}, range {thread_bg_min_px}-{thread_bg_max_px}" + ) + + disk_backend_specs = [(name, func, "strip") for name, func in backends.items()] + disk_backend_specs.append((POLYBG_BACKEND, polybg_func, "polybg")) + + for backend, func, mode in disk_backend_specs: + start = time.perf_counter() + if mode == "polybg": + serial_counters = [ + process_polybg_disk_image(path, func, mask, correction, rois) + for path in image_paths + ] + else: + serial_counters = [ + roi_bench.process_disk_image(path, func, background, mask, correction, rois) + for path in image_paths + ] + serial_s = time.perf_counter() - start + serial_checksum = roi_bench.checksum_counters(serial_counters) + + for threads in THREAD_COUNTS: + start = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor: + if mode == "polybg": + futures = [ + executor.submit( + process_polybg_disk_image, + path, + func, + mask, + correction, + rois, + ) + for path in image_paths + ] + else: + futures = [ + executor.submit( + roi_bench.process_disk_image, + path, + func, + background, + mask, + correction, + rois, + ) + for path in image_paths + ] + threaded_counters = [future.result() for future in futures] + threaded_s = time.perf_counter() - start + + for i, (serial, threaded) in enumerate(zip(serial_counters, threaded_counters, strict=True)): + if mode == "polybg": + for expected, actual in zip(serial, threaded, strict=True): + np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-9) + else: + roi_bench.check_results(serial, threaded, f"{backend} threaded image {i}") + + thread_results.append( + { + "backend": backend, + "threads": threads, + "images": DISK_IMAGES, + "rois": THREAD_ROIS, + "serial_s": serial_s, + "threaded_s": threaded_s, + "thread_speedup": serial_s / threaded_s, + "threaded_images_per_s": DISK_IMAGES / threaded_s, + "bg_total_px": thread_bg_total_px, + "bg_median_px": thread_bg_median_px, + "bg_min_px": thread_bg_min_px, + "bg_max_px": thread_bg_max_px, + "serial_checksum": serial_checksum, + "threaded_checksum": roi_bench.checksum_counters(threaded_counters), + } + ) + +summary_rows = [ + { + "backend": row["backend"], + "threads": row["threads"], + "bg_total": row["bg_total_px"], + "bg_median": f"{row['bg_median_px']:.0f}", + "bg_range": f"{row['bg_min_px']}-{row['bg_max_px']}", + "serial": format_seconds(row["serial_s"]).strip(), + "threaded": format_seconds(row["threaded_s"]).strip(), + "gain": f"{row['thread_speedup']:.2f}x", + "ips": f"{row['threaded_images_per_s']:.2f}", + } + for row in thread_results +] +print_table( + summary_rows, + ( + ("backend", "backend"), + ("threads", "threads"), + ("bg_total", "bg px total"), + ("bg_median", "bg px/ROI median"), + ("bg_range", "bg px/ROI range"), + ("serial", "serial"), + ("threaded", "threaded"), + ("gain", "thread_gain"), + ("ips", "images/s"), + ), +) + + + + + + +fig, axes = plt.subplots(1, 2, figsize=(12, 4.5)) +ax_time, ax_rate = axes + +for backend in plot_backends: + rows = [row for row in thread_results if row["backend"] == backend] + ax_time.plot( + [row["threads"] for row in rows], + [row["threaded_s"] for row in rows], + marker="o", + label=backend, + ) + ax_rate.plot( + [row["threads"] for row in rows], + [row["threaded_images_per_s"] for row in rows], + marker="o", + label=backend, + ) + +ax_time.set_title("50 images: read + integrate") +ax_time.set_xlabel("Worker threads") +ax_time.set_ylabel("Total threaded runtime [s]") +ax_time.set_xticks(THREAD_COUNTS) +ax_time.grid(alpha=0.25) +ax_time.legend(title="Backend") + +ax_rate.set_title("50 images: throughput") +ax_rate.set_xlabel("Worker threads") +ax_rate.set_ylabel("Images / s") +ax_rate.set_xticks(THREAD_COUNTS) +ax_rate.grid(alpha=0.25) +ax_rate.legend(title="Backend") + +fig.tight_layout() + + + + diff --git a/doc/source/benchmarks/ctr_accel_backends.ipynb b/doc/source/benchmarks/ctr_accel_backends.ipynb index bfb1d77..e7de2e5 100644 --- a/doc/source/benchmarks/ctr_accel_backends.ipynb +++ b/doc/source/benchmarks/ctr_accel_backends.ipynb @@ -62,7 +62,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Benchmark directory: C:\\Users\\timof\\Documents\\repos\\orGUI\\benchmarks\n", + "Benchmark directory: C:\\Users\\timof\\Documents\\repos\\orGUI\\doc\\source\\benchmarks\n", "Fixture: C:\\Users\\timof\\Documents\\repos\\orGUI\\benchmarks\\fixtures\\0V12_calculated.xpr\n" ] } @@ -76,7 +76,16 @@ "\n", "\n", "BENCHMARK_DIR = Path.cwd().resolve()\n", - "FIXTURE_DIR = BENCHMARK_DIR / \"fixtures\"\n", + "FIXTURE_CANDIDATES = (\n", + " BENCHMARK_DIR / \"fixtures\",\n", + " BENCHMARK_DIR.parents[2] / \"benchmarks\" / \"fixtures\",\n", + ")\n", + "FIXTURE_DIR = next((path for path in FIXTURE_CANDIDATES if path.is_dir()), None)\n", + "if FIXTURE_DIR is None:\n", + " raise FileNotFoundError(\n", + " \"Could not find benchmark fixtures. Run from benchmarks/ or execute \"\n", + " \"this notebook from doc/source/benchmarks/.\"\n", + " )\n", "XPR_PATH = FIXTURE_DIR / \"0V12_calculated.xpr\"\n", "\n", "if (BENCHMARK_DIR / \"orgui\").is_dir():\n", @@ -84,11 +93,6 @@ " \"Notebook is running from the repository root. Start Jupyter from \"\n", " \"benchmarks/ to benchmark the installed package.\"\n", " )\n", - "if not XPR_PATH.exists():\n", - " raise FileNotFoundError(\n", - " f\"Could not find {XPR_PATH}. Start Jupyter from the benchmarks/ \"\n", - " \"directory so benchmark fixtures are available.\"\n", - " )\n", "\n", "print(f\"Benchmark directory: {BENCHMARK_DIR}\")\n", "print(f\"Fixture: {XPR_PATH}\")\n" @@ -122,8 +126,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "orGUI version 1.5.1.dev39+g72e3872c2.d20260704\n", - "{'native_optimization': True, 'target_cpu': 'x86_64', 'allowed_instruction_set': ['AVX512'], 'native_arguments': '/arch:AVX512', 'cpp_compiler_id': 'msvc', 'cpp_compiler_version': '19.43.34810', 'host_system': 'windows', 'host_cpu_family': 'x86_64', 'host_cpu': 'x86_64', 'available': True}\n" + "orGUI version 1.5.1.dev69+gaf90fcda9.d20260724\n", + "{'native_optimization': False, 'target_cpu': 'generic', 'allowed_instruction_set': [], 'native_arguments': '', 'cpp_compiler_id': 'msvc', 'cpp_compiler_version': '19.43.34810', 'host_system': 'windows', 'host_cpu_family': 'x86_64', 'host_cpu': 'x86_64', 'available': True}\n" ] } ], @@ -229,7 +233,19 @@ " return f\"{value * 1e6:8.1f} us\"\n", " if value < 1:\n", " return f\"{value * 1e3:8.2f} ms\"\n", - " return f\"{value:8.3f} s\"\n" + " return f\"{value:8.3f} s\"\n", + "\n", + "\n", + "def cold_cache_time(func, repeats=7):\n", + " \"\"\"Return a median with the global cache cleared before every call.\"\"\"\n", + " timings = []\n", + " for _ in range(repeats):\n", + " CTRuc.clear_form_factor_cache()\n", + " start = time.perf_counter()\n", + " func()\n", + " timings.append(time.perf_counter() - start)\n", + " return statistics.median(timings)\n", + "\n" ] }, { @@ -281,9 +297,11 @@ "id": "b7762abd", "metadata": {}, "source": [ - "## Benchmark Structure-Factor Evaluation\n", + "## Structure-Factor and C++ Form-Factor Cache Benchmarks\n", "\n", - "This measures direct repeated evaluations for increasing CTR rod lengths. Use the median time for stable comparisons; the minimum time is also shown as a lower bound on a quiet machine.\n" + "The first table measures direct repeated structure-factor evaluations for increasing CTR rod lengths. The cache table then measures canonical `UnitCell.F_uc` with no resident cache, with a cleared cache before each call, and with a pre-populated cache.\n", + "\n", + "Exact cache reuse requires bitwise-identical computed float64 Q-squared vectors and complete 13-value Waasmaier rows. The cache is process-global; its 256 MiB LRU budget counts retained Q-squared and real form-factor vectors, not call-local arrays or output arrays.\n" ] }, { @@ -297,12 +315,12 @@ "output_type": "stream", "text": [ "benchmark points numpy median cpp median numba median cpp speedup numba speedup\n", - "UnitCell.F_uc 200 130.9 us 29.3 us 27.0 us 4.47x 4.85x\n", - "SXRDCrystal.F 200 370.5 us 99.5 us 97.9 us 3.72x 3.78x\n", - "UnitCell.F_uc 2000 551.5 us 207.7 us 216.1 us 2.66x 2.55x\n", - "SXRDCrystal.F 2000 1.10 ms 767.0 us 981.5 us 1.44x 1.12x\n", - "UnitCell.F_uc 20000 3.02 ms 2.04 ms 2.20 ms 1.48x 1.37x\n", - "SXRDCrystal.F 20000 11.02 ms 7.37 ms 9.38 ms 1.50x 1.18x\n" + "UnitCell.F_uc 200 169.7 us 57.3 us 25.8 us 2.96x 6.58x\n", + "SXRDCrystal.F 200 496.3 us 114.0 us 121.8 us 4.35x 4.07x\n", + "UnitCell.F_uc 2000 407.2 us 150.2 us 301.5 us 2.71x 1.35x\n", + "SXRDCrystal.F 2000 1.56 ms 674.8 us 950.7 us 2.30x 1.64x\n", + "UnitCell.F_uc 20000 3.32 ms 1.40 ms 2.91 ms 2.37x 1.14x\n", + "SXRDCrystal.F 20000 10.34 ms 5.00 ms 8.01 ms 2.07x 1.29x\n" ] } ], @@ -373,7 +391,7 @@ "source": [ "## Benchmark Results\n", "\n", - "Plot the median runtime comparison for the direct structure-factor benchmarks.\n" + "The plot compares structure-factor backend medians. The following table reports the canonical C++ `UnitCell.F_uc` cache runs: uncached, cold cache, and warm cache, together with cache counters and retained-versus-expected numerical bytes.\n" ] }, { @@ -384,7 +402,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABEEAAAG4CAYAAACqxPmiAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAdBVJREFUeJzt3Xd4FOX6//HPbkIJQkICSJEWBFQEBMECKMWCFHvDggqCHjRiQcSComJXVCwRVA6IehS7oh5FLFhBpX1BRREBwQZCAglCQjZ7//7glz2EJDAJs5md7Pt1XV6S3cnMvZvPPsneO/M8ATMzAQAAAAAAVHFBrwsAAAAAAACoDDRBAAAAAABAXKAJAgAAAAAA4gJNEAAAAAAAEBdoggAAAAAAgLhAEwQAAAAAAMQFmiAAAAAAACAu0AQBAAAAAABxIdHrAmJdOBzWH3/8oTp16igQCHhdDgAAAAAA2IWZKTc3V02aNFEwWPb5HjRB9uCPP/5Qs2bNvC4DAAAAAADswdq1a9W0adMy76cJsgd16tSRtOOJTE5O9rgaAAAAAACwq5ycHDVr1izyHr4sNEH2oOgSmOTkZJogAAAAAADEsD1NY8HEqAAAAAAAIC7QBAEAAAAAAHGBJggAAAAAAIgLzAniksLCQhUUFHhdRtypVq2aEhISvC4DAAAAAOADNEH2kpnpr7/+0qZNm7wuJW7VrVtXjRo12uMEOAAAAACA+EYTZC8VNUD23Xdf1apVizfilcjMtHXrVq1fv16S1LhxY48rAgAAAADEMpoge6GwsDDSAKlXr57X5cSlpKQkSdL69eu17777cmkMAAAAAKBMTIy6F4rmAKlVq5bHlcS3ouefOVkAAAAAALtDE8QFXALjLZ5/AAAAAIATNEEAAAAAAEBcoAkSp1q2bKmJEydW+nEDgYDefPPNSj8uAAAAAABVvgmydu1a9e7dW+3atVPHjh31yiuveF3SHg0ZMkSBQCDyX7169dSvXz8tWbLE69IAAAAAAPCtKr86TGJioiZOnKhOnTpp/fr1OvTQQzVgwADts88+Xpe2W/369dO0adMk7ViG9+abb9aJJ56oNWvWeFwZAAAAgHjR8oZ3vS7BkdX3DvS6BPhElT8TpHHjxurUqZMkad9991VaWpqysrK8LcqBGjVqqFGjRmrUqJE6deqk66+/XmvXrtXff/8tSbr++uvVtm1b1apVS61atdItt9xSYnWUmTNnqmvXrqpZs6bq16+v008/vczjTZs2TSkpKZo9e7Yk6YcfftCAAQNUu3ZtNWzYUBdccIE2bNgQ2b5379668sorNWbMGKWlpalRo0a67bbbiu3z559/Vs+ePVWzZk21a9cusm8AAAAAALzgeRPks88+00knnaQmTZqUOV/EE088ofT0dNWsWVNdunTR559/XqFjzZ8/X+FwWM2aNdvLqivXli1b9J///EetW7dWvXr1JEl16tTRM888ox9++EGPPPKInn76aT388MOR73n33Xd1+umna+DAgVq0aJE++ugjde3atdT9T5gwQaNHj9asWbN0/PHH688//1SvXr3UqVMnzZ8/X++//77WrVuns88+u9j3TZ8+Xfvss4++/vpr3X///Ro/fnyk0REOh3X66acrISFB8+bN0+TJk3X99ddH6RkCAAAAAGDPPL8c5p9//tEhhxyioUOH6owzzihx/0svvaSrr75aTzzxhHr06KEnn3xS/fv31w8//KDmzZtLkrp06aL8/PwS3/vBBx+oSZMmkqSNGzfqwgsv1JQpU6L7gFzyzjvvqHbt2pJ2PEeNGzfWO++8o2BwR9/q5ptvjmzbsmVLXXvttXrppZc0ZswYSdJdd92lc845R7fffntku0MOOaTEcW688UZNnz5dc+bMUYcOHSRJkyZN0qGHHqq77747st3UqVPVrFkzLV++XG3btpUkdezYUbfeeqskqU2bNnr88cf10Ucf6fjjj9eHH36oZcuWafXq1WratKkk6e6771b//v1de44AAAAAACgPz5sg/fv33+0b44ceekjDhg3T8OHDJUkTJ07UrFmzNGnSJN1zzz2SpAULFuz2GPn5+TrttNN04403qnv37nvcdueGSk5OjqQdZzaEw+Fi24bDYZlZ5D839enTR0888YQkKSsrS5MmTVL//v319ddfq0WLFnr11Vf1yCOPaMWKFdqyZYtCoZCSk5MjdSxevFjDhw/fbV0PPvig/vnnH3377bdq1apVZNsFCxbok08+iTRhdrZixQq1adNGktShQ4di+2/cuLHWrVsnM4s0qfbbb7/INkceeaQkuf58Fe2vtJ8RAAAAgIoLyt33OdHC+wA4zYDnTZDd2b59uxYsWKAbbrih2O19+/bVV1995WgfZqYhQ4bomGOO0QUXXLDH7e+5555iZ08Uyc7OVigUKnZbQUGBwuGwQqFQifv2RjgcVlJSklq2bClpx5kekydPVv369fXkk09q4MCBOvfcczVu3DhNmDBBycnJevnllzVx4sRIHUlJSZHaytKjRw+99957mjFjRuQMEkkqLCzUwIEDi50JUqRx48YKhUIyMyUmJhbbv5mpsLBQoVBIhYWFklTs/qLbirZxSygUUjgc1ubNm7V161bX9gsAAADEu1bJ/miC+GHeR0RXbm6uo+1iugmyYcMGFRYWqmHDhsVub9iwof766y9H+/jyyy/10ksvqWPHjpH5Rp577rnIpR+7uvHGGzVq1KjI1zk5OWrWrJlSU1OVnJxcbNu8vDxlZ2crMTFRiYnuPZXBYFDBYLDYPotuy8/P17x589SiRQvdcsstkfvXrl0rSZHv6dixo+bMmaNhw4aVeZwjjjhCV155pfr166dq1arpuuuukyQdeuihev3119W6desyH1fR8r2l1ZiYmKj27dtrzZo1Wr9+feSSpG+//VaSlJCQ4OrzlZiYqGAwqJSUFNWsWdO1/QIAAADxbmVOwOsSHElLS/O6BHjM6XvMmG6CFAkEir/wzKzEbWU56qijynVqVI0aNVSjRo0Stxe9wd/1tqJmgNN6nMrPz9e6desk7TgL5fHHH9eWLVt08skna/PmzVqzZo1eeuklHXbYYXr33XcjDZ6iOm699VYde+yx2n///XXOOecoFArpvffeK3bGRyAQUPfu3fXee+9FGiHXXHONrrjiCk2ZMkXnnXeerrvuOtWvX18rVqzQjBkz9PTTTyshISHy/aU97kAgoOOPP14HHHCALrroIj344IPKycmJzGPi9vNVtL/SfkYAAAAAKi4sfzRBeB8ApxmI6SZI/fr1lZCQUOKsj/Xr15c4O6Sqef/999W4cWNJO1aCOfDAA/XKK6+od+/ekhRpVuTn52vgwIG65ZZbii1R27t3b73yyiu64447dO+99yo5OVk9e/Ys9Vg9evTQu+++qwEDBighIUFXXnmlvvzyS11//fU64YQTlJ+frxYtWqhfv36OgxUMBvXGG29o2LBhOvzww9WyZUs9+uij6tev3149LwAAAADgVx2ml35FQqxZetFSr0uImoC5PaPnXggEAnrjjTd06qmnRm474ogj1KVLl8gkoZLUrl07nXLKKZGJUaMpJydHKSkpys7OLvVymNWrV0eW74U38vLytGrVKrVs2ZKfAwAAAOCi1jf91+sSHFlx9wCvS3Ck83OdvS7BkUUXLPK6hHLLyclRamqqNm/eXOK9+848PxNky5YtWrFiReTrVatWafHixUpLS1Pz5s01atQoXXDBBeratau6deump556SmvWrNGIESOiWldmZqYyMzMjk3lW5sSoKB8mRgUAAACig4lR3dUisYXXJTjil+dzZ76ZGHX+/Pnq06dP5OuiSUkvuugiPfPMMxo0aJA2btyo8ePH688//1T79u313//+Vy1aRDc8GRkZysjIiJwJUpkTo6J8mBgVAAAAiA4mRnXXr6FfvS7BEb88nzvzzcSovXv31p6uyLn88st1+eWXV1JFpavsiVHhHBOjAgAAANHBxKjuCsv5oh1e8svzuTPH81dGuQ4AAAAAAICYQBMEAAAAAADEBc8vh/GLcDiscDhc4jYzi/wHbxQ9/6X9jAAAAABUXFD+eJ/jl/cBQZ+ch+CX53NnTmumCVIGVofxD1aHAQAAAKKD1WHcxeow0eOb1WFiFavD+AerwwAAAADRweow7mJ1mOjxzeowfsHqMLGL1WEAAACA6GB1GHexOkz0sDoMAAAAAADATjgTJApa3vBupR5v9b0DK/V4AAAAAAD4EU2QONS7d2917NhRNWvW1JQpU1S9enWNGDFCt912m1avXq309HQtWrRInTp1kiRt2rRJqamp+uSTT9S7d2/NmTNHffr00fvvv68bbrhBP/74o7p166YZM2ZowYIFGjVqlH7//XcNHDhQ//73v1WrVq3Icdu3by9Jev7555WQkKDLLrtMd9xxhwKBgMaPH69XXnlFS5cuLVZvly5dNHDgQI0fP75SnycAAAAAPnFbitcVOJPe3OsK4h5NEIdieYncihx7+vTpuuaaazRv3jzNnTtXQ4cOVffu3dWmTZvIPov2u/P/d779tttu02OPPaZatWpp0KBBOvvss1WjRg395z//0ZYtW3T66afr0Ucf1fXXX1/suBdffLHmzZun+fPn61//+peaN2+uSy65REOHDtXtt9+ub775RocddpgkacmSJVq0aJFefvnlMh8nS+QCAAAA0eGbJXJ9MtMDS+RGD0vk7iU/LZFb3mObmTp06KCxY8dKktLT0/X4449r9uzZSk9Pj+yzaL9F/y8sLFQoFIo8J7fddpuOOOIISdKQIUN0880368cff1SrVq0kSaeffro++eQTXXvttZHjNm3aVA888IACgYD2339//d///Z8efvhhDR06VI0aNVLfvn01depUde7cWZI0depU9ezZU82bNy/zcbJELgAAABAdvlkit3pbr0twpEViQ69LcIQlcuOQn5bILe+xA4GAOnbsWOz7mjRpog0bNkRu2/kxFf0/ISFBiYmJSkhIkCR17tw5cl/jxo1Vq1YttW37v8GnUaNGmj9/fmSbQCCgI488UtWqVYts06NHD02cOFGBQEAJCQm65JJLNGzYMD388MNKSEjQiy++qAkTJuz2MbJELgAAABAdvlkit+Zyr0tw5NdQntclOMISuYjpJXIrcuzq1asX+75AICAzizQ4dt5v0RkYuz7WnfcRDAZVrVq1YvsMBoMKh8MljrPr1zvffvLJJ6tGjRp68803VaNGDeXn5+vMM8/c7WNkiVwAAAAgOnyzRK5Plp5lidzocVozTRAU06BBA0nSn3/+GbkkZfHixa7tf968eSW+btOmTaT5kpiYqIsuukjTpk1TjRo1dM4550QmVgUAAAAAYG/QBEExSUlJOvLII3XvvfeqZcuW2rBhg26++WbX9r927VqNGjVK//rXv7Rw4UI99thjevDBB4ttM3z4cB100EGSpC+//NK1YwMAAAAA4htNEJQwdepUXXzxxeratasOOOAA3X///erbt68r+77wwgu1bds2HX744UpISNDIkSN16aWXFtumTZs26t69uzZu3BiZeBUAAAAAgL0VMC/XdvWBoolRN2/eXOrEqKtWrVJ6ejoTcjrQu3dvderUSRMnTtztdmamAw88UP/61780atSoPe6XnwMAAAAQHS1veNfrEhxZXfM8r0twpEN6c69LcGTpRUu9LqHcdvfefWecCeJQOBwuse5wOByWmUX+w57t6blav369nnvuOf3+++8aMmSIo+e1aJ+l/YwAAAAAVFxQ/nifE5Y/JvIM+qROP76vclozTZAyZGZmKjMzU4WFhZKk7OzsyCopRQoKChQOhxUKhUrch5KKmhW7e64aNWqk+vXr64knnlCdOnUcPa+hUEjhcFibN2/W1q1b3SwZAAAAiGutkv3RBMmq3tbrEhxpkdjQ6xIcycrK8rqEcsvNzXW0HU2QMmRkZCgjIyNySk1qamqpl8NkZ2crMTHR8ZrE8WzOnDl7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idvMLPKfVypy7I4dO2rcuHGSpNatW+vxxx/Xhx9+qNatW0f2WbTfnf+/8+133HGHunfvLkm6+OKLddNNN2nFihVq1aqVJOmMM87QJ598ojFjxkSO26xZMz300EMKBAJq27atlixZoocffljDhw+XJA0dOjSybXp6uh555BEdccQRys3NVe3atct8/GZW6s8IAAAAQMUF5d37nPII++Tz/aBP6vTj+yqnNdMEKUNmZqYyMzNVWFgoScrOzlYoFCq2TUFBgcLhsEKhUIn7KlN5j21mat++fbHva9iwodatWxe5befHVPT/wsJChUKhyHPSrl27yH0NGjRQrVq11Lx582K3ffPNN5GvzUyHH3545Psl6fDDD9dDDz2k/Px8JSQkaNGiRbrjjju0ZMkSZWVlRYK8cuVKtWvXrszHHw6HtXnz5hKX3wAAAFQV5zw11+sSHJlxaTevS4CLWiX7owmSVb2t1yU40iKxodclOJKVleV1CeWWm5vraDuaIGXIyMhQRkaGcnJylJKSotTUVCUnJxfbJi8vT9nZ2UpMTFRiondPZXmPHQgEVL169WLfl5CQIEmqXr165Oui+4vO/Ci6rWjbpKSkyDYJCQmqVq1aiX2aWeS2QCCgQCBQ6nETExOVl5engQMHqm/fvnruuefUoEEDrVmzRv369VM4HC7zcSYmJioYDColJUU1a9Ys13MBAADgFytzAl6X4EhaWprXJcBFvsldzeVel+DIr6E8r0twxI+vY6fvi2mCOBQMBhUMBkvcVvTGPhDwbnCoyLHLqnnfffeVJP3111+R+//v//6v2PcU3b7rv3etpbTbvv766xJft2nTRomJifrpp5+0YcMG3XvvvWrWrJkkacGCBbutd+f7SvsZAQAAVBVh+ePNKH+PVS2+yZ38cflG2Cd1+vF17LRm/z0yRFVSUpKOPPJI3Xvvvfrhhx/02Wef6eabb3Zt/2vXrtWoUaP0008/6cUXX9Rjjz2mq666SpLUvHlzVa9eXY899phWrlypmTNn6o477nDt2AAAAACA+EYTBCVMnTpVBQUF6tq1q6666irdeeedru37wgsv1LZt23T44YcrIyNDI0eO1KWXXippxxwizzzzjF555RW1a9dO9957ryZMmODasQEAAAAA8S1gXi5r4gNFc4Js3ry51DlBVq1apfT0dOai8BA/BwAAEA9a3vCu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IcuXL9f333+vgQMHqnr16pHbi7J3xBFHqFevXnr99dfVunVr5efn65prrlFGRoZq1qzpVdmoghjvEG2Md4gFjHWINsY6uIUzQeCJuXPnqkePHlqzZo0kqUaNGtpnn31Uu3ZtSf9bzopfkqiop556SmeffbaWL19e4r6iT6sGDx6sJk2aKBwO64MPPtC1115b7Jcq4AbGO0Qb4x1iAWMdoo2xDm6hCYKoK2197rZt22ru3Ll6/vnnI7c1bdpUr7/+uqQda3ubmbZv366FCxcqJyen0uqFvxVNejVhwgTtu+++evLJJ/XPP/8U26boE4OWLVvq9NNPV8OGDTV79uxi9wEVwXiHysR4B68w1qEyMdbBbSQCUZeQkKC///5by5Ytk7SjU1uvXj1NmDBB999/v3788UcdddRROvTQQ/Xuu+9GBqxAIKBPPvlE48aN0x9//OHlQ4CPBAIBhUIhSdL999+vp556St9++22J7Yp+oZ5xxhk64IADNHPmzMgnC7te1ww4xXiHysR4B68w1qEyMdbBbTRBUCmGDx+uU089VVu3bo10Y0eNGqUGDRro7rvvliSNHj1azZo104knnqiTTz5ZAwcO1Omnn65OnTrpwAMP9LJ8+MDOn0oVLcl3/vnnq0OHDnrggQdKnZHezJSSkqKzzjpLhYWFevLJJyXxiQH2DuMdoo3xDrGAsQ7RxliHaCENqLDyrLV9zz336Pfff9dLL71U7HsnTJigF154QR999JEOOugg/ec//9Hjjz+uTp06qWXLlvruu+905513RqV+VA1FvyCLTrP9888/lZ+fH7n/ySef1HvvvadZs2YV+xRg52uSBw4cqDZt2ujbb7+NXMsM7IzxDrGA8Q7RxliHWMBYh2gLWHlGO+D/KywsjMzyvW3bNiUlJRW7rTTXXnutXn75ZX377bdq1KiRJGn79u064IAD1LVrVz322GOR23c9VjAYZCIt7NakSZM0YcIE1atXTwkJCZo+fbpatWqlxMREnXPOOfruu+/09ttvKz09XZL01Vdf6aOPPtI555yjNm3aaNWqVapXr56Sk5M9fiSINYx3iDWMd4gGxjrEGsY6RAtngqBcirqtCQkJys7O1oknnqgLLrggctvu3HzzzQqFQpo4cWLktp9++klJSUl67bXX9OGHH5Z6vISEBH5JIsLMip0emZWVpfPPP18PPfSQbr75Zk2cOFENGjTQxRdfrAULFkiSJk+erJ9//lkvvPBC5JOEn3/+Wbfddpu+++47SVJ6erqSk5O5ZhQRjHfwGuMdKgNjHbzGWIdKZ0AFjB071hITE23gwIG2cuVKx9/37LPPWkJCgt199922ZMkSy8jIsGnTptnMmTMtPz9/j98fDoctHA7vTenwqezsbLv77rsjX2/YsMHy8/Ptt99+sxtuuMGWLFliZmb//POPnXrqqZaQkGBjxoyxdevWmZnZ7bffbo0aNbJvv/02so8PP/zQ0bHJXXzzarxD/PJyvEP88nKsKywsrGjZ8DGvxzpyF79ogqBcvvzyS2vWrJm1atXK5syZU6F9ZGRkWIcOHSwlJcWOPPJIW716deS+3b3RLCgoiPx7+/btFTo2/GvhwoUWCARsxowZdvXVV1vdunXtww8/tC1bttjPP/9sZmYTJ060evXq2fnnn2+jRo2y+vXr28yZMyP7qFatmg0dOtS2bt1abN/kDqXxcrwrLCzkj7M45tV4R+7iE2MdvMJYB6/QBEG5TJ061RITE+3VV18tdvuaNWv2+KlB0WBTUFBgf/31ly1atKjcxw+Hw3bTTTfZ2LFjy/UpBfypsLCw2FkYRx55pNWqVcvat29vX375ZbFtP/vsM+vUqZPNmDHDzHbkbJ999rGLL77Yli1bZmZm8+bNs7Vr15a7DnIXn7wa70KhUOTfa9euJXNxwuvxjtzFL6/Gup3fhC5cuNCuuuoq+/HHH50XDl/yeqwjdzAzY04QlMvQoUPVs2dPvfDCC8rNzZUkDRkyRF27dtWKFSt2+71FS1MlJCSoYcOG6tSpk6Tiy1/trOh2+/9z97766qtq1KiRPvjgA6Wnp6ugoMCNh4QYZP//2tCiSdPy8/O1bds2rVmzRoWFhTrnnHPUvXt3STuuLTYzvf3226pdu7aOOeYYSdLbb7+t1NRUvfTSS1q2bJkk6fDDD1fTpk13e20ouUORyhzvdpaQkKC8vDxdcsklOuyww/T6669r/fr1e/dgELO8HO92Ru7iV2WPdUW/X4PBoP755x8NHjxYffr00R9//KHff/+d37NVlNdjHblDMV51XxC73nnnnVJvL/qUaM6cObbffvvZGWecYfXq1bPjjz++ROe2rO/f9dS00k5VW716ta1fv77YbX/99ZcdccQRds899zh9GKgCsrOzbcSIEXb11Vfb33//bWZmTz75pNWpUyfStS/K0G233WatWrWyGTNm2MKFC23gwIH29ttv2wcffODoWOQuPnk93pmVvCb5u+++swMPPNCOPfZY++STT+yXX37htN04UJnjnRm5izexMNbl5OSUuO3WW2+1I444InLpA/NvVX2VPdaRO5SGJgiKmT9/fuTaPLOyJwwaMWKEJSYm2l133bXHfYbD4WKn2b7//vuR/e/qo48+sgYNGtisWbPsr7/+sl69etnixYvtyy+/tLZt29rnn39u27dvt48//thmzZplb7zxhv31118VeKSINUVZK/pFNGnSJEtOTrbjjz/eXnnllcgvxry8PDv44INt8ODBxbIVDoetV69e1qZNG6tVq5ZdcMEFxebz2N0vOHIXn7we77766iu7//77I1//3//9n5mZTZs2zfr06RO5vrlofzvvF/7m5XhH7uKP12NdXl6edevWzcaMGWNmZq+++qqNGzfOzMw6dOhg48ePNzOzX375xZYtW2Zz5syxjRs3On+AiFlejnXkDrtDEwT222+/RTqqeXl5NmLECNt///1L3bZoMPvtt9+sRYsWdtttt9nmzZvNrPSBaOeBatWqVda/f39LSUmxxx57rMx6evbsaQceeKAlJSXZcccdZ5s3b7a//vrLOnToYIceeqi1bt3aBgwYYM2bN7c2bdrYSSedVOHHjti0atUq69Spk02bNq3U+2fOnGkJCQn26aefRm4r+jRh8eLFtmrVqsjtTrv75C4+xNJ4N2XKlMgbk549e1pqaqrl5ubajTfeaOnp6fbf//7X7r//fhs9erQdeeSRNmrUKFu8ePFePX7Ensoe78hdfIilsc7M7D//+Y/VqVPH2rdvb3Xq1LEpU6aYmdmQIUOsVatW1qtXL+vXr58dccQRFggE7LTTTrO5c+dW7MEjJnnxtx25Q1logsDGjBljCQkJ9scff5iZ2bJly6xevXp25513mlnJTwyKvr7tttvsoIMOslmzZu12/+Fw2K666iqrXbu2DR48uNiM4UWTI5nt+CW9adMma9u2rSUmJtqoUaOK7WfJkiU2ceJEe/PNN+3TTz+1rKwse/zxx+2AAw5gQqMqIDs7284//3z7+++/7bPPPrO2bdvap59+ajk5Ofbuu+/aCy+8YNOnT4/8QjzxxBOtefPmdt9991mHDh3stNNOK7Z6y+5m/iZ38cvL8a409evXt4SEBBs0aFAk21u2bLETTjjB0tLSbODAgXbllVfayJEj7eijj7aLL764Qo8bsaUyx7vSkLuqz+uxbtf9jxkzxgKBgB1wwAGWlZUVuT0/P9/Gjx9vo0ePttdff92++eYb+/rrr61x48b2n//8p2IPHjGjssc6cgenaILANm3aZC1atLBrr73WzHb8YrvvvvssKSkpsg530RvGcDgcGWBCoZB17tzZLrnkkjJnZX722WctJSXFjjrqqBLLru18GuU///wT+ffixYvt/PPPt169etk333yz29qvv/56O/PMM8v5iOG10k6v/vzzzy09Pd0WLFhgP//8sx133HHWsmVLa9WqlZ144omWnp5urVu3tr59+5qZWW5url144YXWvXt3u+WWWyp0bHIXf7wa73ZuvBV57733rGPHjhYIBOztt9+ObGdmlpWVZevWrbOCggLLy8szM7MBAwbY1VdfXaxGxD4vxztyF79iZawr2u/ChQtt0qRJFggEbPbs2cXu29XatWutffv29sUXX1T04cMDsTTWkTvsCU2QOFc0YEybNs1q1KgRuTb4zz//tPbt29v5559fbLsiRadCPvPMM1azZk17//33S+x7/fr1dtNNN9mTTz5ZbMDZeZDMycmxIUOG2IABA+zWW2+NnHL7888/W/PmzW3cuHGRUzILCgqssLDQ3nrrLXvnnXfsuOOOswYNGtjrr7/u1tOBKNtd9/6vv/6yevXqRU6DXL58uT399NP26aef2rfffms5OTn29NNPW9OmTe2XX34xM7OtW7dafn5+ZB+7u3ad3MGL8W7X/X3//ff29ddf27Zt2yK3nXnmmXbIIYcUO9W3yObNmy0cDtsLL7xgBx54oD3//PMVeOTwgpfjnRm5i2dejHU7L3lqtqPZNnjwYLvtttvs559/juT11FNPtS5dupSYrHLt2rX20Ucf2eTJk61x48Y2aNAgy87O3stnApXBy7GO3KGiaILEoZ2v5SwSDoft8MMPt5NOOikymL366qsWCARs3rx5ZrZjkNu2bZs99NBDkdmUzcw+/PDDMo+1u4Fr8eLF1q9fPzv22GNtzJgx1rZtWzvooIMib0jHjh1r7dq1s48++qjY991xxx12yCGH2CWXXGK5ubnOHzg8tfMvqR9//DHyCdLOeezVq1fkU6vSjBgxwoYMGVLi9tI+7SwLuYsvXo53O2cyJyfHzj77bEtKSrJmzZrZscceay+//LKZ7XhjkpiYaPfee2+xP/xWrVplgwcPtqOPPtpSU1Nt8uTJFXgG4AUvxztyF59i5W+77du325133mn169e3c845x1q1amXt2rWzhx9+2Mx2fNhQo0YNmzRpUrHv+/LLL61bt27Wrl27MueMQOyJlb/tyB3KiyZIHNl1IPn8889txYoVkWvtPv30UwsGg/bWW2+Z2Y5T0gYOHGiHH3545Hs2bdpkgUDArrjiimLX6JW2/7Js2LDBzj33XOvXr5+df/75kU+mli1bZieccIJ16dLFzHYMaJ06dbJzzjnHFi5caJmZmXbllVea2Y4/3oqU9osf3tk1Bzv/sfTbb79Z79697d///rf17t3bunfvbjfffLOZ7ZibY/DgwXbVVVdFzr7Iy8uzN954w5599lnr0qWLtWzZ0j7++OMK1UXu4kusjHdmZrNnz7Z77rnHBg0aZN999519/PHHdtZZZ1mbNm3ss88+MzOzG2+80Ro3bmwLFy40sx3zM+Tk5Ni0adPsnnvuKZY3li2NHbE63pmRu3gRS2Pdo48+aieddJKdf/75kUsK8vLy7LLLLrNDDz00cunM9ddfbw0bNrSFCxfan3/+aWPGjLElS5bY999/X2x/rEwUO2J5rCN3qAiaIFXQrqeG7erFF1+0/fbbz7p06WINGza0kSNH2vLly83M7Oyzz7b27dtHThebO3eu7bPPPvbcc89Fvn/WrFn266+/7lWNl1xyiVWrVs0yMjKK1f3555/bPvvsY6+++qqZ7fjEokePHrbffvtZkyZN7N///ndk+/JOBIfo2t3PIhwO2w8//GC33367nXTSSbZ582bLzs62adOmWVJSko0dO9a2bt1qd911V7E/zMx2nJnRs2dPGzt27F7XSO6qnlgf77744gsLBALWqFEje/HFFyO3L1u2zE4//XTr06dP5LYWLVrYcccdZyNHjrTq1avbPffcU2xfNN5iR6yPd+Su6on1sc7sf7lr1qxZsWXkFy5caCeffLINHTo0cttBBx1k7du3t+rVq1u/fv2KXYLAm9DYEetjnRm5Q8XQBKlidh6s/v77b8vKyir2S/PTTz+1gw8+2CZOnGiFhYX2ySef2BFHHGG9evUyM7PVq1fbPvvsYw8++KCZ7fjj57LLLrNAIFBicKjIG8GifaxcudI6depk/fr1KzZb88aNG61v37520003RW777bff9qpDjOjbOWMzZsywjIwMu/POOyPLjF133XUWCASsd+/etmbNmmLfO2PGDDv22GPt6KOPtmeffdbatGlTbDK2DRs2FLuGsyJ/kJO7qimWxrvdvTkZOXKkBYNBe+mll4rt79lnn7WDDjoo8qn8l19+aSNHjrQ+ffrYzJkzHe8flSuWxjtyFx9iaawrsms2ir5v6NChVq9evRLLKl9zzTXWr1+/SL7/+OMPe+utt+zrr792dDxUvlga60qryYzcoeJoglRBOTk5dumll1qfPn3sxBNPtNdeey1yeuM111wTWdViy5YtdvHFF1tycrJdd911kZngb7/9dmvUqFHkE4GVK1faM8884+jYjz/+uJ199tl29913l7nCRtEAdt9991nXrl2Lfcq+bds2a9GiReQavrIm7ULs+fbbb619+/bWtm1bGzdunN1888327bffmtmOZWY7dOhgPXr0KDYbfZHVq1dbz549rU6dOtasWbNi1yUX2d21oeQufnk53v3zzz+2dOnSMvNR9Obi119/tWbNmtm1115rGzdujNz/xRdf2L777lvsj7ad52bYecUGxBYvxztyF5+8HOv++usvu+mmm2zChAn2+eefl7pNUWY2bNhg1apVszvuuKPYCmw333yztWzZssy5S/gUPjZ5OdaRO0QTTZAqZtq0aZaammonnniiffzxx/bCCy/Ypk2bLBwOW15enp199tn29NNP24MPPmh169a1vn372qJFi4rtIz8/3/bZZx8bNmyYo2OGw2H7+++/rV+/ftayZUu77LLLrGvXrlanTh17/fXXIwNP0SBXNGBt3rzZjjvuOGvdurW99NJLtmLFCnviiSesWbNmkWWs4A/fffedde7c2a644orIqio7KygosAcffNBq1Khhq1evNrOSjYasrCwbO3asJSYmRiYl3dNpmOQuvnkx3hW5++67rX79+ta2bVvr2rWrTZ06tdTtinJ36623Wps2bWz69OmR+/773/9aixYtIqes74w/zGKXF+NdEXIXn7wc66ZPn2777LOPnXDCCdahQwdr0qRJsbMmd1aUn1tvvdVq1apljz32mP3xxx/266+/Wvfu3e3aa6+lweYjXo515A7RRhOkClm6dKl169bNHnjggRKDUNHXw4cPt0AgYO3bt7fXXnstcv+WLVvsqaeeikwoNGfOHPvxxx8dH3vOnDmWnp5uK1asiNx2zjnnWI8ePeyNN94oVoPZ/wbA119/3VJTU61FixZ22mmnWcuWLYt9Qg9/uPrqq+3QQw+1tWvXlvpzNtsxqWiHDh1KXZqv6N9//PGH9evXr8Q16WUhd/HLy/Fu6tSp1qpVK/vvf/9rs2fPtuuuu86CwaA9//zzkU9dd/1UrKCgwDp16mQpKSk2YsQIu+OOO6x27do2fPjwYsuWIvZ5Nd6Ru/jk5VgXDofthBNOsBtvvNHMdiyP+8ILL1ggELCpU6fudhLVVq1aWSAQsHPPPddat25tPXv2tD/++KN8Dx6e8mqsI3eoDDRBfKis6+GuuOIKa9Wqlf3+++8lti/aZsWKFVajRg277777im3z7LPPWr9+/eyTTz7Z7bHKcscdd1inTp0sOzs78j1r1661Pn362LnnnhtZVaO0/Z133nl2xhlnRCalLO+xEX07T8i26y+43Nxc69Spk1111VV73M8rr7xigUAgci1maZ35jh072hNPPFHiWKUhd1VfLI13RaftnnPOOZFTz4sMGzbMOnfuXOrZREWfUr3xxhtWrVo1Gzp0qI0cObLYmxXEjlgb78hdfIilsa7o/mXLlllSUlJkDogil112mbVq1arE2SZm/7t89O2337ZAIGCvvfZase34HRs7Ym2sI3eoTEHBdwKBQLGvg8EdP8Zvv/1Wxx9/vJo0aaJQKFRs+2AwKDPT/vvvr5tuukmPPPKIjjnmGD3yyCMaOHCgRo4cqf79+6t37967PZYkffrpp1q+fLk2b94cua1p06b65ZdflJSUpEAgoFAopKZNm+rcc8/VTz/9pE8++aTE/goLCyVJI0aM0OrVq7V48eLIfeFwuNRjo/IVFhYqEAgoEAgoJycncpu04+eZkJCgX375RXXr1i12XxEzi/z7xBNP1EknnaThw4dL+l92i3z++ef69ddflZKSEtl/EXIXn7we73Y9diAQ0IoVK5Seni5JysvLkyTdf//9Kigo0FtvvaWsrKxi35eQkCBJOvXUU3X00Udr27ZtGjlypE4//XSFw+ESrxl4J1bGu52Ru/jg9Vi3detWfffddwqFQpH7GzdurDp16mjp0qWSpO3bt0uSHnnkEWVnZ+v1118vlnlJSkxMlLQj/127dtWTTz6pVq1aSVKxfcNbsTLWkTt4xrv+CypqxowZ9tBDD0W+Luq89u3b17p167bb7y06hWzmzJk2ePBgO/vss23o0KH2999/R7Ypq1u6ZMkSa9++vTVq1Mhat25t7dq1s6+++srMdkxelJaWFqmraJK17du32yGHHGJjxozZ7b5Hjx5tXbp0iVzCAO/t3MnPycmxa665xk455RQ77LDDrEePHvb666/bhg0bzMzs+OOPt0MPPbTMfW3atMmmTZtmZjtmsa9fv7799NNPxbbZsmWLXXjhhSVOlyR38c2r8c6s7Al3b775ZmvRokWJ40ycONGaNWtm33//fYl9FX0qP3fuXGvevLk98sgjxSZvg7diZbwzI3fxysuxbte5ZoouD928ebNdcMEFdsIJJ0QuuSr6PXv33Xdbo0aNSt1fUe6WLFligUDAJk2axHwzMSKWxjpyBy/RBPGBr776ysaNG2e5ublmtuPUyIMPPtgmTJhgDRs2tDZt2piZ2YgRI6xZs2b29ttvm1nJFS1++uknu+2222z9+vWR23a+JjgUCu12NvqTTjrJhg8fbr/99pstXbrUevXqZd27d48sqXfttddaampq5A+sol/Ko0aNsq5du5a6351P5ezXr599+eWX5XtyEHW33nqrJSUlWf/+/W3y5Mk2duxYGzhwoNWuXdtGjx5tZmZPPfVU5FrNXRUWFtoDDzxgw4cPt4KCAisoKCjzes5df1mRu/jj9Xi3pwl3w+Gwffzxx9aqVSubOHGimRVfWaNOnTr23HPPmVnJU4KLjnf++edbmzZtSn3TCm95Nd6Ru/jj9VhXpLS5ZgKBQCRP06ZNs86dO9ukSZOKHX/p0qVWv379MldkK8rhaaedZsOGDWOVtRjj5d92ZuQO3qMJ4gP33HOPPfnkk5HBZPny5ZacnGwJCQk2duzYyHbffvutNWrUyE455RTbsmVLif3cddddkcFqV3uaNfmXX36xevXqFZs/4eeff7bTTjvN+vbta1lZWfbLL79YmzZtbNCgQcW+98wzz7RLL73UzEr/JKK06xHhvZ9++sk6depk9erVi0yqtrMLL7zQGjRoYK+//rr9888/duKJJ1q9evXszTffNLMdv7C2bdtmmZmZdvjhh9srr7xS7PuddOfJXfyJhfGurAl3jzzySHv//fctPz/fLr/8cmvbtm3kk9bCwkLbvHmzdejQwe66665S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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABEEAAAG4CAYAAACqxPmiAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAdFBJREFUeJzt3Xd8k/X6//F30rKhpWwQKPQAKgKCoALKciDDvcADIstZPSoiDhQVB+JBxKMFRY84cS9cBz0quEBF5Au4QDYuhBZaRkvTXL8/+CWHWgp3StI7d/N6Ph48tEl631fSdz5Jrtz35+MzMxMAAAAAAEAF53e7AAAAAAAAgPJAEwQAAAAAACQEmiAAAAAAACAh0AQBAAAAAAAJgSYIAAAAAABICDRBAAAAAABAQqAJAgAAAAAAEgJNEAAAAAAAkBCS3S4g3gWDQf3666+qVauWfD6f2+UAAAAAAIC/MDPl5eWpSZMm8vtLP96DJsgB/Prrr2rWrJnbZQAAAAAAgAPYsGGDmjZtWur1NEEOoFatWpL2PJApKSkuVwMAAAAAAP4qNzdXzZo1C3+GLw1NkAMInQKTkpJCEwQAAAAAgDh2oGksmBgVAAAAAAAkBJogAAAAAAAgIdAEAQAAAAAACYE5QaKkqKhIhYWFbpeRcCpVqqSkpCS3ywAAAAAAeABNkINkZvr999+1detWt0tJWLVr11ajRo0OOAEOAAAAACCx0QQ5SKEGSIMGDVS9enU+iJcjM9POnTu1adMmSVLjxo1drggAAAAAEM9oghyEoqKicAOkbt26bpeTkKpVqyZJ2rRpkxo0aMCpMQAAAACAUjEx6kEIzQFSvXp1lytJbKHHnzlZAAAAAAD7U+GbIBs2bFDv3r3Vtm1bdejQQS+//HLU98EpMO7i8QcAAAAAOFHhT4dJTk7WtGnT1LFjR23atElHHXWUBgwYoBo1arhdGgAAAAAAKEcV/kiQxo0bq2PHjpKkBg0aqE6dOsrOzna3qDjQokULTZs2rdz36/P59MYbb5T7fgEAAAAAcL0J8sknn+i0005TkyZNSv2APH36dLVs2VJVq1ZV586d9emnn5ZpX4sWLVIwGFSzZs0OsurYGj58uHw+X/hf3bp11a9fPy1dutTt0gAAAAAA8CzXT4fZsWOHjjzySI0YMULnnHNOietffPFFXXPNNZo+fbqOO+44Pfroo+rfv7++//57NW/eXJLUuXNnFRQUlPjd999/X02aNJEkbdmyRcOGDdPjjz8e2zsUJf369dOsWbMk7VmG95ZbbtGpp56q9evXu1wZAAAAgETR4sZ33C7BkbX3DnS7BHiE602Q/v37q3///qVeP3XqVI0aNUqjR4+WJE2bNk1z587VjBkzNGnSJEnSN998s999FBQU6KyzztJNN92k7t27H/C2ezdUcnNzJUnBYFDBYLDYbYPBoMws/C+aqlSpooYNG0qSGjZsqHHjxqlXr17atGmT6tevrxtuuEFvvPGGNm7cqEaNGunvf/+7JkyYoEqVKoW3MWfOHN15551avny5atasqZ49e+rVV18NX7933bNmzdK1116rl19+WSeffLK+//57XX/99frkk09Uo0YN9e3bV1OnTlW9evUkSX369FH79u1VtWpV/fvf/1blypV16aWX6vbbbw9vf+XKlRo9erS++uorZWRkhE+/ifbjFdrevv5GAAAAAMrOr+h+zokVPgfAaQZcb4Lsz+7du/XNN9/oxhtvLHZ537599cUXXzjahplp+PDhOuGEE3ThhRce8PaTJk3SHXfcUeLynJwcBQKBYpcVFhYqGAwqEAiUuO5ghD7Mh7a5fft2Pfvss2rVqpVSU1MVCARUo0YNPf7442rcuLGWL1+uyy+/XDVq1NDYsWMlSe+++67OOecc3XjjjXriiSe0e/duvffee8XqDO1j6tSpuu+++/TOO+/o2GOPDa+oM3LkSE2ePFm7du3S+PHjdf755+v999+XtOdxffrpp3X11Vfrs88+08KFCzV69Gh17dpVJ510koLBoM4++2zVq1dPn376qfLy8nTddddJkoqKiqL6eAUCAQWDQW3btk07d+6M2nYBAACARJeR4o0mCPM+Ii8vz9Ht4roJsnnzZhUVFYWPiAhp2LChfv/9d0fb+Pzzz/Xiiy+qQ4cO4flGnnnmGbVv336ft7/ppps0ZsyY8M+5ublq1qyZ0tLSlJKSUuy2+fn5ysnJUXJyspKTo/dQ+v1+vfvuu0pLS5O055Shxo0b66233lLlypUlSRMmTAjfvlWrVlq5cqVeeumlcMNo8uTJGjx4sO68887w7Tp37lxiP7feequefvppffzxx+HH5LHHHtNRRx2le++9N3zbJ554Qs2bN9fq1avVpk0b+Xw+dejQIdwwOvzww/XII49o3rx56tevn95//339+OOPWrNmjZo2bSpJuueeezRgwAAlJSVF9fFKTk6W3+9XamqqqlatGrXtAgAAAIluda7P7RIcqVOnjtslwGVOP2PGdRMkxOcr/sQzsxKXleb444+P6NCoKlWqqEqVKiUu9/v98vv9JS7bewLTaOrTp49mzJghaU9Xc/r06RowYIC++uorpaen65VXXtG0adP0888/a/v27QoEAkpJSQnXsWTJEl188cX7rWvq1KnasWOHFi1apIyMjPDlixcv1scff6xatWqV+J3Vq1fr0EMPlSR16NCh2PYbN26sP//8Uz6fTz/++KOaN29ebBLa0KlI0X68Qtvb198IAAAAQNkF5Y0mCJ8D4DQDcZ2UevXqKSkpqcRRH5s2bSpxdEhFU6NGDbVq1UqtWrXSMccco3//+9/asWOHHnvsMS1cuFCDBw9W//799fbbb+vbb7/V+PHjtXv37vDvV6tW7YD76NGjh4qKivTSSy8VuzwYDOq0007TkiVLiv1buXKlevbsGb7d3vOPSHuaEaGG077m/Ih2owgAAAAAgEjE9ZEglStXVufOnfXBBx/orLPOCl/+wQcf6IwzzijXWsp7YlSpZCPB7/dr586d+uyzz5Senq6bb745fN3atWuL/U6HDh304Ycfavjw4aVu/+ijj9aVV16pfv36ye/36/rrr5ckderUSa+99prS09P3eUhRaB+l3W8z0+GHH67169frl19+Ca/QE5rHhYlRAQAAAG9gYlR4hWcmRt2+fbt+/vnn8M9r1qzRkiVLVKdOHTVv3lxjxozRhRdeqC5duqhbt26aOXOm1q9fr8suuyymdWVlZSkrK0tFRUWSyn9i1Pz8fG3cuDG87xkzZmj79u0aMGCAtm3bpvXr1+u5555Tly5d9N5774XnOwnVMX78eJ1yyilq0aKFzj//fAUCAc2dOzc8cWpoP0cffbTeeustnXrqqfL7/br66qt16aWX6vHHH9fgwYN13XXXqW7dulq1apVeeuklPfLII0pKSgo3Hv460Wro8ejdu7fatGmjYcOGafLkycrLy9P48eMlMTEqAAAA4BVMjAqv8MzEqIsWLVKfPn3CP4cmJb3ooov05JNPatCgQdqyZYsmTpyo3377Te3atdO7776r9PT0mNaVmZmpzMxM5ebmKjU1tdwnRp07d66aN28uSapV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pYcffljVq1fXoEGDNGTIEFWpUkWzZ8/W9u3bdfbZZ2vGjBm64YYbiu135MiRWrhwoRYtWqRLL71ULVu21MUXXyxJKioq0p133qlDDz1UmzZt0pgxY3TxxRfrnXdKnw09OTlZfr9fqampqlq1amQPHgAAgEeszvW5XYIjderUcbsERzo908ntEhz59sJvXd2/Z3JXdYXbJTiyLpDvdgmOeOV5vDenn4tpgjjk9/vl9/tLXObz+cL/3FKWfXfo0EG33367JKlNmzbKysrSRx99pDZt2oS3Gdru3v/d+/K77rpLxx9/vCRp1KhRuummm7Rq1SplZGRIks4991zNmzdPN954Y3i/zZo107Rp0+Tz+XTYYYdp+fLlmjZtmi655JLwdkL+9re/6V//+peOOeYY7dixQzVr1iz1/vt8vn3+jQAAACqKoLzxYdQr78eC8sbh/m4/np7JnUf+nuQudpzW7L17hqjo0KH4vCWNGzfWpk2byryNhg0bqnr16uEGSOiyv26za9euxZo23bp108qVK8OnHX377bc644wzlJ6erlq1aql3796SpPXr10dUGwAAAAAAf0UTJEFVqlSp2M8+n0/BYDDcPdt78tLCwsIDbsPn85W6Tad27Nihvn37qmbNmnr22Wf19ddf6/XXX5ck7d692/F2AAAAAADYF5ogKKZ+/fqSpN9++y182ZIlS6K2/YULF5b4uXXr1kpKStKPP/6ozZs3695771WPHj102GGHRXx0CgAAAAAApaEJgmKqVaumrl276t5779X333+vTz75RLfcckvUtr9hwwaNGTNGP/30k55//nk99NBDuvrqqyVJzZs3V+XKlfXQQw9p9erVmjNnju68886o7RsAAAAAkNhogqCEJ554QoWFherSpYuuvvpq3XXXXVHb9rBhw7Rr1y4dc8wxyszM1FVXXRWeFLV+/fp68skn9fLLL6tt27a69957NWXKlKjtGwAAAACQ2Hy29+QPKCG0RO62bdv2uUTumjVr1LJlS5ZmdRF/BwAAkAha3PiO2yU4svbegW6X4Ej7p9q7XYIjyy5a5ur+PZO7qn93uwRH2rds7nYJjridu7LY32f3vXEkCAAAAAAASAg0QQAAAAAAQEKgCQIAAAAAABICTRAAAAAAAJAQaIIAAAAAAICEQBMkClhgx108/gAAAAAAJ2iCHIRKlSpJknbu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IJs3b1ZRUZEaNiwelIYNG+r333+PyT5vuukmjRkzJvxzbm6umjVrprS0NKWkpMRkn7GyOtfndgmO1KlTx+0SECVeyZwk1am6wu0SHFkXyHe7BEfcfB57JXdeyZxE7pwgd9FF5pwhd9FF7pwhd9FF7mInOdlZeyOumyAhPl/xJ56ZlbjMieHDhx/wNlWqVFGVKlVKXO73++X3e+PQpZCgvDFgee1xRem8kjlJ8ssbh/gFPVKnm89jr+TOK5mTyJ0T5C66yJwz5C66yJ0z5C66yF3sOK05ru9ZvXr1lJSUVOKoj02bNpU4OgQAAAAAAGB/4roJUrlyZXXu3FkffPBBscs/+OADde/e3aWqAAAAAACAF7l+Osz27dv1888/h39es2aNlixZojp16qh58+YaM2aMLrzwQnXp0kXdunXTzJkztX79el122WXlWierw8SO1x5XlM4rmZOYQTzaWKXjwLySOYncOUHuoovMOUPuoovcOUPuoovcxY5nVodZtGiR+vTpE/45NCnpRRddpCeffFKDBg3Sli1bNHHiRP32229q166d3n33XaWnp8e0rqysLGVlZamoqEgSq8PEkhdnHsa+eSVzEjOIRxurdByYVzInkTsnyF10kTlnyF10kTtnyF10kbvY8czqML1795bZ/p9YV1xxha644opyqmiPzMxMZWZmKjc3V6mpqawOE0NenHkY++aVzEnMIB5trNJxYF7JnETunCB30UXmnCF30UXunCF30UXuYqdCrQ4TD1gdJna89riidF7JnMQM4tHGKh0H5pXMSeTOCXIXXWTOGXIXXeTOGXIXXeQudirE6jAAAAAAAADRQhMEAAAAAAAkBE6HcYjVYWLHa48rSueVzEnMIB5trNJxYF7JnETunCB30UXmnCF30UXunCF30UXuYsczq8PEK1aHKT9enHkY++aVzEnMIB5trNJxYF7JnETunCB30UXmnCF30UXunCF30UXuYsczq8PEK1aHKT9enHkY++aVzEnMIB5trNJxYF7JnETunCB30UXmnCF30UXunCF30UXuYofVYaKM1WFix2uPK0rnlcxJzCAebazScWBeyZxE7pwgd9FF5pwhd9FF7pwhd9FF7mKH1WEAAAAAAAD2QhMEAAAAAAAkBE6HcYjVYWLHa48rSueVzEnMIB5trNJxYF7JnETunCB30UXmnCF30UXunCF30UXuYofVYQ4Sq8OUHy/OPIx980rmJGYQjzZW6Tgwr2ROIndOkLvoInPOkLvoInfOkLvoInexw+owB4nVYcqPF2cexr55JXMSM4hHG6t0HJhXMieROyfIXXSROWfIXXSRO2fIXXSRu9hhdZgoY3WY2PHa44rSeSVzEjOIRxurdByYVzInkTsnyF10kTlnyF10kTtnyF10kbvYYXUYAAAAAACAvdAEAQAAAAAACYEmCAAAAAAASAg0QQAAAAAAQEJgYlSHgsGg59ZK9sya3h57XFE6r2ROYi35aHPzeeyV3HklcxK5c4LcRReZc4bcRRe5c4bcRRe5ix2nNdMEKUVWVpaysrJUVFQkScrJyVEgEHC5qsh4Zk1vD65BjX3zSuYk1pKPNjefx17JnVcyJ5E7J8hddJE5Z8hddJE7Z8hddJG72MnLy3N0O5ogpcjMzFRmZqZyc3OVmpqqtLQ0paSkuF1WRDyzprcH16DGvnklcxJryUebm89jr+TOK5mTyJ0T5C66yJwz5C66yJ0z5C66yF3sJCc7a2/QBHHI7/d7bq1kz6zpPTHN7RKcuX2b2xXEPa9kTmIt+Whzc3z0Su68kjmJ3DlB7qKLzDlD7qKL3DlD7qKL3MWO05q9d88AAAAAAADKgCYIAAAAAABICDRBAAAAAABAQqAJAgAAAAAAEgJNEAAAAAAAkBBYHcahYDCoYNAbM/mG+OWNNb2DXunFeezv7wavZE7yTu78HqnTzfHRK7nzSuYkcucEuYsuMucMuYsucucMuYsuchc7TmumCVKKrKwsZWVlqaioSJKUk5OjQCDgclWRyUjxxoCVXbmN2yU4k53tdgVxzyuZk7yTu/Tkhm6X4Ei2i88Pr+TOK5mTyJ0T5C66yJwz5C66yJ0z5C66yF3s5OXlObodTZBSZGZmKjMzU7m5uUpNTVVaWppSUlLcLisiq3O9saZ3naor3C7BmTp13K4g7nklc5J3crcukO92CY7UcfH54ZXceSVzErlzgtxFF5lzhtxFF7lzhtxFF7mLneRkZ+0NmiAO+f1++f3eOHQpJChvDFh+eeRQK4/9/d3glcxJ3sld0CN1ujk+eiV3XsmcRO6cIHfRReacIXfRRe6cIXfRRe5ix2nN3rtnAAAAAAAAZUATBAAAAAAAJASaIAAAAAAAICHQBAEAAAAAAAmBJggAAAAAAEgINEEAAAAAAEBCoAkCAAAAAAASAk0QAAAAAACQEJLdLsArgsGggsGg22VExC9zuwRHgl7pxXns7+8Gr2RO8k7u/B6p083x0Su580rmJHLnBLmLLjLnDLmLLnLnDLmLLnIXO05rpglSiqysLGVlZamoqEiSlJOTo0Ag4HJVkclI8caAlV25jdslOJOd7XYFcc8rmZO8k7v05IZul+BItovPD6/kziuZk8idE+QuusicM+QuusidM+Quushd7OTl5Tm6HU2QUmRmZiozM1O5ublKTU1VWlqaUlJS3C4rIqtzfW6X4EidqivcLsGZOnXcriDueSVzkndyty6Q73YJjtRx8fnhldx5JXMSuXOC3EUXmXOG3EUXuXOG3EUXuYud5GRn7Q2aIA75/X75/d44dCkkKG8MWH555FArj/393eCVzEneyV3QI3W6OT56JXdeyZxE7pwgd9FF5pwhd9FF7pwhd9FF7mLHac3eu2cAAAAAAABlQBMEAAAAAAAkBJogAAAAAAAgIdAEAQAAAAAACYEmCAAAAAAASAg0QQAAAAAAQEKgCQIAAAAAABICTRAAAAAAAJAQaIIAAAAAAICEQBMEAAAAAAAkhGS3C/CKYDCoYDDodhkR8cvcLsGRoFd6cR77+7vBK5mTvJM7v0fqdHN89EruvJI5idw5Qe6ii8w5Q+6ii9w5Q+6ii9zFjtOaaYKUIisrS1lZWSoqKpIk5eTkKBAIuFxVZDJSvDFgZVdu43YJzmRnu11B3PNK5iTv5C49uaHbJTiS7eLzwyu580rmJHLnBLmLLjLnDLmLLnLnDLmLLnIXO3l5eY5uRxOkFJmZmcrMzFRubq5SU1OVlpamlJQUt8uKyOpcn9slOFKn6gq3S3CmTh23K4h7Xsmc5J3crQvku12CI3VcfH54JXdeyZxE7pwgd9FF5pwhd9FF7pwhd9FF7mInOdlZe4MmiEN+v19+vzcOXQoJyhsDll8eOdTKY39/N3glc5J3chf0SJ1ujo9eyZ1XMieROyfIXXSROWfIXXSRO2fIXXSRu9hxWrP37hkAAAAAAEAZ0AQBAAAAAAAJgSYIAAAAAABICDRBAAAAAABAQqAJAgAAAAAAEgKrwwAOtX+qvdslOLLsomVulwAAAAAAcYkjQQAAAAAAQEKgCQIAAAAAABICTRAAAAAAAJAQaIIAAAAAAICEQBMEAAAAAAAkBJogAAAAAAAgIdAEAQAAAAAACYEmCAAAAAAASAg0QQAAAAAAQEKgCQIAAAAAABICTRAAAAAAAJAQkt0uwCuCwaCCwaDbZUTEL3O7BEeCHunF+T1Sp5s59UrmJHIXbeTuwLySOYncOUHuoovMOUPuoovcOUPuoovcxY7TmmmClCIrK0tZWVkqKiqSJOXk5CgQCLhcVWQyUrwxYGVXbuN2CY6kJzd0uwRHsrOzXdu3VzInkbtoI3cH5pXMSeTOCXIXXWTOGXIXXeTOGXIXXeQudvLy8hzdjiZIKTIzM5WZmalt27apdu3aSkpKUnKytx6un//c5XYJjiRX/cntEhxZXXOH2yU44mZOvZI5idxFG7k7MK9kTiJ3TpC76CJzzpC76CJ3zpC76CJ3sZOUlCRJMtt/485nB7pFgtu4caOaNWvmdhkAAAAAAOAANmzYoKZNm5Z6PU2QAwgGg/r1119Vq1Yt+Xw+t8uBA7m5uWrWrJk2bNiglJQUt8tBgiB3KG9kDm4gd3ADuYMbyJ33mJny8vLUpEkT+f2lz73ivWNcypnf799vFwnxKyUlhQEL5Y7cobyRObiB3MEN5A5uIHfekpqaesDbeGNqWgAAAAAAgINEEwQAAAAAACQEmiCocKpUqaLbbrtNVapUcbsUJBByh/JG5uAGcgc3kDu4gdxVXEyMCgAAAAAAEgJHggAAAAAAgIRAEwQAAAAAACQEmiAAAAAAACAh0AQBkPCKiorcLgEAygXjHYBEwFiH/aEJAiBhheaFTkpKksQLJoCKi/EOQCJgrIMTNEEQd1iwCLFmZgoGg/L5fJKkxx57TH6/X//9739drgyJhvEOscZ4h3jAWIdYY6xDJJLdLgAICQQCSk5ODg9eQKz4fD75fD4tWLBAo0aN0tatW/XII4/olFNOcbs0JAjGO5QXxju4ibEO5YWxDpHgSBDEjeTkPT25Rx55RP/85z+1detWdwtChRUIBDRhwgQdd9xxOuecc7Rq1SpdcsklxW7Dt1aIJcY7lBfGO7iJsQ7lhbEOkaAJgrixYMECtW7dWtOmTVNubq42b97sdkmooPx+vzZs2KAuXbrovPPOU7Vq1cLXvfHGG8UOpwRigfEO5YXxDm5irEN5YaxDJGiCwBXBYLDYz4WFhbr77rvVp08ffffdd5owYYJatWrlUnWoyILBoPx+vy677DKlpqZq5syZkqTXXntNTZs21YwZM/THH3+4XCUqEsY7uIXxDuWJsQ5uYaxDpJgTBK7w+4v33z755BN99NFH+vzzz5WUlBSe0dnM6NqiTFasWKHvvvtOAwcOVOXKlcOXh7J37LHHqlevXnrttdfUqlUrFRQU6Nprr1VmZqaqVq3qVtmogBjvEGuMd4gHjHWINcY6RAtHgsAVCxYs0HHHHaf169dLkqpUqaIaNWqoZs2akv63nBUvkiirmTNn6vzzz9eKFStKXBf6tmro0KFq0qSJgsGg3n//fV133XXFXlSBaGC8Q6wx3iEeMNYh1hjrEC00QRBz+1qfu02bNlqwYIGeffbZ8GVNmzbVa6+9JmnP2t5mpt27d2vx4sXKzc0tt3rhbaFJr6ZMmaIGDRro0Ucf1Y4dO4rdJvSNQYsWLXT22WerYcOG+uCDD4pdB5QF4x3KE+Md3MJYh/LEWIdoIxGIuaSkJP3555/64YcfJO3p1NatW1dTpkzRfffdpx9//FHHH3+8jjrqKL3zzjvhAcvn8+njjz/WhAkT9Ouvv7p5F+AhPp9PgUBAknTfffdp5syZ+vrrr0vcLvSCes455+jQQw/VnDlzwt8s/PW8ZsApxjuUJ8Y7uIWxDuWJsQ7RRhME5WL06NE688wztXPnznA3dsyYMapfv77uueceSdLYsWPVrFkznXrqqTr99NM1cOBAnX322erYsaMOO+wwN8uHB+z9rVRoSb4hQ4aoffv2+uc//7nPGenNTKmpqTrvvPNUVFSkRx99VBLfGODgMN4h1hjvEA8Y6xBrjHWIFdKAMotkre1Jkybpl19+0Ysvvljsd6dMmaLZs2frww8/1OGHH67nnntODz/8sDp27KgWLVpo+fLluuuuu2JSPyqG0Atk6DDb3377TQUFBeHrH330Ub333nuaO3dusW8B9j4neeDAgWrdurW+/vrr8LnMwN4Y7xAPGO8Qa4x1iAeMdYg1n0Uy2gH/X1FRUXiW7127dqlatWrFLtuX6667Ti+99JK+/vprNWrUSJK0e/duHXrooerSpYseeuih8OV/3Zff72ciLezXjBkzNGXKFNWtW1dJSUl66qmnlJGRoeTkZA0ePFjLly/XW2+9pZYtW0qSvvjiC3344YcaPHiwWrdurTVr1qhu3bpKSUlx+Z4g3jDeId4w3iEWGOsQbxjrECscCYKIhLqtSUlJysnJ0amnnqoLL7wwfNn+3HLLLQoEApo2bVr4sp9++knVqlXTq6++qv/+97/73F9SUhIvkggzs2KHR2ZnZ2vIkCGaOnWqbrnlFk2bNk3169fXyJEj9c0330iSHnnkEa1cuVKzZ88Of5OwcuVK3X777Vq+fLkkqWXLlkpJSeGcUYQx3sFtjHcoD4x1cBtjHcqdAWUwfvx4S05OtoEDB9rq1asd/97TTz9tSUlJds8999jSpUstMzPTZs2aZXPmzLGCgoID/n4wGLRgMHgwpcOjcnJy7J577gn/vHnzZisoKLCNGzfajTfeaEuXLjUzsx07dtiZZ55pSUlJNm7cOPvjjz/MzOyOO+6wRo0a2ddffx3exn//+19H+yZ3ic2t8Q6Jy83xDonLzbGuqKiorGXDw9we68hd4qIJgoh8/vnn1qxZM8vIyLB58+aVaRuZmZnWvn17S01Nta5du9ratWvD1+3vg2ZhYWH4/3fv3l2mfcO7Fi9ebD6fz1544QW75pprrHbt2vbf//7Xtm/fbitXrjQzs2nTplndunVtyJAhNmbMGKtXr57NmTMnvI1KlSrZiBEjbOfOncW2Te6wL26Od0VFRbw5S2BujXfkLjEx1sEtjHVwC00QROSJJ56w5ORke+WVV4pdvn79+gN+axAabAoLC+3333+3b7/9NuL9B4NBu/nmm238+PERfUsBbyoqKip2FEbXrl2tevXq1q5dO/v888+L3faTTz6xjh072gsvvGBme3JWo0YNGzlypP3www9mZrZw4ULbsGFDxHWQu8Tk1ngXCATC/79hwwYylyDcHu/IXeJya6zb+0Po4sWL7eqrr7Yff/zReeHwJLfHOnIHMzPmBEFERowYoZ49e2r27NnKy8uTJA0fPlxdunTRzz//vN/fDS1NlZSUpIYNG6pjx46Sii9/tbfQ5fb/5+595ZVX1KhRI73//vtq2bKlCgsLo3GXEIfs/58bGpo0raCgQLt27dL69etVVFSkwYMHq3v37pL2nFtsZnrrrbdUs2ZNnXDCCZKkt956S2lpaXrxxRf1ww8/SJKOOeYYNW3adL/nhpI7hJTneLe3pKQk5efn6+KLL9bRRx+t1157TZs2bTq4O4O45eZ4tzdyl7jKe6wLvb76/X7t2LFDQ4cOVZ8+ffTrr7/ql19+4XW2gnJ7rCN3KMat7gvi19tvv73Py0PfEs2bN88OOeQQO+ecc6xu3bp28sknl+jclvb7fz00bV+Hqq1du9Y2bdpU7LLff//djj32WJs0aZLTu4EKICcnxy677DK75ppr7M8//zQzs0cffdRq1aoV7tqHMnT77bdbRkaGvfDCC7Z48WIbOHCgvfXWW/b+++872he5S0xuj3dmJc9JXr58uR122GF24okn2scff2yrVq3isN0EUJ7jnRm5SzTxMNbl5uaWuOy2226zY489NnzqA/NvVXzlPdaRO+wLTRAUs2jRovC5eWalTxh02WWXWXJyst19990H3GYwGCx2mO1//vOf8Pb/6sMPP7T69evb3Llz7ffff7devXrZkiVL7PPPP7c2bdrYp59+art377aPPvrI5s6da6+//rr9/vvvZbiniDehrIVeiGbMmGEpKSl28skn28svvxx+YczPz7cjjjjChg4dWixbwWDQevXqZa1bt7bq1avbhRdeWGw+j/29wJG7xOT2ePfFF1/YfffdF/75//7v/8zMbNasWdanT5/w+c2h7e29XXibm+MduUs8bo91+fn51q1bNxs3bpyZmb3yyis2YcIEMzNr3769TZw40czMVq1aZT/88IPNmzfPtmzZ4vwOIm65OdaRO+wPTRDYxo0bwx3V/Px8u+yyy+xvf/vbPm8bGsw2btxo6enpdvvtt9u2bdvMbN8D0d4D1Zo1a6x///6WmppqDz30UKn19OzZ0w477DCrVq2anXTSSbZt2zb7/fffrX379nbUUUdZq1atbMCAAda8eXNr3bq1nXbaaWW+74hPa9assY4dO9qsWbP2ef2cOXMsKSnJ5s+fH74s9G3CkiVLbM2aNeHLnXb3yV1iiKfx7vHHHw9/MOnZs6elpaVZXl6e3XTTTdayZUt799137b777rOxY8da165dbcyYMbZkyZKDuv+IP+U93pG7xBBPY52Z2XPPPWe1atWydu3aWa1atezxxx83M7Phw4dbRkaG9erVy/r162fHHnus+Xw+O+uss2zBggVlu/OIS268tyN3KA1NENi4ceMsKSnJfv31VzMz++GHH6xu3bp21113mVnJbwxCP99+++12+OGH29y5c/e7/WAwaFdffbXVrFnThg4dWmzG8NDkSGZ7XqS3bt1qbdq0seTkZBszZkyx7SxdutSmTZtmb7zxhs2fP9+ys7Pt4YcftkMPPZQJjSqAnJwcGzJkiP3555/2ySefWJs2bWz+/PmWm5tr77zzjs2ePdueeuqp8Aviqaeeas2bN7fJkydb+/bt7ayzziq2esv+Zv4md4nLzfFuX+rVq2dJSUk2aNCgcLa3b99up5xyitWpU8cGDhxo//jHP+yqq66yHj162MiRI8t0vxFfynO82xdyV/G5Pdb9dfvjxo0zn89nhx56qGVnZ4cvLygosIkTJ9rYsWPttddes6+++sq+/PJLa9y4sT333HNlu/OIG+U91pE7OEUTBLZ161ZLT0+36667zsz2vLBNnjzZqlWrFl6HO/SBMRgMhgeYQCBgnTp1sosvvrjUWZmffvppS01NteOPP77Esmt7H0a5Y8eO8P8vWbLEhgwZYr169bKvvvpqv7XfcMMNdu6550Z4j+G2fR1e/emnn1rLli3tm2++sZUrV9pJJ51kLVq0sIyMDDv11FOtZcuW1qpVK+vbt6+ZmeXl5dmwYcOse/fuduutt5Zp3+Qu8bg13u3deAt57733rEOHDubz+eytt94K387MLDs72/744w8rLCy0/Px8MzMbMGCAXXPNNcVqRPxzc7wjd4krXsa60HYXL15sM2bMMJ/PZx988EGx6/5qw4YN1q5dO/vss8/Kevfhgnga68gdDoQmSIILDRizZs2yKlWqhM8N/u2336xdu3Y2ZMiQYrcLCR0K+eSTT1rVqlXtP//5T4ltb9q0yW6++WZ79NFHiw04ew+Subm5Nnz4cBswYIDddttt4UNuV65cac2bN7cJEyaED8ksLCy0oqIie/PNN+3tt9+2k046yerXr2+vvfZatB4OxNj+uve///671a1bN3wY5IoVK+yxxx6z+fPn29dff225ubn22GOPWdOmTW3VqlVmZrZz504rKCgIb2N/566TO7gx3v11e9999519+eWXtmvXrvBl5557rh155JHFDvUN2bZtmwWDQZs9e7Yddthh9uyzz5bhnsMNbo53ZuQukbkx1u295KnZnmbb0KFD7fbbb7eVK1eG83rmmWda586dS0xWuWHDBvvwww/tkUcescaNG9ugQYMsJyfnIB8JlAc3xzpyh7KiCZKA9j6XMyQYDNoxxxxjp512Wngwe+WVV8zn89nChQvNbM8gt2vXLps6dWp4NmUzs//+97+l7mt/A9eSJUusX79+duKJJ9q4ceOsTZs2dvjhh4c/kI4fP97atm1rH374YbHfu/POO+3II4+0iy++2PLy8pzfcbhq7xepH3/8MfwN0t557NWrV/hbq3257LLLbPjw4SUu39e3naUhd4nFzfFu70zm5uba+eefb9WqVbNmzZrZiSeeaC+99JKZ7flgkpycbPfee2+xN35r1qyxoUOHWo8ePSwtLc0eeeSRMjwCcIOb4x25S0zx8t5u9+7ddtddd1m9evVs8ODBlpGRYW3btrUHHnjAzPZ82VClShWbMWNGsd/7/PPPrVu3bta2bdtS54xA/ImX93bkDpGiCZJA/jqQfPrpp/bzzz+Hz7WbP3+++f1+e/PNN81szyFpAwcOtGOOOSb8O1u3bjWfz2dXXnllsXP09rX90mzevNkuuOAC69evnw0ZMiT8zdQPP/xgp5xyinXu3NnM9gxoHTt2tMGDB9vixYstKyvL/vGPf5jZnjdvIft64Yd7/pqDvd8sbdy40Xr37m3//ve/rXfv3ta9e3e75ZZbzGzP3BxDhw61q6++Onz0RX5+vr3++uv29NNPW+fOna1Fixb20UcflakucpdY4mW8MzP74IMPbNKkSTZo0CBbvny5ffTRR3beeedZ69at7ZNPPjEzs5tuuskaN25sixcvNrM98zPk5ubarFmzbNKkScXyxrKl8SNexzszcpco4mms+9e//mWnnXaaDRkyJHxKQX5+vl1++eV21FFHhU+dueGGG6xhw4a2ePFi++2332zcuHG2dOlS++6774ptj5WJ4kc8j3XkDmVBE6QC+uuhYX/1/PPP2yGHHGKdO3e2hg0b2lVXXWUrVqwwM7Pzzz/f2rVrFz5cbMGCBVajRg175plnwr8/d+5cW7du3UHVePHFF1ulSpUsMzOzWN2ffvqp1ahRw1555RUz2/ONxXHHHWeHHHKINWnSxP7973+Hbx/pRHCIrf39LYLBoH3//fd2xx132GmnnWbbtm2znJwcmzVrllWrVs3Gjx9vO3futLvvvrvYGzOzPUdm9OzZ08aPH3/QNZK7iifex7vPPvvMfD6fNWrUyJ5//vnw5T/88IOdffbZ1qdPn/Bl6enpdtJJJ9lVV11llStXtkmTJhXbFo23+BHv4x25q3jifawz+1/umjVrVmwZ+cWLF9vpp59uI0aMCF92+OGHW7t27axy5crWr1+/Yqcg8CE0fsT7WGdG7lA2NEEqmL0Hqz///NOys7OLvWjOnz/fjjjiCJs2bZoVFRXZxx9/bMcee6z16tXLzMzWrl1rNWrUsPvvv9/M9rz5ufzyy83n85UYHMryQTC0jdWrV1vHjh2tX79+xWZr3rJli/Xt29duvvnm8GUbN248qA4xYm/vjL3wwguWmZlpd911V3iZseuvv958Pp/17t3b1q9fX+x3X3jhBTvxxBOtR48e9vTTT1vr1q2LTca2efPmYudwluUNObmrmOJpvNvfh5OrrrrK/H6/vfjii8W29/TTT9vhhx8e/lb+888/t6uuusr69Oljc+bMcbx9lK94Gu/IXWKIp7Eu5K/ZCP3eiBEjrG7duiWWVb722mutX79+4Xz/+uuv9uabb9qXX37paH8of/E01u2rJjNyh7KjCVIB5ebm2iWXXGJ9+vSxU0891V599dXw4Y3XXntteFWL7du328iRIy0lJcWuv/768Ezwd9xxhzVq1Cj8jcDq1avtySefdLTvhx9+2M4//3y75557Sl1hIzSATZ482bp06VLsW/Zdu3ZZenp6+By+0ibtQvz5+uuvrV27dtamTRubMGGC3XLLLfb111+b2Z5lZtu3b2/HHXdcsdnoQ9auXWs9e/a0WrVqWbNmzYqdlxyyv3NDyV3icnO827Fjhy1btqzUfIQ+XKxbt86aNWtm1113nW3ZsiV8/WeffWYNGjQo9qZt77kZ9l6xAfHFzfGO3CUmN8e633//3W6++WabMmWKffrpp/u8TSgzmzdvtkqVKtmdd95ZbAW2W265xVq0aFHq3CV8Cx+f3BzryB1iiSZIBTNr1ixLS0uzU0891T766CObPXu2bd261YLBoOXn59v5559vjz32mN1///1Wu3Zt69u3r3377bfFtlFQUGA1atSwUaNGOdpnMBi0P//80/r162ctWrSwyy+/3Lp06WK1atWy1157LTzwhAa50IC1bds2O+mkk6xVq1b24osv2s8//2zTp0+3Zs2ahZexgjcsX77cOnXqZFdeeWV4VZW9FRYW2v33329VqlSxtWvXmlnJRkN2draNHz/ekpOTw5OSHugwTHKX2NwY70Luueceq1evnrVp08a6dOliTzzxxD5vF8rdbbfdZq1bt7annnoqfN27775r6enp4UPW98Ybs/jlxngXQu4Sk5tj3VNPPWU1atSwU045xdq3b29NmjQpdtTk3kL5ue2226x69er20EMP2a+//mrr1q2z7t2723XXXUeDzUPcHOvIHWKNJkgFsmzZMuvWrZv985//LDEIhX4ePXq0+Xw+a9eunb366qvh67dv324zZ84MTyg0b948+/HHHx3ve968edayZUv7+eefw5cNHjzYjjvuOHv99deL1WD2vwHwtddes7S0NEtPT7ezzjrLWrRoUewbenjDNddcY0cddZRt2LBhn39nsz2TirZv336fS/OF/v/XX3+1fv36lTgnvTTkLnG5Od498cQTlpGRYe+++6598MEHdv3115vf77dnn302/K3rX78VKywstI4dO1pqaqpddtllduedd1rNmjVt9OjRxZYtRfxza7wjd4nJzbEuGAzaKaecYjfddJOZ7Vked/bs2ebz+eyJJ57Y7ySqGRkZ5vP57IILLrBWrVpZz5497ddff43szsNVbo115A7lgSaIB5V2PtyVV15pGRkZ9ssvv5S4feg2P//8s1WpUsUmT55c7DZPP/209evXzz7++OP97qs0d955p3Xs2NFycnLCv7Nhwwbr06ePXXDBBeFVNfa1vb///e92zjnnhCeljHTfiL29J2T76wtcXl6edezY0a6++uoDbufll182n88XPhdzX535Dh062PTp00vsa1/IXcUXT+Nd6LDdwYMHhw89Dxk1apR16tRpn0cThb6lev31161SpUo2YsQIu+qqq4p9WEH8iLfxjtwlhnga60LX//DDD1atWrXwHBAhl19+uWVkZJQ42sTsf6ePvvXWW+bz+ezVV18tdjteY+NHvI115A7lyS94js/nK/az37/nz/j111/r5JNPVpMmTRQIBIrd3u/3y8z0t7/9TTfffLMefPBBnXDCCXrwwQc1cOBAXXXVVerfv7969+69331J0vz587VixQpt27YtfFnTpk21atUqVatWTT6fT4FAQE2bNtUFF1ygn376SR9//HGJ7RUVFUmSLrvsMq1du1ZLliwJXxcMBve5b5S/oqIi+Xw++Xw+5ebmhi+T9vw9k5KStGrVKtWuXbvYdSFmFv7/U089VaeddppGjx4t6X/ZDfn000+1bt06paamhrcfQu4Sk9vj3V/37fP59PPPP6tly5aSpPz8fEnSfffdp8LCQr355pvKzs4u9ntJSUmSpDPPPFM9evTQrl27dNVVV+nss89WMBgs8ZyBe+JlvNsbuUsMbo91O3fu1PLlyxUIBMLXN27cWLVq1dKyZcskSbt375YkPfjgg8rJydFrr71WLPOSlJycLGlP/rt06aJHH31UGRkZklRs23BXvIx15A6uca//grJ64YUXbOrUqeGfQ53Xvn37Wrdu3fb7u6FDyObMmWNDhw61888/30aMGGF//vln+DaldUuXLl1q7dq1s0aNGlmrVq2sbdu29sUXX5jZnsmL6tSpE64rNMna7t277cgjj7Rx48btd9tjx461zp07h09hgPv27uTn5ubatddea2eccYYdffTRdtxxx9lrr71mmzdvNjOzk08+2Y466qhSt7V161abNWuWme2Zxb5evXr2008/FbvN9u3bbdiwYSUOlyR3ic2t8c6s9Al3b7nlFktPTy+xn2nTplmzZs3su+++K7Gt0LfyCxYssObNm9uDDz5YbPI2uCtexjszcpeo3Bzr/jrXTOj00G3bttmFF15op5xySviUq9Dr7D333GONGjXa5/ZCuVu6dKn5fD6bMWMG883EiXga68gd3EQTxAO++OILmzBhguXl5ZnZnkMjjzjiCJsyZYo1bNjQWrdubWZml112mTVr1szeeustMyu5osVPP/1kt99+u23atCl82d7nBAcCgf3ORn/aaafZ6NGjbePGjbZs2TLr1auXde/ePbyk3nXXXWdpaWnhN1ihF+UxY8ZYly5d9rndvQ/l7Nevn33++eeRPTiIudtuu82qVatm/fv3t0ceecTGjx9vAwcOtJo1a9rYsWPNzGzmzJnhczX/qqioyP75z3/a6NGjrbCw0AoLC0s9n/OvL1bkLvG4Pd4daMLdYDBoH330kWVkZNi0adPMrPjKGrVq1bJnnnnGzEoeEhza35AhQ6x169b7/NAKd7k13pG7xOP2WBeyr7lmfD5fOE+zZs2yTp062YwZM4rtf9myZVavXr1SV2QL5fCss86yUaNGscpanHHzvZ0ZuYP7aIJ4wKRJk+zRRx8NDyYrVqywlJQUS0pKsvHjx4dv9/XXX1ujRo3sjDPOsO3bt5fYzt133x0erP7qQLMmr1q1yurWrVts/oSVK1faWWedZX379rXs7GxbtWqVtW7d2gYNGlTsd88991y75JJLzGzf30Ts63xEuO+nn36yjh07Wt26dcOTqu1t2LBhVr9+fXvttddsx44dduqpp1rdunXtjTfeMLM9L1i7du2yrKwsO+aYY+zll18u9vtOuvPkLvHEw3hX2oS7Xbt2tf/85z9WUFBgV1xxhbVp0yb8TWtRUZFt27bN2rdvb3ffffc+txva76ZNm8IfaBAf4mG8I3eJxe2x7kBzzXTo0MG++OILy83NtWHDhtkxxxxTLJuzZ8+2Fi1a2MaNG0vd/oFqQPlze6wjd4gXNEHi2L6ewAUFBfbUU0/Z8ccfb+np6fbee++Z2f8+yN16661Wr149O/PMM+3777+3FStW2Ndff21Dhw61Vq1a2TvvvFOmWhYsWGCHHHJI+DSE0P6ef/55O/roo23KlClmZvbhhx9aUlKSnXPOOTZr1iybMmWKNWjQoMTkk4gv+2oEfP7559apUye7/PLLi90u1OlfunSpHX/88da9e3crLCy0tWvXWteuXa1mzZrWqlUrO++886xdu3Z2yCGH2Jtvvlmmushd4oin8W5/E+4OHjzYtm3bZt9++60dddRR1rdv3/Chw4sWLbJWrVrZN998U+q2abq5L17HO3KXGOJprDMz69Kli11//fVm9r8jSLZs2WLt2rWzK664wgoLC23hwoXWq1cva9++vb3yyiu2aNEiO+WUU+z8888Pn66A+BOvY50ZuYP7aILEqb8OXGvXrrUTTzwx/KHPzOy4446zc845p1iHNLQcWrNmzaxKlSrWuXNna9y4sfXv3982bNhwwP0uXbrURowYYddcc43dd999xa475JBDbMKECWb2v05vXl6eDRs2zM4666zwShyvv/66nXfeedapUydr27Yt8y3EsQN1yu+++2479thj7aWXXtrn7W+77TZr3LixffTRR2a25/zQN99808aOHWtjx461hx56qNjt9zffDLlLXG6Nd2Z7vn3/6aefbOvWreHLZs2aZbVq1Qq/yQp9wzpz5kzr2LFj+PmwePFiO+SQQ6xly5Y2cOBAq1Gjho0cOdJ27tzJh844FC/jnRm5S1RujnVlmWvmkEMOCc/xsH79ejvhhBOsQ4cO1rBhQzvjjDMsJycn0ocA5SCexjpyh3hFEyQOlXYo2amnnmqnn356eLKs9957zw455BCbMWNGicMg165da59++qnNmTOn2BJRpW27oKDAxowZYzVr1rRRo0bZkCFDLDk52caOHRsemO69915LTU0Nf/sU2tYTTzxhzZo1sy1bthTb5l8PVePNWfzYe1k0sz1vss8//3wbO3aszZ8/P3z5ypUr7YwzzrCzzjor/GY9GAyG87Z48WLz+Xw2b968fe4jpLRzMskd3BjvzA5uwt3Qt1dmZj/++KO9+OKLdsMNN5RYhhLxIV7GOzNyl8jcGOuiMdfM008/Hf65oKDANm/ebKtXrw5fxmkH8SNexjpyBy+gCeKi0BM4NKDs/YTevHmzTZ8+3b766qvwh7+PPvrIjjrqKLvlllvCA8+gQYPshBNOCL94/vjjj/vcVzAY3O+L5L/+9S/r06dPsTdTL774otWsWdNyc3PNbE83tnXr1jZixIhig+C8efOsWrVq4cHprx86mZk5fv3nP/+x5s2bW6tWrSwzM9Pat29vdevWte+//z58m8cff9yOPfbY8AvV3n/fN99806pVq1bqxKJ/fUH+63XkLnHEy3hnFrsJd0P75s1ZfHJzvDMjd4kinsY6s+jONbN3vslc/HJ7rDMjd4h/NEHixN5P8FdffdUqV65sbdu2tUaNGtno0aPDT/grr7zSevToYW+//baZ7enmtmnTxk455RT7+9//bj6fL/xmal/bLs3jjz9uEyZMCL8AB4NB++6776xx48a2aNGi8O3eeecd8/v9Nnny5PChapdcckmJyY0Qf/Z+oxQIBOy+++4zn89nDz74YLgDv2zZMqtWrVqxbxy3bdtmF110kZ144onFlj7bvHmzDRkyxIYOHVrmmshdYnJ7vIvVhLu8MYsf8TjekbvE4/ZYZxbbuWbgvngc68zIHeKfX3DN5s2bdeihh+q5556Tz+fTZ599pn/84x9atGiRXnnlFS1btkw33HCDvv76a911112SpKuuukqBQEBvv/22Nm/erFatWumBBx5Qu3btZGb67rvvdNpppxXbj8/nO2Ato0aN0h133KHk5GQFg0H5fD5t3LhRSUlJ6tChQ/h2AwYM0OTJk/XUU0+pb9++ateund566y1ddtll0X1wEHVJSUmSpOzsbPn9fqWlpalWrVo6+uijVblyZUnS7t27Va1aNa1fv16SFAwGlZKSovPOO08FBQV64oknJEm//fabMjMz9eOPP4b/9sFgMOKayF3iiKfxbtOmTapataqaNGkiSTIztWrVSueff75ycnL0xBNPKCMjQ4888oheeeUVnXvuuXryySd1//3365NPPlHfvn33uS+/n5fUeBGP4x25SwxujnXz58/XihUrtG3btvBlTZs21apV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", "text/plain": [ "
" ] @@ -421,9 +439,98 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "44121b2e-221f-48b5-b520-051ab38a1e8c", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " points uncached cold warm speedup hits misses evictions resident expected\n", + " 2000 154.9 us 155.8 us 106.4 us 1.46x 7 16 0 32000B 32000B\n", + " 20000 1.67 ms 2.16 ms 1.18 ms 1.42x 7 16 0 320000B 320000B\n", + " 200000 20.36 ms 20.90 ms 14.21 ms 1.43x 7 16 0 3200000B 3200000B\n" + ] + } + ], + "source": [ + "def benchmark_form_factor_cache(n_points, repeats=7):\n", + " if not CTRuc.HAS_CPP_ACCEL:\n", + " return None\n", + "\n", + " xtal = load_crystal()\n", + " h, k, l = make_rod(n_points)\n", + "\n", + " def run_unitcell():\n", + " return xtal.uc_bulk.F_uc(h, k, l)\n", + "\n", + " set_backend(\"cpp\")\n", + " original_budget = CTRuc.form_factor_cache_stats()[\"budget_bytes\"]\n", + " CTRuc.clear_form_factor_cache()\n", + " CTRuc.reset_form_factor_cache_stats()\n", + " try:\n", + " CTRuc.set_form_factor_cache_budget(0)\n", + " uncached = time_call(run_unitcell, repeats=repeats)[\"median_s\"]\n", + "\n", + " CTRuc.set_form_factor_cache_budget(original_budget)\n", + " cold = cold_cache_time(run_unitcell, repeats=repeats)\n", + "\n", + " CTRuc.clear_form_factor_cache()\n", + " run_unitcell()\n", + " warm = time_call(run_unitcell, repeats=repeats, warmups=0)[\"median_s\"]\n", + " stats = CTRuc.form_factor_cache_stats()\n", + " finally:\n", + " CTRuc.clear_form_factor_cache()\n", + " CTRuc.set_form_factor_cache_budget(original_budget)\n", + "\n", + " return {\n", + " \"points\": n_points,\n", + " \"uncached_median_s\": uncached,\n", + " \"cold_median_s\": cold,\n", + " \"warm_median_s\": warm,\n", + " \"warm_speedup\": uncached / warm,\n", + " \"resident_bytes\": stats[\"resident_bytes\"],\n", + " \"expected_bytes\": CTRuc.form_factor_cache_expected_bytes(\n", + " n_points, stats[\"species_entries\"]\n", + " ),\n", + " \"hits\": stats[\"hits\"],\n", + " \"misses\": stats[\"misses\"],\n", + " \"evictions\": stats[\"evictions\"],\n", + " }\n", + "\n", + "\n", + "cache_results = [\n", + " benchmark_form_factor_cache(n_points)\n", + " for n_points in (2_000, 20_000, 200_000)\n", + "]\n", + "cache_results = [result for result in cache_results if result is not None]\n", + "\n", + "if not cache_results:\n", + " print(\"C++ extension unavailable; cache benchmark skipped.\")\n", + "else:\n", + " print(\n", + " f\"{'points':>8} {'uncached':>14} {'cold':>14} {'warm':>14} \"\n", + " f\"{'speedup':>9} {'hits':>7} {'misses':>7} {'evictions':>10} \"\n", + " f\"{'resident':>12} {'expected':>12}\"\n", + " )\n", + " for row in cache_results:\n", + " print(\n", + " f\"{row['points']:8d} \"\n", + " f\"{format_seconds(row['uncached_median_s']):>14} \"\n", + " f\"{format_seconds(row['cold_median_s']):>14} \"\n", + " f\"{format_seconds(row['warm_median_s']):>14} \"\n", + " f\"{row['warm_speedup']:8.2f}x \"\n", + " f\"{row['hits']:7d} {row['misses']:7d} {row['evictions']:10d} \"\n", + " f\"{row['resident_bytes']:11d}B {row['expected_bytes']:11d}B\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "debfda02-5292-482c-a49b-9ca9821af021", + "metadata": {}, "outputs": [], "source": [] } diff --git a/doc/source/benchmarks/ctr_zdensity_accel.ipynb b/doc/source/benchmarks/ctr_zdensity_accel.ipynb new file mode 100644 index 0000000..353369d --- /dev/null +++ b/doc/source/benchmarks/ctr_zdensity_accel.ipynb @@ -0,0 +1,272 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "density-title", + "metadata": {}, + "source": [ + "# Unit-Cell Electron-Density C++ Benchmark\n", + "\n", + "Benchmark `UnitCell.zDensity_G` for the original NumPy implementation and the C++ kernel. The C++ kernel is intentionally the only accelerated path for this calculation; special electron-density callback models retain the Python/SciPy implementation.\n", + "\n", + "Timings exclude one warmup call and measure the standard atomic-density expression only." + ] + }, + { + "cell_type": "markdown", + "id": "density-setup", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Start Jupyter from `benchmarks/` with an installed `orgui` package. The benchmark uses a synthetic unit cell with two coherent domains, so it does not depend on external files." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "density-imports", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "orGUI version 1.5.1.dev72+g06d7563e4.d20260724\n", + "{'native_optimization': False, 'target_cpu': 'generic', 'allowed_instruction_set': [], 'native_arguments': '', 'cpp_compiler_id': 'msvc', 'cpp_compiler_version': '19.43.34810', 'host_system': 'windows', 'host_cpu_family': 'x86_64', 'host_cpu': 'x86_64', 'available': True}\n", + "C++ density acceleration available: True\n" + ] + } + ], + "source": [ + "import statistics\n", + "import time\n", + "\n", + "import numpy as np\n", + "from matplotlib import pyplot as plt\n", + "\n", + "from orgui import __version__, get_build_config\n", + "from orgui.datautils.xrayutils import CTRcalc, CTRuc\n", + "\n", + "\n", + "print(\"orGUI version\", __version__)\n", + "print(get_build_config())\n", + "print(f\"C++ density acceleration available: {CTRuc.HAS_CPP_ACCEL}\")" + ] + }, + { + "cell_type": "markdown", + "id": "density-helpers-title", + "metadata": {}, + "source": [ + "## Benchmark Helpers\n", + "\n", + "The profile coordinate `z` is in Angstrom. `h` and `k` are reference-frame reciprocal coordinates in r.l.u.; the result is complex electron density in electrons per Angstrom cubed." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "density-helpers", + "metadata": {}, + "outputs": [], + "source": [ + "def make_cell():\n", + " cell = CTRcalc.UnitCell([3.0, 4.0, 5.0], [90.0, 90.0, 90.0])\n", + " cell.addAtom(\"C\", [0.2, 0.3, 0.4], 0.12, 0.18, 0.8)\n", + " cell.addAtom(\"O\", [0.7, 0.6, 0.1], 0.21, 0.24, 0.5)\n", + " cell.setEnergy(10000.0)\n", + " shifted_domain = np.vstack((np.identity(3).T, [0.1, -0.2, 0.15])).T\n", + " cell.coherentDomainMatrix = [cell.coherentDomainMatrix[0], shifted_domain]\n", + " cell.coherentDomainOccupancy = [0.65, 0.35]\n", + " return cell\n", + "\n", + "\n", + "def time_call(func, repeats=9, warmups=1):\n", + " for _ in range(warmups):\n", + " func()\n", + " timings = []\n", + " for _ in range(repeats):\n", + " start = time.perf_counter()\n", + " func()\n", + " timings.append(time.perf_counter() - start)\n", + " return statistics.median(timings)\n", + "\n", + "\n", + "def format_seconds(value):\n", + " if value < 1e-3:\n", + " return f\"{value * 1e6:.1f} us\"\n", + " return f\"{value * 1e3:.2f} ms\"" + ] + }, + { + "cell_type": "markdown", + "id": "density-correctness-title", + "metadata": {}, + "source": [ + "## Correctness Check\n", + "\n", + "Before timing, verify that the C++ kernel matches the NumPy reference for a multi-domain cell." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "density-correctness", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C++ zDensity_G matches NumPy for the multi-domain test cell.\n" + ] + } + ], + "source": [ + "if not CTRuc.HAS_CPP_ACCEL:\n", + " raise RuntimeError(\"Build or install orGUI with the C++ extension to run this benchmark.\")\n", + "\n", + "cell = make_cell()\n", + "z = np.linspace(-2.0, 7.0, 2_000)\n", + "CTRuc.set_accel_backend(\"numpy\")\n", + "reference = cell.zDensity_G(z, 0.37, -0.22)\n", + "CTRuc.set_accel_backend(\"cpp\")\n", + "accelerated = cell.zDensity_G(z, 0.37, -0.22)\n", + "np.testing.assert_allclose(accelerated, reference, rtol=1e-12, atol=1e-12)\n", + "print(\"C++ zDensity_G matches NumPy for the multi-domain test cell.\")" + ] + }, + { + "cell_type": "markdown", + "id": "density-benchmark-title", + "metadata": {}, + "source": [ + "## Benchmark Electron-Density Profiles\n", + "\n", + "Use the median runtime across repeated evaluations. The profile lengths span interactive plotting through dense optical-profile grids." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "density-benchmark", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " points numpy median cpp median cpp speedup\n", + " 200 518.0 us 228.0 us 2.27x\n", + " 2000 866.9 us 754.1 us 1.15x\n", + " 20000 5.51 ms 4.42 ms 1.25x\n", + " 200000 121.83 ms 42.75 ms 2.85x\n" + ] + } + ], + "source": [ + "cell = make_cell()\n", + "results = []\n", + "for points in (200, 2_000, 20_000, 200_000):\n", + " z = np.linspace(-2.0, 7.0, points)\n", + " timings = {}\n", + " for backend in (\"numpy\", \"cpp\"):\n", + " CTRuc.set_accel_backend(backend)\n", + " timings[backend] = time_call(lambda: cell.zDensity_G(z, 0.37, -0.22))\n", + " results.append({\n", + " \"points\": points,\n", + " \"numpy_s\": timings[\"numpy\"],\n", + " \"cpp_s\": timings[\"cpp\"],\n", + " \"speedup\": timings[\"numpy\"] / timings[\"cpp\"],\n", + " })\n", + "\n", + "print(f\"{'points':>10} {'numpy median':>16} {'cpp median':>16} {'cpp speedup':>13}\")\n", + "for row in results:\n", + " print(\n", + " f\"{row['points']:10d} {format_seconds(row['numpy_s']):>16} \"\n", + " f\"{format_seconds(row['cpp_s']):>16} {row['speedup']:12.2f}x\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "density-results-title", + "metadata": {}, + "source": [ + "## Benchmark Results\n", + "\n", + "The logarithmic scale shows how the native kernel changes runtime as the number of sampled profile points grows." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "density-plot", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAxYAAAG4CAYAAADYN3EQAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAPRxJREFUeJzt3Xl8VPW9//H3TFa2bCBLStikStk1eGWxgK1K0UpRq5RbFRStaBQVXFBvZbkUaG+L3JYBlCpYayu1KJdWXGLLKngpIBWEgkgQLKEpZMgEwYRkzu+P3szPkARm5kzynTPn9Xw88ngwZ74z38+Ez8ycT845n6/HsixLAAAAAGCD13QAAAAAAJyPwgIAAACAbRQWAAAAAGyjsAAAAABgG4UFAAAAANsoLAAAAADYRmEBAAAAwDYKCwAAAAC2JZsOIN4Fg0EdOXJErVq1ksfjMR0OAAAA0GQsy1J5eblyc3Pl9Z77mASFxXkcOXJEeXl5psMAAAAAjDl8+LA6dux4zjEUFufRqlUrSf/6ZWZkZBiOBgAAAGg6gUBAeXl5oX3ic6GwOI+a058yMjIoLAAAAOBK4VwSwMXbAAAAAGyjsAAAAABgG4UFAAAAANu4xiIGgsGgKisrTYfhWikpKUpKSjIdBgAAgKtRWNhUWVmpoqIiBYNB06G4WlZWltq3b89aIwAAAIZQWNhgWZaKi4uVlJSkvLy88y4agtizLEunTp1SSUmJJKlDhw6GIwIAAHAnCgsbqqqqdOrUKeXm5qp58+amw3GtZs2aSZJKSkrUtm1bTosCAAAwgD+x21BdXS1JSk1NNRwJagq7M2fOGI4EAADAnSgsYoDz+s3j/wAAAMAsCgsAAAAAtlFYAAAAALCNi7cbQZepbzTpfAfnXhfR+PHjx+vFF1/UnDlzNHXq1ND2lStX6oYbbpBlWbEOsZYvn7bUsmVLXXzxxXryySd14403Nuq8AAAAaDwcsXCp9PR0/fjHP5bf7zcy/9KlS1VcXKy//OUv6tevn26++WZt3rzZSCwAAACwj8LCpa666iq1b99ec+bMqff+6dOnq3///rW2zZ8/X126dAndHj9+vEaPHq3Zs2erXbt2ysrK0owZM1RVVaVHH31UOTk56tixo1544YU6z1+zoF2PHj20ePFipaena9WqVVq/fr1SUlJ09OjRWuOnTJmioUOH2n7dAAAAaBycCuVSSUlJmj17tv793/9dkyZNUseOHaN6nj//+c/q2LGj1q9fr/fee08TJkzQ5s2bNXToUP3v//6vli9frokTJ+rqq69WXl5evc+RkpKi5ORknTlzRkOHDlW3bt300ksv6dFHH5X0r/VCfv3rX2vu3LlRv14AABJFU59yHU8iPf0bTYsjFi52ww03qH///po2bVrUz5GTk6Of//znuvjii3XnnXfq4osv1qlTp/Tkk0/qq1/9qp544gmlpqbqvffeq/fxFRUVmjVrlgKBgL75zW9KkiZMmKClS5eGxrzxxhs6deqUbrnllqjjBAAAQOOisHC5H//4x3rxxRe1e/fuqB7fq1cveb3/P43atWunPn36hG4nJSWpdevWKikpqfW4sWPHqmXLlmrevLnmzZunn/70pxo5cqSkf51itX//fr3//vuSpBdeeEG33HKLWrRoEVWMAAAAaHycCuVyQ4cO1YgRI/Tkk09q/Pjxoe1er7dOd6j6VrVOSUmpddvj8dS7LRgM1tr2zDPP6KqrrlJGRobatm1b6762bdvq+uuv19KlS9WtWzetXr1aa9eujeLVAQAAoKlQWEBz585V//79ddFFF4W2XXDBBTp69Kgsywq1h92xY0fM5mzfvr26d+/e4P133XWXvve976ljx4668MILNWTIkJjNDQAAgNhzxalQN9xwg7Kzs/Xd737XdChxqU+fPvr+97+vX/ziF6Ftw4cP1z//+U/95Cc/0SeffCKfz6c333yzyWIaMWKEMjMzNWvWLN1xxx1NNi8AAACi44rCYtKkSfrVr35lOoy49p//+Z+1Tn362te+poULF8rn86lfv37asmWLHnnkkSaLx+v1avz48aqurtbtt9/eZPMCAAAgOh6rsZdZjhNr167VggUL9Pvf/z6ixwUCAWVmZqqsrEwZGRm17vviiy9UVFSkrl27Kj09PZbhQtLdd9+tf/zjH1q1atV5x/J/AQBwC9rNoimda1/4bMaPWKxfv17XX3+9cnNz5fF4tHLlyjpjFi5cGNphzM/P14YNG5o+UDSZsrIyvfvuu3r55Zf1wAMPmA4HAAAAYTB+8fbnn3+ufv366Y477tBNN91U5/7ly5froYce0sKFCzVkyBA9++yzGjlypHbv3q1OnTpJkvLz81VRUVHnse+8845yc3Mb/TUgtr7zne9oy5Ytuuee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zVeJnDUhEAgoOztbZWVl590XjmqBPElq3ry5BgwYEO3DHcfr9crrdenhNwBIcEF5TIdghFfOPiUoag7/PndrvkrkrJmpw5877MLizjvvDGvcCy+8EPbkAAAAABJD2IXFsmXL1LlzZ11yySWK8uwpAAAAAAkq7MJi4sSJeuWVV3TgwAHdeeeduvXWW5WTk9OYsQEAAABwiLBPmlq4cKGKi4v1+OOP6w9/+IPy8vJ0yy236O233+YIBgAAAOByEV0JkpaWprFjx6qwsFC7d+9Wr169dN9996lz5846efJkY8UIAAAAIM5FfYm5x+ORx+ORZVksHAcAAAC4XESFRUVFhX7729/q6quv1sUXX6ydO3dqwYIFOnToUMKuYwEAAADg/MK+ePu+++7TK6+8ok6dOumOO+7QK6+8otatWzdmbAAAAAAcIuzCYvHixerUqZO6du2qdevWad26dfWOe+2112IWHAAAAABnCLuwuP322+XxuHelRwAAAAANi2iBPAAAAACoT9RdoQAAAACgBoUFAAAAANsoLAAAAADYRmEBAAAAwDYKCwAAAAC2UVgAAAAAsI3CAgAAAIBtFBYAAAAAbKOwAAAAAGAbhQUAAAAA2ygsAAAAANhGYQEAAADANgoLAAAAALZRWAAAAACwjcICAAAAgG0UFgAAAABso7AAAAAAYBuFBQAAAADbEr6wKC8v12WXXab+/furT58+WrJkiemQAAAAgISTbDqAxta8eXOtW7dOzZs316lTp9S7d2/deOONat26tenQAAAAgISR8EcskpKS1Lx5c0nSF198oerqalmWZTgqAAAAILEYLyzWr1+v66+/Xrm5ufJ4PFq5cmWdMQsXLlTXrl2Vnp6u/Px8bdiwIaI5Tpw4oX79+qljx4567LHH1KZNmxhFDwAAAECKg8Li888/V79+/bRgwYJ671++fLkeeughPfXUU/rggw/09a9/XSNHjtShQ4dCY/Lz89W7d+86P0eOHJEkZWVl6a9//auKior0m9/8Rv/4xz+a5LUBAAAAbmH8GouRI0dq5MiRDd4/b948TZgwQXfddZckaf78+Xr77be1aNEizZkzR5K0bdu2sOZq166d+vbtq/Xr1+vmm2+ud0xFRYUqKipCtwOBgCQpGAwqGAyGNQ8AwFm8cucpskHzf180w+Hf527NV4mcNTN1+HMbLyzOpbKy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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "labels = [f\"{row['points']:,}\" for row in results]\n", + "x = np.arange(len(results))\n", + "width = 0.36\n", + "fig, ax = plt.subplots(figsize=(8, 4.5))\n", + "ax.bar(x - width / 2, [row['numpy_s'] for row in results], width, label=\"NumPy\")\n", + "ax.bar(x + width / 2, [row['cpp_s'] for row in results], width, label=\"C++\")\n", + "ax.set_yscale(\"log\")\n", + "ax.set_xticks(x, labels)\n", + "ax.set_xlabel(\"z profile points\")\n", + "ax.set_ylabel(\"Median runtime [s]\")\n", + "ax.grid(axis=\"y\", which=\"both\", alpha=0.25)\n", + "ax.legend()\n", + "fig.tight_layout()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e74da763-db71-4a36-8e1b-cd06fcc6473c", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": {}, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/source/ctr_resolution.rst b/doc/source/ctr_resolution.rst new file mode 100644 index 0000000..d9b5f8a --- /dev/null +++ b/doc/source/ctr_resolution.rst @@ -0,0 +1,128 @@ +CTR Resolution Modeling +======================= + +``CTRresolution`` supplies optional instrumental-resolution broadening for +calculated crystal truncation rods. It does not change ``SXRDCrystal.F`` or +enable broadening implicitly in fitting code. + +Intensity and output convention +------------------------------- + +Resolution acts on calculated intensity rather than on the complex amplitude: + +.. math:: + + I_{\mathrm{res}}(L_i) = + \frac{\int R_i(L-L_i)\lvert F(H_i,K_i,L)\rvert^2\,\mathrm{d}L} + {\int R_i(L-L_i)\,\mathrm{d}L}. + +The returned ``CTRCollection`` follows the existing ``sfI`` convention and +stores the effective amplitude + +.. math:: + + F_{\mathrm{eff}}(L_i) = \sqrt{I_{\mathrm{res}}(L_i)}. + +Phase and experimental-error information cannot be retained by this operation +and is therefore absent from the result. Input collections are never modified. + +Resolution functions +-------------------- + +The built-in functions are ``BoxResolution`` and ``GaussianResolution``. Their +effective width is in reciprocal lattice units: + +.. math:: + + \Delta L = \Delta L_0 + \Delta L_1\lvert\sin(\gamma)\rvert. + +Here ``gamma`` is the Vlieg out-of-plane detector angle in radians. For a box, +``DeltaL`` is the complete support width. For a Gaussian it is the full width +at half maximum (FWHM). Setting ``delta_l_1=0`` creates a constant function; +setting both contributions to zero disables broadening. + +Gamma-dependent models require angle records on their input collection. They +can be calculated for all rods at once: + +.. code-block:: python + + ub = HKLVlieg.UBCalculator(crystal.reference_uc, energy=70.0) + ub.defaultU_GID() + angle_calculator = HKLVlieg.VliegAngles(ub) + ctrs.calcAnglesZmode( + angle_calculator, + fixedangle=np.deg2rad(0.1), + fixed="in", + ) + +Fast convolution on existing points +----------------------------------- + +``fast_convolve`` uses only the L coordinates and amplitudes already present +in each rod. It does not interpolate and does not require an equidistant grid. +Local composite-trapezoidal weights account for unequal point spacing, so a +dense part of a scan is not overrepresented merely because it has more points. + +At the first and last points the kernel is truncated to the available data and +renormalized. This preserves constant intensity without assuming either zero +intensity or an extrapolation outside the measured range. L coordinates must +be finite and unique within a rod, but they may be in any order. + +.. code-block:: python + + from orgui.datautils.xrayutils import CTRresolution + + model = CTRresolution.GaussianResolution( + delta_l_0=0.015, + delta_l_1=0.08, + ) + broadened = CTRresolution.fast_convolve(calculated_ctrs, model) + +The fast method approximates the convolution represented by the existing +sampling. If the kernel is narrower than the local point spacing, it may only +contain the central point and consequently cannot reproduce unresolved detail. + +Sampled structure factors +------------------------- + +``sample_structure_factor`` reevaluates ``crystal.F`` around every requested +point and is slower but does not depend on the density of the input L samples. +It uses Gauss-Legendre quadrature for a box and Gauss-Hermite quadrature for an +unbounded Gaussian. The quadrature order must be a positive odd integer and +defaults to 25. + +.. code-block:: python + + sampled = CTRresolution.sample_structure_factor( + data_positions, + crystal, + model, + quadrature_order=25, + ) + +The central point's gamma determines the width of all quadrature samples for +that point. H and K remain fixed. This first implementation therefore models +only out-of-plane L resolution; a future in-plane model can use the H, K, L, +and complete angle context accepted by ``ResolutionFunction.width``. + +A complete runnable comparison using the bundled CTR reference data is in +``examples/CTR/ctr_resolution_example.ipynb``. + +API reference +------------- + +.. autoclass:: orgui.datautils.xrayutils.CTRresolution.ResolutionFunction + :members: width, weights, quadrature + :member-order: bysource + +.. autoclass:: orgui.datautils.xrayutils.CTRresolution.BoxResolution + :members: + :member-order: bysource + +.. autoclass:: orgui.datautils.xrayutils.CTRresolution.GaussianResolution + :members: + :member-order: bysource + +.. autofunction:: orgui.datautils.xrayutils.CTRresolution.fast_convolve + +.. autofunction:: orgui.datautils.xrayutils.CTRresolution.sample_structure_factor diff --git a/doc/source/ctr_structure_factors.rst b/doc/source/ctr_structure_factors.rst index cd6e1fe..b38e90a 100644 --- a/doc/source/ctr_structure_factors.rst +++ b/doc/source/ctr_structure_factors.rst @@ -30,8 +30,176 @@ area or volume normalization is applied. and return electrons for one lateral unit cell of their source ``UnitCell``. -An ``EpitaxyInterface`` can join materials with different lateral areas. Its -canonical lateral cell is the lower unit cell. Internally it combines the +Semi-infinite bulk amplitude +----------------------------- + +``UnitCell.F_bulk`` sums ``UnitCell.F_uc_bulk_direct`` over an infinite stack +of bulk repeats along the out-of-plane direction using a closed-form +geometric series with attenuation: + +.. math:: + + F_{\mathrm{bulk}}(\mathbf{Q}) + = \frac{F_{\mathrm{uc,bulk}}(\mathbf{Q})} + {1 - \exp\left(-2\pi i\, l_{\mathrm{bulk}} - \mathrm{atten}\right)}, + +where :math:`l_{\mathrm{bulk}}` is the out-of-plane reciprocal index *after* +conversion from the reference unit cell via ``refHKLTransform``, i.e. the +third component of ``refHKLTransform @ (h, k, l)``. This is the same index +used to phase every atom in ``F_uc_bulk_direct``, so the phase advance per +bulk repeat in the denominator is consistent with the periodicity actually +being summed. + +.. warning:: + + Versions up to and including v1.5.0 used the raw, untransformed ``l`` in + this denominator instead of the reference-transformed index. This was only + correct when ``refHKLTransform``'s third row equals ``(0, 0, 1)``, i.e. + only when the bulk cell's own out-of-plane reciprocal axis exactly + coincides with the reference cell's, in both direction and length. Since + ``refHKLTransform = B_mat_inv @ rotMatrix @ uc.B_mat`` (see + ``UnitCell.setReferenceUnitCell``), this held only for the default case of + a component using its own bulk cell as the reference (no ``reference_uc`` + set). Any explicit ``reference_uc`` whose out-of-plane reciprocal axis + differs from the bulk's was affected — including a plain scale difference + between the reference and bulk out-of-plane axis length, not only a + rotated or reindexed reference. + +Surface structure on a rough Film +--------------------------------- + +A ``PoissonSurface`` is stacked immediately above a ``Film`` and replaces the +Film structure only on the fraction that is truly exposed. Let +:math:`\theta_i` be the cumulative material occupancy of structural layer +:math:`i`, ordered from bottom to top. Its exposed surface occupancy is + +.. math:: + + s_i = \theta_i - \theta_{i+1}, + +where the occupancy above the highest represented layer is set to zero. The +part of layer :math:`i` that is covered by another layer remains Film material +with occupancy + +.. math:: + + c_i = \theta_i - s_i. + +The sharp Film already supplies occupancy :math:`\chi_{i<0}` below its nominal +boundary. Before applying a reconstructed surface structure, +``PoissonSurface`` adds the rough-Film correction + +.. math:: + + \Delta F_{\mathrm{rough}} + = \sum_i \left(\theta_i-\chi_{i<0}\right)F_{\mathrm{Film},i}. + +The exposed fraction is subsequently replaced by a termination-specific +surface slab as described below. Film and surface structures can consequently +occur at the same terrace height on complementary lateral fractions. + +.. figure:: _static/poisson_surface_occupancies.svg + :alt: Stacked bars of covered Film and exposed surface occupancy versus structural-layer offset. + :align: center + :width: 92% + + Occupancy decomposition for ``PoissonProfile(mean_change=2, alpha=0.5)``. + Each bar has total height :math:`\theta_i`; its orange segment is the true + surface fraction :math:`s_i`, and its blue segment is covered Film + :math:`c_i`. + +Termination-specific relaxed surface slabs +------------------------------------------- + +A reconstructed surface generally does not repeat the bulk layer cycle. The +``layer`` column therefore has a narrower meaning for a surface slab: it +selects the Film termination to which the *complete slab* belongs. Internal +planes of that slab are distinguished by their z coordinates, not by cycling +layer identifiers. ``UnitCell.as_surface_termination`` makes this explicit by +assigning one termination identifier to every atom and setting +``layer_behavior="select"``. Selection masks an inactive cell during the +calculation; it does not overwrite atomic occupancy fit parameters. + +A ``PoissonSurface`` owns exactly one complete unit cell for every member of +the underlying Film's primitive stacking cycle. For a two-layer RuO2 Film, +this means two termination cells regardless of how many bulk cells deep each +surface slab is. The surface-normal length of every termination cell may be +an integer multiple of the Film c axis, while its lateral lattice constants and +lattice angles must match the Film. + +Surface-supercell generation example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``CTRutil.generate_surface_termination_cells`` automates the affine cycling, +top-plane selection, naming, and whole-slab relabeling. For example, a +two-cell-deep RuO2 slab and both of its surface terminations can be built as + +.. code-block:: python + + from orgui.datautils.xrayutils.CTRdistributions import PoissonProfile + from orgui.datautils.xrayutils.CTRfilm import PoissonSurface + from orgui.datautils.xrayutils.CTRutil import ( + generate_surface_termination_cells, + ) + + slab = ruo2_surface.supercell((1, 1, 2), symmetry="independent") + terminations = generate_surface_termination_cells( + slab, + ruo2_film, + name_template="RuO2_termination_{layer:g}", + ) + + surface = PoissonSurface( + terminations, + profile=PoissonProfile(mean_change=-0.55, alpha=0.5), + ) + +Here ``ruo2_film`` may be the underlying ``Film``, its ``UnitCell``, its +``LayerCycle``, or simply the ordered tuple ``(1, 2)``. The number of internal +layers in ``slab`` must be an integer multiple of this primitive Film-cycle +length. ``symmetry="independent"`` gives repeated copies independent Wyckoff +sites; use ``symmetry="preserve"`` when all repeats should retain shared +symmetry parameters. + +The helper chooses which original plane is at the top of each generated slab +and then assigns the complete slab to that Film-cycle state. Atoms at the +selected top plane can consequently be relaxed independently of their former +internal layer numbers. The returned termination cells are separate objects +and can carry independent coordinate, displacement-factor, occupancy, and +Wyckoff fit parameters. The same mapping contract can be consumed by other +surface-roughness models; it is not specific to ``PoissonSurface``. + +At exposed terrace :math:`i`, let :math:`t_i` be the corresponding Film-cycle +termination. CTRcalc generates a bulk-reference slab with the same depth and +alignment as the selected surface slab and adds + +.. math:: + + \Delta F_{\mathrm{termination}} + = \sum_i s_i + \left( + F_{\mathrm{surface\ slab},t_i} + - F_{\mathrm{Film\ slab},t_i} + \right). + +Thus a multi-layer relaxed slab replaces, rather than duplicates, the same +depth of Film material. If every termination slab is identical to its Film +reference, this term cancels numerically in ``F_uc``, ``zDensity_G``, and the +optical profile. A zero-width distribution selects and replaces only the +single sharp termination. + +The immediately underlying ``Film`` and its current stacking phase are inferred +when ``SXRDCrystal`` applies stacking; no Film reference is serialized. +Termination banks are stored in ``.xtal`` and ``.xpr`` files as +``TerminationUnitCell `` sections. A legacy single ``UnitCell`` +surface remains readable and is expanded automatically into one termination +variant per Film-cycle state. + +Epitaxy-interface strain coupling and offset +--------------------------------------------- + +An ``EpitaxyInterface`` can join materials with different lateral areas. Its +canonical lateral cell is the lower unit cell. Internally it combines the upper and lower amplitudes as .. math:: @@ -45,6 +213,213 @@ upper and lower amplitudes as The result is therefore in electrons per lower interface cell. +The lower material is a correction to the semi-infinite bulk, whose atoms +remain at their unstrained positions below the nominal interface boundary. +For every represented lower-material layer, the interface therefore adds its +distributed occupancy at the strain-field position and subtracts the +corresponding sharp-bulk occupancy at the original bulk-lattice position. +This remains true when the statistical support extends below zero. The +subtraction is deliberately *not* strained: it removes the density already +present in the sharp, semi-infinite bulk before the distributed replacement +is added. + +Coordinate model +^^^^^^^^^^^^^^^^ + +The dimensionless ``strain_coupling`` parameter, written :math:`\kappa`, +controls the out-of-plane lattice transition, with +:math:`0\leq\kappa\leq1`. It does not control whether film and bulk +amplitudes scatter coherently; those amplitudes are always summed as complex +structure factors. Let :math:`z_{0,m}` be an atom's position on the +independent lattice of material :math:`m`, and let :math:`z_1` be its position +in the fully strain-coupled, zero-offset field. Strain coupling alone gives +the linear interpolation + +.. math:: + + z_m(\kappa,0) + = z_{0,m} + \kappa\left(z_1-z_{0,m}\right). + +The ``offset`` parameter :math:`o` is expressed in fractional lower-bulk +:math:`c` coordinates. Its physical displacement is + +.. math:: + + \Delta = o\,c_{\mathrm{bulk}}. + +Positive :math:`o` points toward increasing :math:`z`. Let :math:`P_i` be +the upper-material occupancy at represented interface layer :math:`i`. The +profile used for the offset field is normalized over the complete represented +statistical support, + +.. math:: + + \widehat P_i + = \frac{P_i-P_{\mathrm{deep\ bulk}}} + {P_{\mathrm{film}}-P_{\mathrm{deep\ bulk}}}, + \qquad + \widehat P_{\mathrm{deep\ bulk}}=0,\quad + \widehat P_{\mathrm{film}}=1. + +Using this occupancy coordinate, the generated upper- and lower-material +positions are + +.. math:: + + z_{\mathrm{top}}(\kappa,o) + &= z_{0,\mathrm{top}} + + \kappa(z_1-z_{0,\mathrm{top}}) + + \Delta\left[(1-\kappa)+\kappa\widehat{P}(z)\right], \\ + z_{\mathrm{bottom}}(\kappa,o) + &= z_{0,\mathrm{bottom}} + + \kappa(z_1-z_{0,\mathrm{bottom}}) + + \Delta\kappa\widehat{P}(z). + +The offset terms have a useful decomposition: + +.. math:: + + u_{\mathrm{top}}(z) + &= \Delta(1-\kappa) + \kappa\Delta\widehat P(z),\\ + u_{\mathrm{bottom}}(z) + &= \kappa\Delta\widehat P(z). + +The first term is a rigid registry offset between two independent lattices. +The second is a shared displacement field that expands or contracts the +interface as a whole. Their difference is independent of :math:`z`, + +.. math:: + + u_{\mathrm{top}}-u_{\mathrm{bottom}}=(1-\kappa)\Delta, + +while the shared displacement accumulated from deep bulk to film is + +.. math:: + + u_\kappa(\mathrm{film}) + -u_\kappa(\mathrm{deep\ bulk}) + =\kappa\Delta. + +Equivalently, wherever the occupancy profile is differentiable, the additional +local strain due to the offset is + +.. math:: + + \epsilon_o(z) + = \frac{\mathrm{d}u_C}{\mathrm{d}z} + = \kappa\Delta\frac{\mathrm{d}\widehat P}{\mathrm{d}z}. + +This separates three named regimes: + +.. list-table:: + :header-rows: 1 + :widths: 18 25 32 25 22 + + * - ``strain_coupling`` + - Regime + - Atomic positions + - Residual registry offset + - Shared expansion + * - ``0`` + - Independent-lattice limit + - Both materials retain their independent ideal lattice spacings. + - :math:`\Delta` + - :math:`0` + * - ``0.5`` + - Partially strain-coupled interface + - Half of the strain and offset field is applied. + - :math:`\Delta/2` + - :math:`\Delta/2` + * - ``1`` + - Fully strain-coupled interface + - Both distributed materials follow the same strain field. + - :math:`0` + - :math:`\Delta` + +Thus :math:`\kappa=0` is the **independent-lattice limit**, +:math:`0<\kappa<1` is a **partially strain-coupled interface**, and +:math:`\kappa=1` is the **fully strain-coupled interface**. In the +independent-lattice limit the lower material remains on its bulk lattice and +the upper material remains on its own lattice, translated rigidly by +:math:`\Delta`; there is no strain field. In the fully strain-coupled limit, +coincident lattices receive identical occupancy-mediated displacements and +therefore remain coincident. Intermediate strain coupling continuously +partitions :math:`\Delta` between rigid registry and shared interface strain. + +The strain-coupled field is anchored to the unstrained deep bulk at the lower +edge of the represented statistical support. Its accumulated displacement is +propagated upward rather than removed by re-anchoring at the nominal boundary. +The complete Film and all subsequently stacked surface, water, and other +components receive the full :math:`\Delta` translation without a thickness +change. The fixed sharp-bulk subtraction, semi-infinite bulk, and nominal +statistical boundary remain unchanged. + +The conventional epitaxial *relaxation degree* uses the opposite endpoint +direction: :math:`R=0` denotes a pseudomorphic, fully strained layer and +:math:`R=1` a layer at its relaxed lattice constant. Consequently, +``strain_coupling`` is qualitatively analogous to :math:`1-R`, but it is not +the same model: :math:`\kappa` controls an occupancy-mediated, depth-dependent +out-of-plane coordinate field shared by both interface materials, rather than +a uniform in-plane film lattice parameter. See `Zhylik et al., Journal of +Applied Crystallography 46, 919--925 (2013) +`_ for the conventional relaxation +definition and its diffraction treatment. + +Newly serialized interfaces use the explicit four-column header +``Width/cells Skew/cells StrainCoupling Offset/bulk_frac``. ``.xtal`` and +``.xpr`` models written by orGUI v1.5.0 used the two-column header +``Width/cells Skew/cells``; these records remain readable and are interpreted +as :math:`\kappa=1` and :math:`o=0`, preserving the fully strain-coupled, +zero-offset model. Saving the reconstructed model writes the current +four-column form. + +The optional companion ``.h5`` file contains fit definitions rather than a +separate crystal model. A v1.5.0 two-element interface baseline stored there +is likewise expanded to ``[Width, Skew, 1, 0]`` when loaded. Existing Width +and Skew fit parameters retain their original indices; ``strain_coupling`` and +``offset`` become fit parameters only when explicitly selected. + +RuO2 on TiO2 example +^^^^^^^^^^^^^^^^^^^^ + +The following density profiles use a 15 nm RuO2 film on TiO2 with a 1 nm +Skellam interface width. The columns use offsets :math:`o=0`, :math:`0.02`, +and :math:`0.17`. For the TiO2 lower-bulk lattice +:math:`c_{\mathrm{bulk}}=6.5807` Angstrom, these correspond to +:math:`\Delta=0`, :math:`0.13`, and :math:`1.12` Angstrom. Each column +vertically separates the :math:`\kappa=0`, :math:`\kappa=0.5`, and +:math:`\kappa=1` densities. +Gray is the summed density, blue is the TiO2 contribution including the +semi-infinite bulk and lower-interface correction, and orange is the RuO2 +interface-plus-film contribution. + +.. figure:: _static/epitaxy_strain_coupling_offset_density.png + :alt: RuO2 on TiO2 interface densities and shared displacement profiles for three offsets and three strain-coupling values. + :align: center + :width: 100% + + Strain coupling partitions the physical offset into rigid registry and + shared interface expansion. Arrows above the density panels indicate the + full bulk-to-film displacement :math:`\Delta`. The lower panels show the + shared displacement :math:`u_\kappa(z)` obtained from the generated + lower-material domain transforms; its film-side plateau is + :math:`\kappa\Delta`. + +The :math:`\kappa=0` density peaks show two independently periodic lattices: +the TiO2 peaks retain the lower-bulk spacing, while the RuO2 peaks retain the +RuO2 film spacing and are shifted rigidly by :math:`\Delta`. No density +feature is bent into a strain field. At :math:`\kappa=1` the offset is +accumulated through the occupancy profile, so both distributed materials +follow the same displacement and the lower-panel curve rises from zero to +:math:`\Delta`. The :math:`\kappa=0.5` row is the exact intermediate case: +half of the offset remains as registry separation and half appears as a +measurable expansion of the interface. + +The lower curves are plotted at the represented lower-material layer +positions rather than inferred from peak maxima. They therefore provide a +direct diagnostic of the coordinate transforms used by ``F_uc``, +``zDensity_G``, and the optical profile. + Crystal composition ------------------- @@ -79,12 +454,20 @@ included. Calculated intensity is proportional to API reference ------------- +.. autoclass:: orgui.datautils.xrayutils.CTRdistributions.SurfaceProfile + :members: support, occupancy, correction, surface_occupancy + :member-order: bysource + +.. autoclass:: orgui.datautils.xrayutils.CTRdistributions.PoissonProfile + :members: + :member-order: bysource + .. autoclass:: orgui.datautils.xrayutils.CTRcalc.SXRDCrystal :members: F, F_surf, setGlobalReferenceUnitCell :member-order: bysource .. autoclass:: orgui.datautils.xrayutils.CTRuc.UnitCell - :members: F_uc, F_bulk, setReferenceUnitCell + :members: F_uc, F_bulk, setReferenceUnitCell, supercell, affine_layer_transform, as_surface_termination :member-order: bysource .. autoclass:: orgui.datautils.xrayutils.CTRfilm.Film @@ -98,3 +481,5 @@ API reference .. autoclass:: orgui.datautils.xrayutils.CTRfilm.PoissonSurface :members: F_uc, uc_area, setReferenceUnitCell :member-order: bysource + +.. autofunction:: orgui.datautils.xrayutils.CTRutil.generate_surface_termination_cells diff --git a/doc/source/index.rst b/doc/source/index.rst index c297512..76082ac 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -22,6 +22,7 @@ large 2D detectors. acceleration_backends entry_points benchmarks/ctr_accel_backends + benchmarks/ctr_zdensity_accel benchmarks/roi_sum_accel api release_notes diff --git a/doc/source/release_notes.rst b/doc/source/release_notes.rst index dec89b3..c7eb1c2 100644 --- a/doc/source/release_notes.rst +++ b/doc/source/release_notes.rst @@ -4,6 +4,20 @@ Release Notes .. This file is generated by doc/generate_release_notes.py from CHANGELOG.md. Do not edit it by hand. +Unreleased (2026-07-19) +----------------------- + +Scientific and analysis additions: + +- Added optional CTR intensity-resolution modeling with constant or gamma-dependent box and Gaussian functions. Calculations can convolve irregular existing L points or sample the crystal structure factor with deterministic quadrature. +- Added py3Dmol atom-sphere rendering for Jupyter notebooks. ``plot3d`` now selects py3Dmol automatically in a notebook, can be directed to either py3Dmol or Mayavi explicitly, and can incrementally add unit cells to a shared viewer. Covalent radii are interpreted consistently by both backends and can be adjusted with the dimensionless ``radius_scale`` parameter. +- ``PoissonSurface``'s basis now requires three parameters (``W``, ``alpha``, ``offset``) instead of two. The previous two-parameter form was only ever used in test code, so no migration path is provided; a saved ``.xtal``/ ``.xpr`` file with a two-parameter ``PoissonSurface`` basis will fail to load. +- Wyckoff-parameterized fit values are now interpreted as absolute atomic positions instead of deltas relative to the Wyckoff position. Wyckoff parameter fitting was development-only and never part of a release, so no migration is provided. + +A ***critical bug*** was fixed that affects bulk CTR calculations: + +- ``UnitCell.F_bulk``'s semi-infinite geometric lattice sum used the raw, untransformed ``l`` index instead of the index converted by ``refHKLTransform`` when computing the out-of-plane attenuation phase. This was only correct for the default case where a component uses its own bulk cell as the reference (no ``reference_uc`` set); any explicit ``reference_uc`` whose out-of-plane reciprocal axis differs from the bulk's — including a plain scale difference between the reference and bulk out-of-plane axis length, not only a rotated or reindexed reference — gave incorrect bulk structure-factor amplitudes. This bug was present in both the accelerated (numba/C++) and plain-Python code paths in all previous released versions, up to and including v1.5.0. See the CTR structure-factor documentation for details. + 1.5.0 (2026-06-07) ------------------ diff --git a/doc/source/wyckoff_fitting.rst b/doc/source/wyckoff_fitting.rst index 5abf3bb..de628b6 100644 --- a/doc/source/wyckoff_fitting.rst +++ b/doc/source/wyckoff_fitting.rst @@ -59,12 +59,34 @@ Querying Wyckoff Metadata .. code-block:: python unitcell.wyckoff_sites() + unitcell.wyckoff("O_4f") unitcell.wyckoff_couplings("O_4f") unitcell.wyckoff_site_couplings("O_4f") unitcell.atom_wyckoff_metadata(0) ``wyckoff_sites()`` returns site dictionaries with the site id, element, -Wyckoff label, free variables, generated atom indices, space group, and status. +Wyckoff label, free variables, generated atom indices, site occupancy and +Debye-Waller parameters, space group, and status. ``wyckoff(site_id)`` returns +the same dictionary for one site. + +Direct convenience setters use symmetric signatures for atom and site data: + +.. code-block:: python + + # Surface-cell atom coordinates or per-atom physical parameters. + unitcell.set_wyckoff_atom_parameter("O_4f", "occ", 0.5) + unitcell.set_wyckoff_atom_parameter("O_4f", "z", oxygen_z_values) + + # Representative coordinate in the parent conventional cell. + unitcell.set_wyckoff_site_parameter("O_4f", "x", 0.31) + unitcell.set_wyckoff_site_parameter("O_4f", "iDW", 0.6) + +The atom setter accepts ``x``, ``y``, ``z``, ``iDW``, ``oDW``, and ``occ``. +Coordinate values are in surface-cell fractional units and may be supplied per +atom; ``iDW``, ``oDW``, and ``occ`` are scalar site-wide values. The site setter +accepts ``x``, ``y``, and ``z`` in parent conventional fractional units and +propagates the displacement through the stored symmetry couplings. It also +accepts scalar site-wide ``iDW``, ``oDW``, and ``occ`` values. The status is one of: ``metadata_only`` @@ -94,15 +116,17 @@ atom coordinates are updated through the stored affine Wyckoff couplings: unitcell.addWyckoffParameter( "O_4f", "u", - absolute_limits=(0.28, 0.34), + limits=(0.28, 0.34), ) -The fitted value is a delta from the stored variable value. For rutile oxygen, -coordinates such as ``u``, ``1-u``, ``0.5+u``, and ``0.5-u`` therefore move -with the correct signs. +The fitted value and limits are absolute Wyckoff-variable coordinates. The +implementation converts the absolute value to an internal delta from the +site's reference value and applies that delta through the affine couplings. +For rutile oxygen, coordinates such as ``u``, ``1-u``, ``0.5+u``, and +``0.5-u`` therefore move with the correct signs. -``addWyckoffShift`` fits a parent conventional fractional displacement of the -representative site coordinate: +``addWyckoffShift`` fits a relative parent conventional fractional displacement +from the representative symmetry-site coordinate: .. code-block:: python @@ -113,10 +137,11 @@ representative site coordinate: ) Here ``"x"``, ``"y"``, and ``"z"`` are parent conventional fractional axes, -not generated surface-cell axes. The shift is propagated through the stored +not generated surface-cell axes, and the parameter is a relative shift rather +than an absolute coordinate. The shift is propagated through the stored space-group operation for each generated atom and then through the surface-cell -transform. This intentionally keeps the original atom assignment and atom count -while allowing the site to move away from its exact Wyckoff constraint. +transform. This intentionally keeps the original atom assignment and atom +count while allowing the site to move away from its exact Wyckoff constraint. Plural helpers are available for fitting several variables or shifts: @@ -133,7 +158,7 @@ template unit cell: .. code-block:: python - film.addWyckoffParameter("O_4f", "u", limits=(-0.05, 0.05)) + film.addWyckoffParameter("O_4f", "u", limits=(0.25, 0.35)) poisson.addWyckoffShift("O_4f", "x", limits=(-0.02, 0.02)) ``EpitaxyInterface`` follows the same selector convention as its existing @@ -144,7 +169,7 @@ template unit cell: interface.addWyckoffParameter( "O_4f", "u", - limits=(-0.05, 0.05), + limits=(0.25, 0.35), unitcell="top", ) @@ -163,8 +188,11 @@ Linking Across Unit Cells ------------------------- ``SXRDCrystal`` provides the same two Wyckoff helper modes at the crystal -level. These helpers create one coupled crystal parameter and ordinary -relative fit parameters inside each selected unit cell. +level. ``addWyckoffParameter`` distributes one absolute value to the selected +unit cells, while ``addWyckoffShift`` distributes one relative displacement. +If selected sites begin with different values, the coupled starting value is +their mean; setting the coupled parameter assigns the same absolute Wyckoff +variable to every selected site. For a symmetry-preserving Wyckoff variable: @@ -176,7 +204,7 @@ For a symmetry-preserving Wyckoff variable: "TiO2": ("O_4f", "u"), }, name="shared_rutile_oxygen_u", - limits=(-0.05, 0.05), + limits=(0.25, 0.35), ) For a representative-site shift: @@ -206,7 +234,7 @@ value dictionary, or use a ``(component, unitcell)`` key: } }, name="shared_interface_u", - limits=(-0.05, 0.05), + limits=(0.25, 0.35), ) crystal.addWyckoffShift( @@ -225,8 +253,12 @@ after the layer metadata. The persisted information includes: * surface transform and origin; * Wyckoff site ids, labels, variables, and representative parent coordinates; * generated atom metadata; -* Wyckoff variable couplings; -* representative-site shift couplings. +* deduplicated matrices for Wyckoff-variable and representative-site + couplings, referenced by the generated atoms. + +The matrix representation avoids repeating one text row for every nonzero +atom/coordinate coupling. The reader also accepts the older expanded +``wyckoff_couplings`` and ``wyckoff_site_couplings`` tables. Reloaded files can be queried and fitted with ``addWyckoffParameter`` and ``addWyckoffShift`` without PyXtal because the resolved couplings are already @@ -242,7 +274,8 @@ API Reference :no-index: .. autoclass:: orgui.datautils.xrayutils.CTRuc.UnitCell - :members: wyckoff_sites, wyckoff_couplings, wyckoff_site_couplings, + :members: wyckoff_sites, wyckoff, set_wyckoff_atom_parameter, + set_wyckoff_site_parameter, wyckoff_couplings, wyckoff_site_couplings, atom_wyckoff_metadata, addWyckoffParameter, addWyckoffParameters, addWyckoffShift, addWyckoffShifts :member-order: bysource diff --git a/examples/CTR/RuO2_TiO2_Poisson_etching.xpr b/examples/CTR/RuO2_TiO2_Poisson_etching.xpr index 6cd3373..3d249d3 100644 --- a/examples/CTR/RuO2_TiO2_Poisson_etching.xpr +++ b/examples/CTR/RuO2_TiO2_Poisson_etching.xpr @@ -1,124 +1,170 @@ -E = 20.00000 keV -# PoissonSurface RuO2surface -0003 occupancy = 1.00000 - -W/layers offset/layers --6.00000 0.00000 -support_cursor: nominal - -UnitCell RuO2 -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -# Film RuO2 -0002 occupancy = 1.00000 - -Width/layers -(17.00000 +- nan) - -UnitCell RuO2 -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -# EpitaxyInterface TiO2toRuO2 -0001 occupancy = 1.00000 -type skellam -Width/cells Skew/cells -0.35000 0.00000 -support_cursor: nominal - -TopUnitCell RuO2interf -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) -06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -BottomUnitCell TiO2interf -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O (0.50000 +- nan) (0.00000 +- nan) (0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -01 O (0.00000 +- nan) (0.00000 +- nan) (0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -02 Ti (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -03 Ti (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -04 O (0.30458 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -05 O (0.69542 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -06 O (0.00000 +- nan) (0.00000 +- nan) (0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -07 O (0.50000 +- nan) (0.00000 +- nan) (0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -08 Ti (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -09 Ti (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -10 O (0.19542 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -11 O (0.80458 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -# UnitCell bulk -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O (0.50000 +- nan) (0.00000 +- nan) (-0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -01 O (0.00000 +- nan) (0.00000 +- nan) (-0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -02 Ti (0.50000 +- nan) (0.00000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -03 Ti (0.00000 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -04 O (0.30458 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -05 O (0.69542 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) -06 O (0.00000 +- nan) (0.00000 +- nan) (-0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -07 O (0.50000 +- nan) (0.00000 +- nan) (-0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -08 Ti (0.00000 +- nan) (0.00000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -09 Ti (0.50000 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -10 O (0.19542 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -11 O (0.80458 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) -layerpos: 1.0 = -1.0, 2.0 = -0.5 -layer_behaviour: ignore +E = 20.00000 keV +# PoissonSurface RuO2surface +0003 occupancy = 1.00000 + +W/layers alpha offset/layers +-6.00000 1.00000 0.00000 + +TerminationUnitCell 1 RuO2_termination_1 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.15285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.09715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.40285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.34715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +12 O (0.50000 +- nan) (0.00000 +- nan) (0.65285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +13 O (0.00000 +- nan) (0.00000 +- nan) (0.59715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +14 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +15 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +16 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +17 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +18 O (0.00000 +- nan) (0.00000 +- nan) (0.90285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +19 O (0.50000 +- nan) (0.00000 +- nan) (0.84715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +20 Ru (0.00000 +- nan) (0.00000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +21 Ru (0.50000 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +22 O (0.19431 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +23 O (0.80569 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.75 +layer_behaviour: select +layer_cycle: 1.0 + + +TerminationUnitCell 2 RuO2_termination_2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.00000 +- nan) (0.00000 +- nan) (0.15285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.50000 +- nan) (0.00000 +- nan) (0.09716 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.50000 +- nan) (0.00000 +- nan) (0.40285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +07 O (0.00000 +- nan) (0.00000 +- nan) (0.34715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +08 Ru (0.50000 +- nan) (0.00000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +09 Ru (0.00000 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +10 O (0.30569 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +11 O (0.69431 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +12 O (0.00000 +- nan) (0.00000 +- nan) (0.65285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +13 O (0.50000 +- nan) (0.00000 +- nan) (0.59715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +14 Ru (0.00000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +15 Ru (0.50000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +16 O (0.19431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +17 O (0.80569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +18 O (0.50000 +- nan) (0.00000 +- nan) (0.90285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +19 O (0.00000 +- nan) (0.00000 +- nan) (0.84715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +20 Ru (0.50000 +- nan) (0.00000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +21 Ru (0.00000 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +22 O (0.30569 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +23 O (0.69431 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +layerpos: 2.0 = 0.75 +layer_behaviour: select +layer_cycle: 2.0 + + + +# Film RuO2 +0002 occupancy = 1.00000 + +Width/layers +(17.00000 +- nan) + +UnitCell RuO2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# EpitaxyInterface TiO2toRuO2 +0001 occupancy = 1.00000 +type skellam +Width/cells Skew/cells StrainCoupling Offset/bulk_frac +0.35000 0.00000 1.00000 0.00000 + +TopUnitCell RuO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +BottomUnitCell TiO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ti (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ti (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30458 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69542 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ti (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ti (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19542 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80458 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# UnitCell bulk +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (-0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (-0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ti (0.50000 +- nan) (0.00000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ti (0.00000 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30458 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69542 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (-0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (-0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ti (0.00000 +- nan) (0.00000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ti (0.50000 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19542 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80458 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = -1.0, 2.0 = -0.5 +layer_behaviour: ignore diff --git a/examples/CTR/RuO2_TiO2_Poisson_etching.xtal b/examples/CTR/RuO2_TiO2_Poisson_etching.xtal index fc7d326..5e787ea 100644 --- a/examples/CTR/RuO2_TiO2_Poisson_etching.xtal +++ b/examples/CTR/RuO2_TiO2_Poisson_etching.xtal @@ -1,124 +1,170 @@ -E = 20.00000 keV -# PoissonSurface RuO2surface -0003 occupancy = 1.00000 - -W/layers offset/layers --6.00000 0.00000 -support_cursor: nominal - -UnitCell RuO2 -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 -01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 -02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 -03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 -04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 -05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 -06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 -07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 -08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 -09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 -10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 -11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -# Film RuO2 -0002 occupancy = 1.00000 - -Width/layers -17.00000 - -UnitCell RuO2 -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 -01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 -02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 -03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 -04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 -05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 -06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 -07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 -08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 -09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 -10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 -11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -# EpitaxyInterface TiO2toRuO2 -0001 occupancy = 1.00000 -type skellam -Width/cells Skew/cells -0.35000 0.00000 -support_cursor: nominal - -TopUnitCell RuO2interf -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 -01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 -02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 -03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 -04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 -05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 -06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 -07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 -08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 -09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 -10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 -11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -BottomUnitCell TiO2interf -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O 0.50000 0.00000 0.80458 0.3719 0.3719 1.0000 2 -01 O 0.00000 0.00000 0.69542 0.3719 0.3719 1.0000 2 -02 Ti 0.50000 0.00000 0.50000 0.3719 0.3719 1.0000 2 -03 Ti 0.00000 0.50000 0.50000 0.3719 0.3719 1.0000 2 -04 O 0.30458 0.50000 0.50000 0.3719 0.3719 1.0000 2 -05 O 0.69542 0.50000 0.50000 0.3719 0.3719 1.0000 2 -06 O 0.00000 0.00000 0.30458 0.3719 0.3719 1.0000 1 -07 O 0.50000 0.00000 0.19542 0.3719 0.3719 1.0000 1 -08 Ti 0.00000 0.00000 0.00000 0.3719 0.3719 1.0000 1 -09 Ti 0.50000 0.50000 0.00000 0.3719 0.3719 1.0000 1 -10 O 0.19542 0.50000 0.00000 0.3719 0.3719 1.0000 1 -11 O 0.80458 0.50000 0.00000 0.3719 0.3719 1.0000 1 -layerpos: 1.0 = 0.0, 2.0 = 0.5 -layer_behaviour: ignore - - -# UnitCell bulk -return -Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 -6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 -Name x/frac y/frac z/frac iDW oDW occup layerIdx -00 O 0.50000 0.00000 -0.19542 0.3719 0.3719 1.0000 2 -01 O 0.00000 0.00000 -0.30458 0.3719 0.3719 1.0000 2 -02 Ti 0.50000 0.00000 -0.50000 0.3719 0.3719 1.0000 2 -03 Ti 0.00000 0.50000 -0.50000 0.3719 0.3719 1.0000 2 -04 O 0.30458 0.50000 -0.50000 0.3719 0.3719 1.0000 2 -05 O 0.69542 0.50000 -0.50000 0.3719 0.3719 1.0000 2 -06 O 0.00000 0.00000 -0.69542 0.3719 0.3719 1.0000 1 -07 O 0.50000 0.00000 -0.80458 0.3719 0.3719 1.0000 1 -08 Ti 0.00000 0.00000 -1.00000 0.3719 0.3719 1.0000 1 -09 Ti 0.50000 0.50000 -1.00000 0.3719 0.3719 1.0000 1 -10 O 0.19542 0.50000 -1.00000 0.3719 0.3719 1.0000 1 -11 O 0.80458 0.50000 -1.00000 0.3719 0.3719 1.0000 1 -layerpos: 1.0 = -1.0, 2.0 = -0.5 -layer_behaviour: ignore +E = 20.00000 keV +# PoissonSurface RuO2surface +0003 occupancy = 1.00000 + +W/layers alpha offset/layers +-6.00000 1.00000 0.00000 + +TerminationUnitCell 1 RuO2_termination_1 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.15285 0.5033 0.5033 1.0000 1 +01 O 0.00000 0.00000 0.09715 0.5033 0.5033 1.0000 1 +02 Ru 0.50000 0.00000 0.00000 0.5033 0.5033 1.0000 1 +03 Ru 0.00000 0.50000 0.00000 0.5033 0.5033 1.0000 1 +04 O 0.30569 0.50000 0.00000 0.5033 0.5033 1.0000 1 +05 O 0.69431 0.50000 0.00000 0.5033 0.5033 1.0000 1 +06 O 0.00000 0.00000 0.40285 0.5033 0.5033 1.0000 1 +07 O 0.50000 0.00000 0.34715 0.5033 0.5033 1.0000 1 +08 Ru 0.00000 0.00000 0.25000 0.5033 0.5033 1.0000 1 +09 Ru 0.50000 0.50000 0.25000 0.5033 0.5033 1.0000 1 +10 O 0.19431 0.50000 0.25000 0.5033 0.5033 1.0000 1 +11 O 0.80569 0.50000 0.25000 0.5033 0.5033 1.0000 1 +12 O 0.50000 0.00000 0.65285 0.5033 0.5033 1.0000 1 +13 O 0.00000 0.00000 0.59715 0.5033 0.5033 1.0000 1 +14 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 1 +15 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 1 +16 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 1 +17 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 1 +18 O 0.00000 0.00000 0.90285 0.5033 0.5033 1.0000 1 +19 O 0.50000 0.00000 0.84715 0.5033 0.5033 1.0000 1 +20 Ru 0.00000 0.00000 0.75000 0.5033 0.5033 1.0000 1 +21 Ru 0.50000 0.50000 0.75000 0.5033 0.5033 1.0000 1 +22 O 0.19431 0.50000 0.75000 0.5033 0.5033 1.0000 1 +23 O 0.80569 0.50000 0.75000 0.5033 0.5033 1.0000 1 +layerpos: 1.0 = 0.75 +layer_behaviour: select +layer_cycle: 1.0 + + +TerminationUnitCell 2 RuO2_termination_2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.00000 0.00000 0.15285 0.5033 0.5033 1.0000 2 +01 O 0.50000 0.00000 0.09716 0.5033 0.5033 1.0000 2 +02 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 2 +03 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 2 +04 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 2 +05 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 2 +06 O 0.50000 0.00000 0.40285 0.5033 0.5033 1.0000 2 +07 O 0.00000 0.00000 0.34715 0.5033 0.5033 1.0000 2 +08 Ru 0.50000 0.00000 0.25000 0.5033 0.5033 1.0000 2 +09 Ru 0.00000 0.50000 0.25000 0.5033 0.5033 1.0000 2 +10 O 0.30569 0.50000 0.25000 0.5033 0.5033 1.0000 2 +11 O 0.69431 0.50000 0.25000 0.5033 0.5033 1.0000 2 +12 O 0.00000 0.00000 0.65285 0.5033 0.5033 1.0000 2 +13 O 0.50000 0.00000 0.59715 0.5033 0.5033 1.0000 2 +14 Ru 0.00000 0.00000 0.50000 0.5033 0.5033 1.0000 2 +15 Ru 0.50000 0.50000 0.50000 0.5033 0.5033 1.0000 2 +16 O 0.19431 0.50000 0.50000 0.5033 0.5033 1.0000 2 +17 O 0.80569 0.50000 0.50000 0.5033 0.5033 1.0000 2 +18 O 0.50000 0.00000 0.90285 0.5033 0.5033 1.0000 2 +19 O 0.00000 0.00000 0.84715 0.5033 0.5033 1.0000 2 +20 Ru 0.50000 0.00000 0.75000 0.5033 0.5033 1.0000 2 +21 Ru 0.00000 0.50000 0.75000 0.5033 0.5033 1.0000 2 +22 O 0.30569 0.50000 0.75000 0.5033 0.5033 1.0000 2 +23 O 0.69431 0.50000 0.75000 0.5033 0.5033 1.0000 2 +layerpos: 2.0 = 0.75 +layer_behaviour: select +layer_cycle: 2.0 + + + +# Film RuO2 +0002 occupancy = 1.00000 + +Width/layers +17.00000 + +UnitCell RuO2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 +01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 +02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 +03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 +04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 +05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 +06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 +07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 +08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 +09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 +10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 +11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# EpitaxyInterface TiO2toRuO2 +0001 occupancy = 1.00000 +type skellam +Width/cells Skew/cells StrainCoupling Offset/bulk_frac +0.35000 0.00000 1.00000 0.00000 + +TopUnitCell RuO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 +01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 +02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 +03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 +04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 +05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 +06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 +07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 +08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 +09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 +10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 +11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +BottomUnitCell TiO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.80458 0.3719 0.3719 1.0000 2 +01 O 0.00000 0.00000 0.69542 0.3719 0.3719 1.0000 2 +02 Ti 0.50000 0.00000 0.50000 0.3719 0.3719 1.0000 2 +03 Ti 0.00000 0.50000 0.50000 0.3719 0.3719 1.0000 2 +04 O 0.30458 0.50000 0.50000 0.3719 0.3719 1.0000 2 +05 O 0.69542 0.50000 0.50000 0.3719 0.3719 1.0000 2 +06 O 0.00000 0.00000 0.30458 0.3719 0.3719 1.0000 1 +07 O 0.50000 0.00000 0.19542 0.3719 0.3719 1.0000 1 +08 Ti 0.00000 0.00000 0.00000 0.3719 0.3719 1.0000 1 +09 Ti 0.50000 0.50000 0.00000 0.3719 0.3719 1.0000 1 +10 O 0.19542 0.50000 0.00000 0.3719 0.3719 1.0000 1 +11 O 0.80458 0.50000 0.00000 0.3719 0.3719 1.0000 1 +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# UnitCell bulk +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 -0.19542 0.3719 0.3719 1.0000 2 +01 O 0.00000 0.00000 -0.30458 0.3719 0.3719 1.0000 2 +02 Ti 0.50000 0.00000 -0.50000 0.3719 0.3719 1.0000 2 +03 Ti 0.00000 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +04 O 0.30458 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +05 O 0.69542 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +06 O 0.00000 0.00000 -0.69542 0.3719 0.3719 1.0000 1 +07 O 0.50000 0.00000 -0.80458 0.3719 0.3719 1.0000 1 +08 Ti 0.00000 0.00000 -1.00000 0.3719 0.3719 1.0000 1 +09 Ti 0.50000 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +10 O 0.19542 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +11 O 0.80458 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +layerpos: 1.0 = -1.0, 2.0 = -0.5 +layer_behaviour: ignore diff --git a/examples/CTR/RuO2_TiO2_epitaxy_strain_coupling_offset.ipynb b/examples/CTR/RuO2_TiO2_epitaxy_strain_coupling_offset.ipynb new file mode 100644 index 0000000..6144338 --- /dev/null +++ b/examples/CTR/RuO2_TiO2_epitaxy_strain_coupling_offset.ipynb @@ -0,0 +1,341 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "overview", + "metadata": {}, + "source": [ + "# RuO₂/TiO₂ epitaxy: strain coupling and vertical offset\n", + "\n", + "Compare the `(0, 0, L)` CTR and adjacent $zDensity_G(z,0,0)$ profile for the `EpitaxyInterface` strain-coupling and film–bulk offset parameters.\n", + "\n", + "- The RuO₂ film is approximately **15 nm** thick.\n", + "- The Skellam interface width is **3 nm**, converted to lower-bulk unit cells.\n", + "- The first sweep uses `κ = 0, 0.25, 0.5, 0.75, 1` at zero offset.\n", + "- The second sweep fixes `κ = 0` and varies the offset from `-1` to `+1` lower-bulk `c` lattice units.\n", + "- CTR curves are vertically separated by multiplicative factors; density curves are separated by additive offsets, following the other CTR comparison notebooks.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports-and-template", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib widget\n", + "from pathlib import Path\n", + "import copy\n", + "import sys\n", + "\n", + "import matplotlib as mpl\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "\n", + "def find_repository_root():\n", + " \"\"\"Find the checkout containing this example notebook.\"\"\"\n", + " for candidate in (Path.cwd(), *Path.cwd().parents):\n", + " if (candidate / \"orgui\" / \"datautils\").is_dir():\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "repository_root = find_repository_root()\n", + "if repository_root is not None:\n", + " sys.path.insert(0, str(repository_root))\n", + "\n", + "from orgui.datautils.xrayutils import CTRcalc\n", + "\n", + "if (\n", + " repository_root is not None\n", + " and repository_root not in Path(CTRcalc.__file__).resolve().parents\n", + "):\n", + " raise RuntimeError(\n", + " \"An installed orgui version is already loaded. Restart the \"\n", + " \"kernel and run the notebook from the first cell.\"\n", + " )\n", + "\n", + "\n", + "def find_example_file(filename):\n", + " \"\"\"Find an example file from the repository or notebook directory.\"\"\"\n", + " candidates = (Path(filename), Path(\"examples/CTR\") / filename)\n", + " for candidate in candidates:\n", + " if candidate.is_file():\n", + " return candidate\n", + " raise FileNotFoundError(filename)\n", + "\n", + "\n", + "model_path = find_example_file(\"RuO2_TiO2_Poisson_etching.xtal\")\n", + "model_template = CTRcalc.SXRDCrystal.fromFile(model_path)\n", + "model_template.getUcNames()\n" + ] + }, + { + "cell_type": "markdown", + "id": "model-construction-notes", + "metadata": {}, + "source": [ + "## Model construction\n", + "\n", + "`Film.basis[0]` is a number of structural layers, whereas `EpitaxyInterface.basis[0]` is a width in unit cells. The requested physical dimensions are converted using the RuO₂ structural-layer spacing and the TiO₂ lower-bulk `c` lattice constant, respectively.\n", + "\n", + "The interface width is the Skellam standard deviation. A `tail_probability` of `1e-4` truncates only the far statistical tails while keeping the generated interface support shorter than the 15 nm film. The film surface is otherwise ideal; the Poisson surface component from the template is deliberately omitted so this notebook isolates epitaxial strain coupling and offset.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "model-and-plot-helpers", + "metadata": {}, + "outputs": [], + "source": [ + "film_thickness_A = 150.0\n", + "interface_width_A = 30.0\n", + "tail_probability = 1e-4\n", + "\n", + "template_interface = model_template[\"TiO2toRuO2\"]\n", + "template_film = model_template[\"RuO2\"]\n", + "\n", + "film_layer_spacing_A = (\n", + " template_film.unitcell.a[2] / len(template_film.layers)\n", + ")\n", + "film_layers = int(round(film_thickness_A / film_layer_spacing_A))\n", + "actual_film_thickness_A = film_layers * film_layer_spacing_A\n", + "interface_width_cells = (\n", + " interface_width_A / template_interface.uc_bottom.a[2]\n", + ")\n", + "\n", + "print(f\"Film: {film_layers} layers = {actual_film_thickness_A / 10:.3f} nm\")\n", + "print(\n", + " f\"Interface width: {interface_width_cells:.4f} lower-bulk cells \"\n", + " f\"= {interface_width_A / 10:.1f} nm\"\n", + ")\n", + "\n", + "\n", + "def make_model(strain_coupling, offset=0.0):\n", + " \"\"\"Build a bulk–interface–film model for one parameter pair.\"\"\"\n", + " bulk = copy.deepcopy(model_template[\"bulk\"])\n", + " interface = copy.deepcopy(template_interface)\n", + " film = copy.deepcopy(template_film)\n", + "\n", + " interface.profile = CTRcalc.SkellamProfile(\n", + " width=interface_width_cells,\n", + " asymmetry=0.0,\n", + " tail_probability=tail_probability,\n", + " )\n", + " interface.basis[:] = [\n", + " interface_width_cells,\n", + " 0.0,\n", + " strain_coupling,\n", + " offset,\n", + " ]\n", + " interface.basis_0[:] = interface.basis\n", + "\n", + " film.basis[0] = film_layers\n", + " film.basis_0[:] = film.basis\n", + "\n", + " model = CTRcalc.SXRDCrystal(\n", + " bulk,\n", + " interface,\n", + " film,\n", + " stacking=np.array([1, 2]),\n", + " atten=model_template.atten,\n", + " )\n", + " model.apply_stacking()\n", + " return model\n", + "\n", + "\n", + "L = np.linspace(0.95, 7.0, 2400)\n", + "H = np.zeros_like(L)\n", + "K = np.zeros_like(L)\n", + "z = np.linspace(-150.0, 170.0, 6000)\n", + "\n", + "\n", + "def evaluate_sweep(values, model_factory):\n", + " \"\"\"Calculate `(0, 0, L)` amplitudes and real z densities.\"\"\"\n", + " ctrs = []\n", + " densities = []\n", + " for value in values:\n", + " model = model_factory(value)\n", + " ctrs.append(np.abs(model.F(H, K, L)))\n", + " densities.append(np.real(model.zDensity_G(z, 0, 0)))\n", + " return np.asarray(ctrs), np.asarray(densities)\n", + "\n", + "\n", + "def plot_paired_sweep(values, ctrs, densities, *, cmap_name, colorbar_label, title):\n", + " \"\"\"Plot vertically separated density and CTR curves in adjacent axes.\"\"\"\n", + " values = np.asarray(values, dtype=float)\n", + " cmap = mpl.colormaps[cmap_name]\n", + " norm = mpl.colors.Normalize(vmin=values.min(), vmax=values.max())\n", + " ctr_stack_factor = 3.0\n", + " density_step = 1.15 * np.max(np.abs(densities))\n", + "\n", + " fig, (density_ax, ctr_ax) = plt.subplots(\n", + " 1,\n", + " 2,\n", + " figsize=(13.0, 5.4),\n", + " constrained_layout=True,\n", + " )\n", + " for plot_index, (value, ctr, density) in enumerate(\n", + " zip(values, ctrs, densities)\n", + " ):\n", + " color = cmap(norm(value))\n", + " density_ax.plot(\n", + " z,\n", + " density + density_step * plot_index,\n", + " color=color,\n", + " linewidth=1.15,\n", + " )\n", + " ctr_ax.semilogy(\n", + " L,\n", + " ctr * ctr_stack_factor**plot_index,\n", + " color=color,\n", + " linewidth=1.2,\n", + " )\n", + "\n", + " density_ax.axvline(0.0, color=\"0.25\", linestyle=\"--\", linewidth=0.9)\n", + " density_ax.set_xlabel(r\"$z$ / Angstrom\")\n", + " density_ax.set_ylabel(r\"Vertically offset $\\mathrm{Re}[\\rho_{00}(z)]$\")\n", + " density_ax.set_title(r\"Adjacent $zDensity_G(z,0,0)$ profiles\")\n", + " density_ax.grid(alpha=0.18)\n", + "\n", + " ctr_ax.set_xlabel(r\"$L$ / r.l.u.\")\n", + " ctr_ax.set_ylabel(r\"Vertically offset $|F_{00L}|$ / electrons\")\n", + " ctr_ax.set_title(r\"$(0,0,L)$ CTR\")\n", + " ctr_ax.grid(alpha=0.18, which=\"both\")\n", + " fig.suptitle(title)\n", + "\n", + " colorbar = fig.colorbar(\n", + " mpl.cm.ScalarMappable(norm=norm, cmap=cmap),\n", + " ax=(density_ax, ctr_ax),\n", + " pad=0.02,\n", + " ticks=values,\n", + " )\n", + " colorbar.set_label(colorbar_label)\n", + " return fig\n" + ] + }, + { + "cell_type": "markdown", + "id": "strain_coupling-sweep-notes", + "metadata": {}, + "source": [ + "## Strain coupling sweep\n", + "\n", + "`strain_coupling = 0` leaves the bulk and film on their independent unstrained lattices. Increasing `strain_coupling` linearly moves their generated interface positions toward the fully strain-coupled field at `strain_coupling = 1`. The film–bulk offset remains zero.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "strain_coupling-sweep", + "metadata": {}, + "outputs": [], + "source": [ + "strain_couplings = np.array([0.0, 0.25, 0.5, 0.75, 1.0])\n", + "strain_coupling_ctrs, strain_coupling_densities = evaluate_sweep(\n", + " strain_couplings,\n", + " lambda strain_coupling: make_model(strain_coupling=strain_coupling, offset=0.0),\n", + ")\n", + "\n", + "strain_coupling_figure = plot_paired_sweep(\n", + " strain_couplings,\n", + " strain_coupling_ctrs,\n", + " strain_coupling_densities,\n", + " cmap_name=\"viridis\",\n", + " colorbar_label=\"Strain coupling κ\",\n", + " title=\"15 nm RuO₂ film, 3 nm interface: independent-lattice to fully strain-coupled\",\n", + ")\n", + "# strain_coupling_figure\n" + ] + }, + { + "cell_type": "markdown", + "id": "offset-sweep-notes", + "metadata": {}, + "source": [ + "## Offset sweep at `strain_coupling = 0`\n", + "\n", + "The offset is expressed in fractional lower-bulk `c` coordinates. It translates the RuO₂-side interface atoms and the complete film while leaving the TiO₂ bulk fixed. The sweep below spans `-1` to `+1`, including half-cell offsets.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "offset-sweep", + "metadata": {}, + "outputs": [], + "source": [ + "offsets = np.array([0.0, 0.1, -0.1, 0.2, -0.2])[::-1]\n", + "offset_ctrs, offset_densities = evaluate_sweep(\n", + " offsets,\n", + " lambda offset: make_model(strain_coupling=0.0, offset=offset),\n", + ")\n", + "\n", + "offset_figure = plot_paired_sweep(\n", + " offsets,\n", + " offset_ctrs,\n", + " offset_densities,\n", + " cmap_name=\"plasma\",\n", + " colorbar_label=r\"Offset / lower-bulk $c$\",\n", + " title=\"15 nm RuO₂ film, 3 nm interface: offset sweep at strain_coupling = 0\",\n", + ")\n", + "# offset_figure\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c808045-11c2-4037-b1cb-71413e32762f", + "metadata": {}, + "outputs": [], + "source": [ + "offsets = np.array([0.0, 0.03, -0.03, 0.06, -0.06])[::-1]\n", + "offset_ctrs, offset_densities = evaluate_sweep(\n", + " offsets,\n", + " lambda offset: make_model(strain_coupling=1.0, offset=offset),\n", + ")\n", + "\n", + "offset_figure = plot_paired_sweep(\n", + " offsets,\n", + " offset_ctrs,\n", + " offset_densities,\n", + " cmap_name=\"plasma\",\n", + " colorbar_label=r\"Offset / lower-bulk $c$\",\n", + " title=\"15 nm RuO₂ film, 3 nm interface: offset sweep at strain_coupling = 1\",\n", + ")\n", + "offset_figure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd521878-e73d-421d-a6d6-4176fc9767be", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/CTR/test/0001_fit_2V036_reference.xtal b/examples/CTR/test/0001_fit_2V036_reference.xtal index 7669762..3ecca0c 100644 --- a/examples/CTR/test/0001_fit_2V036_reference.xtal +++ b/examples/CTR/test/0001_fit_2V036_reference.xtal @@ -30,8 +30,8 @@ layer_behaviour: ignore # EpitaxyInterface TiO2toRuO2 0001 occupancy = 1.00000 +- nan type skellam -Width/cells Skew/cells -(0.35000 +- nan) (0.00000 +- 0.00000) +Width/cells Skew/cells StrainCoupling Offset/bulk_frac +(0.35000 +- nan) (0.00000 +- 0.00000) (1.00000 +- nan) (0.00000 +- nan) TopUnitCell RuO2interf return diff --git a/meson.build b/meson.build index 7d4ac50..80e95da 100644 --- a/meson.build +++ b/meson.build @@ -12,6 +12,14 @@ project( py = import('python').find_installation(pure: false) pybind11_dep = dependency('pybind11', required: true) +# The default preserves normal Meson dependency resolution. Wheel builds +# request a static library so they do not acquire a libxxhash runtime +# dependency from the build host; the pinned fallback is itself static. +xxhash_dep = dependency( + 'libxxhash', + static: get_option('static_xxhash'), + fallback: ['xxhash', 'xxhash_dep'], +) cpp = meson.get_compiler('cpp') native_args = [] @@ -196,7 +204,7 @@ configure_file( py.extension_module( '_CTRcalc_cpp', files('orgui/datautils/xrayutils/cpp/CTRcalc_cpp.cpp'), - dependencies: [pybind11_dep], + dependencies: [pybind11_dep, xxhash_dep], install: true, subdir: 'orgui/datautils/xrayutils' ) diff --git a/meson_options.txt b/meson_options.txt index 7538b5f..372fa85 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -4,3 +4,10 @@ option( value: false, description: 'Build C++ extensions with native CPU flags for local benchmarking' ) + +option( + 'static_xxhash', + type: 'boolean', + value: false, + description: 'Force the pinned static xxHash subproject instead of a system library' +) diff --git a/orgui/datautils/xrayutils/AGENTS.md b/orgui/datautils/xrayutils/AGENTS.md index 891d8c4..1fc53f6 100644 --- a/orgui/datautils/xrayutils/AGENTS.md +++ b/orgui/datautils/xrayutils/AGENTS.md @@ -100,6 +100,10 @@ Run the narrowest relevant regression test first: - `pytest orgui/datautils/xrayutils/test/test_HKLcalc.py` - `pytest orgui/datautils/xrayutils/test/test_DetectorCalibration.py` - `pytest orgui/datautils/xrayutils/test/test_CTRcalc.py` +- `pytest orgui/datautils/xrayutils/test/test_CTRsymmetry.py` +- `pytest orgui/datautils/xrayutils/test/test_CTRresolution.py` +- `pytest orgui/datautils/xrayutils/test/test_CTRoptical_profile.py` +- `pytest orgui/datautils/xrayutils/test/test_scattering_factor_cache.py` Use `ruff check orgui/datautils/xrayutils` for local lint checks. If a change touches config-facing unit conversions, also inspect `examples/config_minimal` diff --git a/orgui/datautils/xrayutils/CTRcalc.py b/orgui/datautils/xrayutils/CTRcalc.py index e453877..88247eb 100644 --- a/orgui/datautils/xrayutils/CTRcalc.py +++ b/orgui/datautils/xrayutils/CTRcalc.py @@ -40,6 +40,15 @@ from .CTRuc import WaterModel, UnitCell from .CTRfilm import EpitaxyInterface, Film, PoissonSurface +from .CTRoptics import ( + _specular_reflection, + add_structural_to_sampled_profile, + combine_profiles, + simplify_profile, + solve_wavefield, + stratify_profile, + top_layer_spacing, +) from .CTRdistributions import PoissonProfile, SkellamProfile # noqa: F401 from .CTRstacking import ( # noqa: F401 LayerCycle, @@ -77,14 +86,16 @@ def __init__(self, uc_bulk, *uc_surface, **keyargs): :param numpy.ndarray stacking: Integer stacking levels for the surface components. :param float atten: - Dimensionless bulk attenuation exponent per unit cell. + Dimensionless bulk attenuation exponent per reference-cell + out-of-plane repeat. """ self.uc_bulk = uc_bulk self.uc_surface_list = list(uc_surface) self.enable_uc_stacking = keyargs.get("enable_stacking", False) if not self.enable_uc_stacking: for uc in self.uc_surface_list: - if isinstance(uc, Film | PoissonSurface | EpitaxyInterface): + stackable = Film | PoissonSurface | EpitaxyInterface | WaterModel + if isinstance(uc, stackable): self.enable_uc_stacking = True break @@ -253,18 +264,23 @@ def apply_stacking(self): layer_state_new = getattr(self.uc_bulk, "layer_state", None) layer_number_new = layer_state_new.layer if layer_state_new is not None else -1 loc_new = 0.0 + component_new = self.uc_bulk for stacking_level in np.unique(self.uc_stacking_ordered): below_height = height_new below_layer = layer_number_new below_loc = loc_new + below_component = component_new while self.uc_stacking_ordered[i] == stacking_level: uc = self.uc_surface_list_ordered[i] if hasattr(uc, "stack_on"): + stack_kwargs = {"below_state": layer_state_new} + if isinstance(uc, PoissonSurface): + stack_kwargs["below_component"] = below_component uc.stack_on( below_loc, below_height, below_layer, - below_state=layer_state_new, + **stack_kwargs, ) else: uc.start_layer_number = below_layer @@ -279,6 +295,7 @@ def apply_stacking(self): loc_new = uc.loc_absolute layer_number_new = uc.end_layer_number layer_state_new = getattr(uc, "layer_state", None) + component_new = uc i += 1 if i == len(self.uc_stacking_ordered): return @@ -512,10 +529,14 @@ def addWyckoffParameter(self, parameter, limits=(-np.inf, np.inf), **keyargs): ``unitcell="top"``, ``unitcell="bottom"``, or a list in the selection dictionary. + The coupled value and limits are absolute. This method distributes the + request to :meth:`UnitCell.addWyckoffParameter` on each selected unit + cell and links the resulting absolute parameters by name. + :param dict parameter: Component-specific Wyckoff variable selections. :param tuple limits: - Shared delta limits in fractional units. + Shared absolute limits in fractional units. :param keyargs: Additional coupled-parameter settings such as ``name`` or ``prior``. @@ -528,13 +549,12 @@ def addWyckoffParameter(self, parameter, limits=(-np.inf, np.inf), **keyargs): parameter, kind="coordinate", value_key="variable", - coupling_getter="wyckoff_couplings", limits=limits, **keyargs, ) def addWyckoffShift(self, parameter, limits=(-np.inf, np.inf), **keyargs): - """Add a coupled representative Wyckoff-site shift parameter. + """Add coupled relative shifts along parent-cell directions. ``parameter`` maps crystal component names to Wyckoff shift selections. A selection can be ``(site_id, axis)`` or a dictionary with @@ -546,7 +566,7 @@ def addWyckoffShift(self, parameter, limits=(-np.inf, np.inf), **keyargs): :param dict parameter: Component-specific Wyckoff shift selections. :param tuple limits: - Shared parent-coordinate delta limits in fractional units. + Shared relative-shift limits in parent fractional units. :param keyargs: Additional coupled-parameter settings such as ``name`` or ``prior``. @@ -559,7 +579,6 @@ def addWyckoffShift(self, parameter, limits=(-np.inf, np.inf), **keyargs): parameter, kind="site_displacement", value_key="axis", - coupling_getter="wyckoff_site_couplings", limits=limits, **keyargs, ) @@ -569,7 +588,6 @@ def _add_wyckoff_coupled_parameter( parameter, kind, value_key, - coupling_getter, limits=(-np.inf, np.inf), **keyargs, ): @@ -581,47 +599,41 @@ def _add_wyckoff_coupled_parameter( keyargs["name"] = name keyargs["prior"] = prior - component_indices = [] + targets = [] for component_key, selection in parameter.items(): - for component_index, unitcell, site_id, value in self._wyckoff_targets( - component_key, - selection, - value_key, - ): - couplings = [ - coupling - for coupling in getattr(unitcell, coupling_getter)(site_id) - if getattr(coupling, value_key) == value - ] - if not couplings: - raise ValueError( - f"No Wyckoff {value_key} couplings found for " - f"{component_key!r}, site {site_id!r}, {value_key} " - f"{value!r}." - ) - atoms = np.asarray( - [coupling.atom_index for coupling in couplings], - dtype=np.intp, + targets.extend( + (component_key, *target) + for target in self._wyckoff_targets( + component_key, + selection, + value_key, ) - coordinates = tuple(coupling.coordinate for coupling in couplings) - factors = np.asarray( - [coupling.factor for coupling in couplings], - dtype=np.float64, + ) + + component_indices = [] + for component_key, component_index, unitcell, site_id, value in targets: + settings = dict(keyargs) + if kind == "coordinate": + unitcell.addWyckoffParameter( + site_id, + value, + limits=limits, + **settings, ) - settings = dict(keyargs) - settings["wyckoff"] = { - "site_id": site_id, - "kind": kind, - value_key: value, - "value_kind": "delta", - } - unitcell.addRelParameter( - (atoms, coordinates), - factors, - limits, + else: + unitcell.addWyckoffShift( + site_id, + value, + limits=limits, **settings, ) - component_indices.append(component_index) + component_indices.append(component_index) + + coupled_settings = dict(keyargs) + coupled_settings["wyckoff"] = { + "kind": kind, + "value_kind": "absolute" if kind == "coordinate" else "delta", + } par = Parameter( name, @@ -629,7 +641,7 @@ def _add_wyckoff_coupled_parameter( ParameterType.RELATIVE, limits, prior, - keyargs, + coupled_settings, ) self.parameters["coupled"].append(par) self._parIdNo += 1 @@ -926,7 +938,7 @@ def setParameters(self, x): x = np.asarray(x) x_r = x[ self.fit_metadata_cache["par_idx_sortarray"] - ] # reorder fitpars, this handles coupled parameters + ] # reorder fitpars, including coupled absolute Wyckoff parameters x_ucs = x_r[self.fit_metadata_cache["number_xtalpar"] :] uc_numberpars = self.fit_metadata_cache["uc_numberpars"] @@ -958,7 +970,7 @@ def setLimits(self, lim): lim = np.asarray(lim) x_r = lim[ self.fit_metadata_cache["par_idx_sortarray"] - ] # reorder fitpars, this handles coupled parameters + ] # reorder limits, including coupled absolute Wyckoff parameters x_ucs = x_r[self.fit_metadata_cache["number_xtalpar"] :] uc_numberpars = self.fit_metadata_cache["uc_numberpars"] @@ -1079,6 +1091,192 @@ def zDensity_G(self, z, h, k): rho += uc.zDensity_G(z, h, k) * w return rho + def optical_profile(self): + """Return the combined homogeneous optical profile of this crystal. + + Crystal surface weights are applied directly to ``delta`` and ``beta``. + Water is sampled on the top atomistic layer grid. Without water, one + vacuum row is appended one top-layer spacing above the highest atomic + layer. + + :returns: + C-contiguous ``(N, 3)`` array with columns ``z`` in Angstrom, + ``delta``, and ``beta``. + :rtype: numpy.ndarray + :raises NotImplementedError: + If a component does not expose an atomistic optical profile. + """ + if self.enable_uc_stacking: + self.apply_stacking() + profiles = [self.uc_bulk.optical_profile_asbulk()] + water_components = [] + for component, weight in zip(self.uc_surface_list, self.weights): + if not hasattr(component, "optical_profile"): + raise NotImplementedError( + f"{type(component).__name__} has no atomistic optical profile." + ) + if isinstance(component, WaterModel): + water_components.append((component, weight)) + else: + profile = component.optical_profile().copy() + profile[:, 1:] *= weight + profiles.append(profile) + structural_profile = combine_profiles(*profiles) + dz = top_layer_spacing(structural_profile) + if not water_components: + vacuum = np.array( + [[structural_profile[-1, 0] + dz, 0.0, 0.0]], dtype=np.float64 + ) + return np.ascontiguousarray( + np.concatenate((structural_profile, vacuum), axis=0) + ) + water_profiles = [] + z_origin = structural_profile[-1, 0] + for component, weight in water_components: + profile = component.optical_profile( + z_step=dz, z_origin=z_origin + ).copy() + profile[:, 1:] *= weight + water_profiles.append(profile) + return add_structural_to_sampled_profile( + structural_profile, *water_profiles + ) + + def simplified_optical_profile( + self, delta_tolerance=1e-9, beta_tolerance=None + ): + """Return a thickness-conserving simplified optical profile. + + :param float delta_tolerance: + Maximum delta range within a merged finite layer. + :param float beta_tolerance: + Maximum beta range within a merged finite layer. Defaults to the + delta tolerance. + :returns: + Simplified ``(N, 3)`` optical profile. + :rtype: numpy.ndarray + """ + return simplify_profile( + self.optical_profile(), delta_tolerance, beta_tolerance + ) + + def stratified_optical_profile( + self, delta_tolerance=1e-9, beta_tolerance=None + ): + """Return optical media and their physical interface positions. + + Profile z coordinates are centers of homogeneous media. Interfaces + are centered between adjacent samples before optional simplification; + merged media retain those original outer boundaries. + + :param float delta_tolerance: + Maximum delta range within a merged finite layer. Pass ``None`` + to retain every sampled medium. + :param float beta_tolerance: + Maximum beta range within a merged finite layer. Defaults to the + delta tolerance when simplification is requested. + :returns: + Layer-center optical constants and substrate-to-ambient edges. + :rtype: CTRoptics.StratifiedProfile + """ + return stratify_profile( + self.optical_profile(), delta_tolerance, beta_tolerance + ) + + def wavefield( + self, + alpha, + polarization="s", + delta_tolerance=1e-9, + beta_tolerance=None, + ): + """Return the unperturbed layered electric field. + + The field follows the Renaud convention with downward and upward + amplitudes ``A_plus`` and ``A_minus``. Slabs are ordered from the + incident medium towards the substrate. + + :param float or numpy.ndarray alpha: + Glancing incidence angle or angle array in degrees inside the + incident medium. For arrays, wavefield quantities have the layer + axis first followed by the original angle-array shape. + :param str polarization: + ``"s"`` or ``"p"``. + :param float delta_tolerance: + Maximum delta range used to simplify adjacent layers. Pass + ``None`` to disable simplification. + :param float beta_tolerance: + Optional beta simplification tolerance. Defaults to the delta + tolerance when simplification is requested. + :returns: + Layer amplitudes, normal wavevectors, and sampled electric field. + :rtype: CTRoptics.Wavefield + """ + profile = self.stratified_optical_profile( + delta_tolerance, beta_tolerance + ) + return solve_wavefield( + profile.values, + self.uc_bulk._E, + alpha, + polarization, + boundaries=profile.boundaries, + ) + + def specular_reflectivity( + self, + alpha, + polarization="s", + delta_tolerance=1e-9, + beta_tolerance=None, + ): + """Return scalar optical specular reflectivity. + + :param float or numpy.ndarray alpha: + Glancing incidence angle or angles in degrees inside the incident + medium. + :param str polarization: + ``"s"``, ``"p"``, or ``"unpolarized"``. Unpolarized intensity + is the equal incoherent average of s and p reflectivity. + :param float delta_tolerance: + Maximum delta range used to simplify adjacent layers. Pass + ``None`` to disable simplification. + :param float beta_tolerance: + Optional beta simplification tolerance. Defaults to the delta + tolerance when simplification is requested. + :returns: + Reflectivity with the same scalar/array shape as ``alpha``. + :rtype: float or numpy.ndarray + """ + if polarization not in {"s", "p", "unpolarized"}: + raise ValueError( + "polarization must be 's', 'p', or 'unpolarized'." + ) + alpha_array = np.asarray(alpha, dtype=np.float64) + scalar = alpha_array.ndim == 0 + angles = np.atleast_1d(alpha_array).ravel() + profile = self.stratified_optical_profile( + delta_tolerance, beta_tolerance + ) + + def reflectivity(pol): + reflection = _specular_reflection( + profile.values, + self.uc_bulk._E, + angles, + pol, + boundaries=profile.boundaries, + ) + return np.abs(reflection) ** 2 + + if polarization == "unpolarized": + result = 0.5 * (reflectivity("s") + reflectivity("p")) + else: + result = reflectivity(polarization) + if scalar: + return float(result[0]) + return result.reshape(alpha_array.shape) + def toRODStr(self): s = f"E = {self.uc_bulk._E * 1e-3:.5f} keV\n" for i, w, uc in zip(self.uc_stacking, self.weights, self.uc_surface_list): @@ -1215,23 +1413,69 @@ def plot3d( occuon=False, figure=None, translate=np.array([0.0, 0.0, 0.0]), + backend="auto", + radius_scale=1.0, **keyargs, ): - try: - from mayavi import mlab - except ImportError: - warnings.warn("can not import mayavi: 3D plotting not supported") - return - - if figure is None: - figure = mlab.figure() + """Plot the bulk and surface unit cells in one 3D viewer. + + Coordinates and radii passed to either backend are in Angstrom. + Assign the result or terminate an incremental call with a semicolon to + prevent Jupyter from also rendering the returned view as a new output. + + :param int ucx: Number of cells along the first lattice direction. + :param int ucy: Number of cells along the second lattice direction. + :param int ucz: Number of bulk cells along the third lattice direction. + :param bool dwon: + Sample atom positions using the stored Debye-Waller disorder. + :param bool occuon: + Randomly omit atoms according to their occupancies. + :param figure: + Existing Mayavi figure or py3Dmol view to extend. + :param numpy.ndarray translate: + Fractional translation retained for API compatibility. + :param str backend: + ``"auto"``, ``"mayavi"``, or ``"py3dmol"``. + :param float radius_scale: + Positive dimensionless multiplier applied to covalent radii. + :returns: The selected backend's shared figure or view. + """ + keyargs["_defer_update"] = True for mat in self.uc_bulk.coherentDomainMatrix: - self.uc_bulk.plot3d(ucx, ucy, ucz, dwon, occuon, figure, mat, **keyargs) + figure = self.uc_bulk.plot3d( + ucx, + ucy, + ucz, + dwon, + occuon, + figure, + mat, + backend=backend, + radius_scale=radius_scale, + **keyargs, + ) for uc in self.uc_surface_list: for mat in uc.coherentDomainMatrix: - uc.plot3d(ucx, ucy, 1, dwon, occuon, figure, mat, **keyargs) + figure = uc.plot3d( + ucx, + ucy, + 1, + dwon, + occuon, + figure, + mat, + backend=backend, + radius_scale=radius_scale, + **keyargs, + ) + + if getattr(figure, "_orgui_plot3d_backend", None) == "py3dmol": + figure.zoomTo() + if getattr(figure, "uniqueid", None) is not None: + figure.update() + return figure def getUcNames(self): return [uc.name for uc in self.uc_surface_list] + ["bulk"] diff --git a/orgui/datautils/xrayutils/CTRdistributions.py b/orgui/datautils/xrayutils/CTRdistributions.py index d686caa..7c2b408 100644 --- a/orgui/datautils/xrayutils/CTRdistributions.py +++ b/orgui/datautils/xrayutils/CTRdistributions.py @@ -1,5 +1,6 @@ """Statistical occupancy profiles for CTR interfaces and surfaces.""" +from abc import ABC, abstractmethod from dataclasses import dataclass import numpy as np @@ -7,6 +8,7 @@ DEFAULT_TAIL_PROBABILITY = 1e-10 +SURFACE_OCCUPANCY_TOLERANCE = 1e-12 def _validate_tail_probability(tail_probability): @@ -16,18 +18,95 @@ def _validate_tail_probability(tail_probability): return tail_probability -class PoissonProfile: - """Describe signed Poisson growth or etching of a surface. +class SurfaceProfile(ABC): + """Contract for discrete surface-height occupancy profiles. - The random surface-height change in structural layers is - ``offset + sign(mean_change) * Poisson(abs(mean_change))``. + Offsets passed to :meth:`surface_occupancy` are consecutive structural + layers ordered from bottom to top. :meth:`occupancy` returns the total + material fraction at each offset. The exposed fraction is the decrease + in material occupancy toward the next layer, with the highest represented + layer treated as completely exposed. + """ + + @abstractmethod + def support(self): + """Return the inclusive structural-layer support offsets.""" + + @abstractmethod + def occupancy(self, offsets): + """Return cumulative material occupancy at structural-layer offsets.""" + + def correction(self, offsets): + """Return material occupancy relative to a sharp surface at zero.""" + offsets = np.asarray(offsets, dtype=np.float64) + sharp = (offsets < 0).astype(np.float64) + return self.occupancy(offsets) - sharp + + def surface_occupancy(self, offsets): + """Return the truly exposed fraction of each represented layer. + + :param numpy.ndarray offsets: + Consecutive integer structural-layer offsets ordered bottom to + top. + :returns: + Exposed surface occupancy for every supplied offset. + :rtype: numpy.ndarray + :raises ValueError: + If offsets are not consecutive or the cumulative occupancy is + non-finite, outside zero to one, or increases toward the surface. + """ + offsets = np.asarray(offsets, dtype=np.float64) + if offsets.ndim != 1: + raise ValueError("surface offsets must be a one-dimensional array") + if offsets.size == 0: + return np.array([], dtype=np.float64) + if not np.all(np.isclose(offsets, np.rint(offsets))): + raise ValueError("surface offsets must be integer structural layers") + if offsets.size > 1 and not np.all(np.diff(offsets) == 1): + raise ValueError("surface offsets must be consecutive and bottom-to-top") + + occupancy = np.asarray(self.occupancy(offsets), dtype=np.float64) + if occupancy.shape != offsets.shape: + raise ValueError("surface occupancy must match the offsets shape") + if not np.all(np.isfinite(occupancy)): + raise ValueError("surface occupancy must be finite") + tolerance = SURFACE_OCCUPANCY_TOLERANCE + if np.any(occupancy < -tolerance) or np.any(occupancy > 1.0 + tolerance): + raise ValueError("surface occupancy must be between zero and one") + occupancy = np.clip(occupancy, 0.0, 1.0) + + exposed = np.empty_like(occupancy) + exposed[:-1] = occupancy[:-1] - occupancy[1:] + exposed[-1] = occupancy[-1] + if np.any(exposed < -tolerance): + raise ValueError("surface occupancy must not increase toward the surface") + return np.clip(exposed, 0.0, 1.0) + + +class PoissonProfile(SurfaceProfile): + """Describe signed step--Poisson growth or etching of a surface. + + The random surface-height change is the convolution + + ``Step(offset + (1 - alpha) * mean_change)`` + + ``+ sign(mean_change) * Poisson(alpha * abs(mean_change))``. + + For ``s = sign(s) * (floor(abs(s)) + f)``, ``Step(s)`` is + ``sign(s) * (floor(abs(s)) + Bernoulli(f))``. This two-layer + interpolation keeps a fractional deterministic offset continuous on the + structural-layer grid. Consequently, ``alpha = 0`` gives ideal + layer-by-layer growth or dissolution, and ``alpha = 1`` combines the + deterministic structural cursor with a Poisson process. :param float mean_change: Signed mean process width in structural layers. Positive values model growth and negative values model dissolution or etching. + :param float alpha: + Fraction of the absolute mean assigned to the Poisson component. + Must lie between zero and one. :param float offset: - Deterministic surface-height offset in structural layers. Set this to - ``-mean_change`` for mean-preserving roughness. + Deterministic surface-height offset in structural layers. :param float tail_probability: Maximum omitted probability mass in the unrepresented Poisson tail. :param float mean: @@ -37,6 +116,7 @@ class PoissonProfile: def __init__( self, mean_change=None, + alpha=1.0, offset=0.0, tail_probability=DEFAULT_TAIL_PROBABILITY, *, @@ -49,6 +129,9 @@ def __init__( elif mean is not None: raise TypeError("provide mean_change or mean, not both") self.mean_change = float(mean_change) + self.alpha = float(alpha) + if not 0.0 <= self.alpha <= 1.0: + raise ValueError("alpha must be between zero and one") self.offset = float(offset) self.tail_probability = _validate_tail_probability(tail_probability) @@ -59,26 +142,72 @@ def mean(self): @property def rate(self): - """Return the non-negative Poisson rate in structural layers.""" - return abs(self.mean_change) + """Return the Poisson component mean in structural layers.""" + return self.alpha * abs(self.mean_change) + + @property + def step_mean(self): + """Return the unsigned process step mean in structural layers.""" + return (1.0 - self.alpha) * abs(self.mean_change) + + @property + def structural_mean(self): + """Return the signed deterministic cursor mean in structural layers.""" + return self.offset + (1.0 - self.alpha) * self.mean_change @property def expected_height_change(self): """Return the expected surface-height change in structural layers.""" return self.offset + self.mean_change + def _step_parameters(self): + step_sign = -1 if self.structural_mean < 0 else 1 + magnitude = abs(self.structural_mean) + step_floor = int(np.floor(magnitude)) + step_fraction = magnitude - step_floor + step_low = step_sign * step_floor + step_high = step_low + step_sign + return step_low, step_high, step_fraction + + def _height_cdf(self, values): + """Return the CDF of the signed convolved surface-height change.""" + values = np.asarray(values, dtype=np.float64) + step_low, step_high, step_fraction = self._step_parameters() + if self.rate == 0: + lower = (values >= step_low).astype(np.float64) + upper = (values >= step_high).astype(np.float64) + elif self.mean_change > 0: + lower = poisson.cdf(np.floor(values - step_low), self.rate) + upper = poisson.cdf(np.floor(values - step_high), self.rate) + else: + lower = poisson.sf(np.ceil(step_low - values) - 1, self.rate) + upper = poisson.sf(np.ceil(step_high - values) - 1, self.rate) + return (1.0 - step_fraction) * lower + step_fraction * upper + + def probability(self, changes): + """Return probability masses for signed integer height changes.""" + changes = np.asarray(changes, dtype=np.float64) + integer = np.isclose(changes, np.rint(changes)) + changes = np.rint(changes) + probability = self._height_cdf(changes) - self._height_cdf(changes - 1) + return np.where(integer, probability, 0.0) + def support(self): """Return the inclusive structural-layer support offsets.""" + step_low, step_high, step_fraction = self._step_parameters() + if step_fraction == 0.0: + step_high = step_low if self.rate == 0: - process_low = process_high = self.offset + process_low = min(step_low, step_high) + process_high = max(step_low, step_high) else: quantile = int(poisson.ppf(1.0 - self.tail_probability, self.rate)) if self.mean_change > 0: - process_low = self.offset - process_high = self.offset + quantile + process_low = min(step_low, step_high) + process_high = max(step_low, step_high) + quantile else: - process_low = self.offset - quantile - process_high = self.offset + process_low = min(step_low, step_high) - quantile + process_high = max(step_low, step_high) lower = int(np.floor(min(0.0, process_low))) - 1 upper = int(np.ceil(max(0.0, process_high))) return lower, upper @@ -86,19 +215,7 @@ def support(self): def occupancy(self, offsets): """Return material occupancy at structural-layer offsets.""" offsets = np.asarray(offsets, dtype=np.float64) - if self.rate == 0: - return (offsets < self.offset).astype(np.float64) - if self.mean_change > 0: - return poisson.sf(offsets - self.offset, self.rate) - threshold = self.offset - offsets - return poisson.cdf(np.ceil(threshold) - 1, self.rate) - - def correction(self, offsets): - """Return occupancy relative to a sharp surface at zero.""" - offsets = np.asarray(offsets, dtype=np.float64) - sharp = (offsets < 0).astype(np.float64) - return self.occupancy(offsets) - sharp - + return 1.0 - self._height_cdf(offsets) @dataclass(frozen=True) class SkellamProfile: diff --git a/orgui/datautils/xrayutils/CTRfilm.py b/orgui/datautils/xrayutils/CTRfilm.py index 4806cd9..9d80cb4 100644 --- a/orgui/datautils/xrayutils/CTRfilm.py +++ b/orgui/datautils/xrayutils/CTRfilm.py @@ -31,10 +31,17 @@ import numpy as np from .. import util import re +from collections.abc import Mapping # random.seed(45) -from .CTRutil import _ensure_contiguous, next_skip_comment, LinearFitFunctions +from .CTRutil import ( + _ensure_contiguous, + generate_surface_termination_cells, + next_skip_comment, + LinearFitFunctions, +) +from .CTRoptics import combine_profiles from .CTRuc import UnitCell, ctr_accel_enabled from .CTRdistributions import ( @@ -153,16 +160,16 @@ def addWyckoffParameter( absolute_limits=None, **kwargs, ): - """Add a Wyckoff variable parameter on contained unit cells. + """Add an absolute Wyckoff variable parameter on contained unit cells. :param str site_id: Wyckoff site identifier. :param str variable: Wyckoff variable name, for example ``"u"``. :param tuple limits: - Delta fit limits in fractional units. + Absolute variable limits in parent fractional units. :param tuple absolute_limits: - Optional absolute variable limits. + Deprecated alias for ``limits`` retained for compatibility. :param kwargs: For ``EpitaxyInterface``, provide ``unitcell="top"``, ``unitcell="bottom"``, or a list of these names. @@ -186,16 +193,17 @@ def addWyckoffParameters( absolute_limits=None, **kwargs, ): - """Add several Wyckoff variable parameters on contained unit cells. + """Add absolute Wyckoff variable parameters on contained unit cells. :param str site_id: Wyckoff site identifier. :param variables: Iterable of Wyckoff variable names. If ``None``, fit all variables. :param tuple limits: - Delta fit limits in fractional units. + Absolute variable limits in parent fractional units, or a mapping + from variable names to absolute limits. :param absolute_limits: - Optional absolute variable limits. + Deprecated alias for ``limits`` retained for compatibility. :param kwargs: For ``EpitaxyInterface``, provide ``unitcell="top"``, ``unitcell="bottom"``, or a list of these names. @@ -220,7 +228,10 @@ def addWyckoffShift( absolute_limits=None, **kwargs, ): - """Add a representative Wyckoff-site shift on contained unit cells. + """Add a relative symmetry-site shift along a parent direction. + + The parameter is a displacement from the representative symmetry-site + coordinate, not an absolute parent coordinate. :param str site_id: Wyckoff site identifier. @@ -230,7 +241,8 @@ def addWyckoffShift( :param tuple limits: Delta fit limits in parent fractional units. :param tuple absolute_limits: - Optional absolute parent-coordinate limits. + Optional absolute parent-coordinate bounds converted to relative + shift bounds. The fitted value remains a relative displacement. :param kwargs: For ``EpitaxyInterface``, provide ``unitcell="top"``, ``unitcell="bottom"``, or a list of these names. @@ -254,7 +266,10 @@ def addWyckoffShifts( absolute_limits=None, **kwargs, ): - """Add representative Wyckoff-site shifts on contained unit cells. + """Add relative symmetry-site shifts along parent directions. + + Every parameter is a displacement from the representative + symmetry-site coordinate, not an absolute parent coordinate. :param str site_id: Wyckoff site identifier. @@ -263,7 +278,8 @@ def addWyckoffShifts( :param tuple limits: Delta fit limits in parent fractional units. :param absolute_limits: - Optional absolute parent-coordinate limits. + Optional absolute parent-coordinate bounds converted to relative + shift bounds. The fitted values remain relative displacements. :param kwargs: For ``EpitaxyInterface``, provide ``unitcell="top"``, ``unitcell="bottom"``, or a list of these names. @@ -327,7 +343,14 @@ def _set_start_layer(self, start_layer): self._set_layer_order(layer_order, indices) self._basis_created = np.full_like(self.basis, np.nan) - def stack_on(self, below_loc, below_height, below_layer=-1, below_state=None): + def stack_on( + self, + below_loc, + below_height, + below_layer=-1, + below_state=None, + below_component=None, + ): """Generate this object's layer order and height from the object below. :param float below_loc: @@ -336,7 +359,21 @@ def stack_on(self, below_loc, below_height, below_layer=-1, below_state=None): Absolute top height of the object below in Angstrom. :param float below_layer: Top cyclic layer identifier of the object below. + :param LayerState below_state: + Optional pre-built layer state for the object below. If given, + it is used as-is instead of constructing a fresh + ``LayerState(self.layer_cycle, below_layer)``. + :param below_component: + Optional underlying component (for example a :class:`Film`) + passed to this object's ``_bind_underlying_component`` if it + defines one, for subclasses (such as ``PoissonSurface``) that + need direct access to the component below rather than just its + scalar layer metadata. Ignored by subclasses without that + method. """ + bind_underlying = getattr(self, "_bind_underlying_component", None) + if bind_underlying is not None: + bind_underlying(below_component) if below_state is None: below_state = LayerState(self.layer_cycle, below_layer) if self.layer_behavior == "ignore": @@ -381,23 +418,61 @@ def _parse_float_metadata(string, name, default): return default -def _parse_text_metadata(string, name, default=None): - prefix = name.lower() + ":" - for line in string.splitlines(): - normalized = line.strip() - if normalized.lower().startswith(prefix): - return normalized.split(":", 1)[1].strip() - return default - - class EpitaxyInterface(_LayerStackingMixin, LinearFitFunctions): - parameterOrder = "Width/cells Skew/cells" + """A distributed, strain-coupled interface between two lattices. + + The statistical profile controls the upper- and lower-material + occupancies. ``strain_coupling`` controls the out-of-plane lattice + transition: zero leaves both materials on their independent, unstrained + lattices, while one linearly moves every generated position to the fully + strain-coupled field. The strain-coupling-dependent coordinate is + + ``z = z_unstrained + strain_coupling * (z_coupled - z_unstrained)``. + + At zero strain coupling, ``offset`` is a rigid registry shift between the + independent film and bulk lattices: the upper material moves and the lower + material remains fixed. At full strain coupling, the offset is accommodated + by a displacement field proportional to the statistical upper-material + occupancy and shared by both material additions. Intermediate strain + coupling linearly interpolates between those endpoint geometries. + + The semi-infinite lower bulk remains on its unstrained lattice. Generated + lower-material domains therefore add the distributed material at its + strain-coupling-dependent positions and separately subtract the sharp bulk + at its original positions. + + New interfaces default to ``strain_coupling = 1`` and ``offset = 0``. + Text models and HDF5 fit settings written by orGUI v1.5.0 contained only + Width and Skew; loading them supplies those same defaults. + + :param UnitCell uc_top: + Upper (film-side) interface unit cell. + :param UnitCell uc_bottom: + Lower (bulk-side) interface unit cell. + :param str type: + Statistical interface model. Currently only ``"skellam"``. + """ - parameterLookup = {"W": 0, "S": 1} + parameterOrder = ( + "Width/cells Skew/cells StrainCoupling Offset/bulk_frac" + ) + legacyParameterOrder = "Width/cells Skew/cells" + + parameterLookup = { + "W": 0, + "S": 1, + "strain_coupling": 2, + "offset": 3, + } avail_types = ["skellam"] - parameterLookup_inv = dict(map(reversed, parameterLookup.items())) + parameterLookup_inv = { + 0: "W", + 1: "S", + 2: "strain_coupling", + 3: "offset", + } def __init__(self, uc_top, uc_bottom, type="skellam", **kwargs): """ @@ -419,18 +494,15 @@ def __init__(self, uc_top, uc_bottom, type="skellam", **kwargs): raise TypeError("EpitaxyInterface profile must be SkellamProfile") self.type = type self.profile = profile - self._legacy_support_cursor = kwargs.pop( - "legacy_support_cursor", profile is None - ) self.sigma_calc = kwargs.get("sigma_calc", 3) self.fixed_ucs = kwargs.get("fixed_ucs", False) self.set_ucs(uc_top, uc_bottom, **kwargs) if profile is None: - self.basis = np.array([0.0, 0.0]) + self.basis = np.array([0.0, 0.0, 1.0, 0.0]) else: - self.basis = np.array([profile.width, profile.asymmetry]) - self._basis_created = np.array([np.nan, np.nan]) - self.basis_0 = np.array([0.0, 0.0]) + self.basis = np.array([profile.width, profile.asymmetry, 1.0, 0.0]) + self._basis_created = np.full(4, np.nan) + self.basis_0 = np.array([0.0, 0.0, 1.0, 0.0]) self.errors = None if "name" in kwargs: self.name = kwargs["name"] @@ -440,6 +512,7 @@ def __init__(self, uc_top, uc_bottom, type="skellam", **kwargs): self.below_loc = 0.0 self.below_H = 0.0 self.below_layer = -1.0 + self._strain_coupling_displacement = 0.0 self._initialize_layer_stacking(kwargs) def set_ucs(self, uc_top, uc_bottom, **kwargs): @@ -577,26 +650,42 @@ def end_layer_number(self): @property def stacking_height_absolute(self): - """Return the nominal interface boundary height in Angstrom.""" - if self._legacy_support_cursor: - return self.height_absolute - return self.below_H + """Return the physical upper support height in Angstrom. + + Components stacked above this interface start at this height, so the + interface alone owns every layer in its generated support. + """ + return self.height_absolute @property def stacking_loc_absolute(self): - """Return the nominal interface boundary location in Angstrom.""" - if self._legacy_support_cursor: - return self.loc_absolute - return self.below_H + """Return the translated film-side boundary in Angstrom. + + The statistical boundary and lower bulk remain at + :attr:`loc_absolute`. The strain displacement accumulated upward from + the fixed bulk and the explicit film offset translate the film-side + boundary. A Film uses this location as the origin of its requested + total width, while + :attr:`stacking_height_absolute` supplies the physical support it must + not duplicate. + """ + if np.any(self._basis_created != self.basis): + self.createInterfaceCells() + return ( + self.loc_absolute + + self._strain_coupling_displacement + + self._offset_absolute + ) + + @property + def _offset_absolute(self): + """Return the coupling-independent upper-material offset in Angstrom.""" + return self.basis[3] * self.uc_bottom.a[2] @property def layer_state(self): - """Return the nominal layer state at the upper side of the interface.""" - if self._legacy_support_cursor: - return super().layer_state - layers = self.layer_cycle.layers - start = layers.index(self.start_layer_number) - return LayerState(self.layer_cycle, layers[start - 1]) + """Return the terminal layer state of the physical upper support.""" + return super().layer_state def createInterfaceCells(self): n_layers = len(self.uc_top.layers) @@ -634,14 +723,32 @@ def createInterfaceCells(self): (-1, n_layers) ) probability_bottom = 1.0 - probability_top - occupancy_top = probability_top - occupancy_bottom = probability_bottom - if not self._legacy_support_cursor: - sharp_top = ( - (unitcells >= loc).astype(np.float64).reshape((-1, n_layers)) + sharp_top = ( + (unitcells >= loc).astype(np.float64).reshape((-1, n_layers)) + ) + sharp_bottom = 1.0 - sharp_top + strain_coupling = self.basis[2] + if ( + not np.isfinite(strain_coupling) + or not 0.0 <= strain_coupling <= 1.0 + ): + raise ValueError( + "EpitaxyInterface strain_coupling must be between 0 and 1" ) - occupancy_top = probability_top - sharp_top - occupancy_bottom = probability_bottom - (1.0 - sharp_top) + offset = self.basis[3] + if not np.isfinite(offset): + raise ValueError("EpitaxyInterface offset must be finite") + + # The upper material owns the complete generated support because + # Film starts only above ``stacking_height_absolute``. The lower + # material is represented by an addition at its potentially + # strained position and a separate subtraction at the unstrained + # position already occupied by the semi-infinite bulk. + occupancy_top = probability_top + occupancy_bottom = np.concatenate( + (probability_bottom, -sharp_bottom), + axis=0, + ) a3_top = self.top_layers[0].a[2] a3_bottom = self.bottom_layers[0].a[2] @@ -658,9 +765,15 @@ def createInterfaceCells(self): ratio_top = a3_bottom / a3_top ratio_bottom = 1 / ratio_top - h = 0.0 - - for p_t, p_b in zip(probability_top, probability_bottom): + coupled_top = [[] for _ in self.top_layers] + coupled_bottom = [[] for _ in self.bottom_layers] + ideal_top = [[] for _ in self.top_layers] + ideal_bottom = [[] for _ in self.bottom_layers] + coupled_height = 0.0 + + for cycle_index, (p_t, p_b) in enumerate( + zip(probability_top, probability_bottom) + ): top_strains = p_t + ratio_top * p_b bottom_strains = ratio_bottom * p_t + p_b physical_top_index = np.flatnonzero( @@ -679,9 +792,11 @@ def createInterfaceCells(self): relative_layer_position - (self.uc_top.layerpos[self.layer_order[i]]) ) - mat_top_i[2, 3] = h / a3_top + top_strain_and_h * top_layer_offset - - uc_t.coherentDomainMatrix.append(mat_top_i) + mat_top_i[2, 3] = ( + coupled_height / a3_top + + top_strain_and_h * top_layer_offset + ) + coupled_top[i].append(mat_top_i) mat_bottom_i = np.copy(mat_0) bottom_strain_and_h = bottom_strains[i] @@ -691,32 +806,134 @@ def createInterfaceCells(self): - (self.uc_bottom.layerpos[self.layer_order[i]]) ) mat_bottom_i[2, 3] = ( - h / a3_bottom + bottom_strain_and_h * bottom_layer_offset + coupled_height / a3_bottom + + bottom_strain_and_h * bottom_layer_offset ) - # h_bottom += bottom_strain_and_h - uc_b.coherentDomainMatrix.append(mat_bottom_i) - h += cell_height - # h_bottom += bottom_strain_and_h - # h_top += top_strain_and_h + coupled_bottom[i].append(mat_bottom_i) + + ideal_top_i = np.copy(mat_0) + ideal_top_i[2, 3] = cycle_index + top_layer_offset + ideal_top[i].append(ideal_top_i) + + ideal_bottom_i = np.copy(mat_0) + ideal_bottom_i[2, 3] = cycle_index + bottom_layer_offset + ideal_bottom[i].append(ideal_bottom_i) + coupled_height += cell_height loc_rescaled = loc - unitcells[0] uc_no_loc = int(np.floor(loc_rescaled)) // n_layers layer_no_loc = int(np.floor(loc_rescaled)) % n_layers loc_remainder = (loc_rescaled % n_layers) % 1 - loc_mat = self.top_layers[layer_no_loc].coherentDomainMatrix[uc_no_loc] - self._loc_absolute_ref = ( - loc_mat[2, 3] * a3_top + loc_remainder * loc_mat[2, 2] * a3_top - ) - if self._legacy_support_cursor: - translation = self.below_H - self._loc_absolute = self._loc_absolute_ref + self.below_H + ideal_top_loc_mat = ideal_top[layer_no_loc][uc_no_loc] + ideal_top_loc = ( + ideal_top_loc_mat[2, 3] + + loc_remainder * ideal_top_loc_mat[2, 2] + ) * a3_top + ideal_bottom_loc_mat = ideal_bottom[layer_no_loc][uc_no_loc] + ideal_bottom_loc = ( + ideal_bottom_loc_mat[2, 3] + + loc_remainder * ideal_bottom_loc_mat[2, 2] + ) * a3_bottom + + offset_absolute = self._offset_absolute + occupancy_low = np.min(probability_top) + occupancy_span = np.max(probability_top) - occupancy_low + if occupancy_span == 0.0: + if offset_absolute != 0.0: + raise ValueError( + "EpitaxyInterface offset requires an occupancy profile " + "that spans the film-bulk transition" + ) + offset_profile = np.zeros_like(probability_top) else: - translation = self.below_H - self._loc_absolute_ref - self._loc_absolute = self.below_H - _translate_domains(self.top_layers, translation) - _translate_domains(self.bottom_layers, translation) + # Normalize the represented occupancy profile so truncating + # its statistical tails does not leave a displacement jump at + # either stacking boundary. + offset_profile = ( + offset_absolute + * (probability_top - occupancy_low) + / occupancy_span + ) + # The lower support boundary is the physical anchor shared by the + # strain-coupled field and the unstrained semi-infinite bulk. + # Translate + # both using the ideal lower-lattice boundary location. The + # coupled statistical boundary is then free to move by the + # displacement accumulated through the strain field. The + # displacement at the upper support boundary is passed to every + # subsequently stacked component. + upper_layer_id = self.layer_order[-1] + upper_layer_space = np.diff( + self.layerpos, + append=self.layerpos[0] + 1, + )[-1] + upper_boundary = ( + self.uc_top.layerpos[upper_layer_id] + upper_layer_space + ) + coupled_upper_mat = coupled_top[-1][-1] + ideal_upper_mat = ideal_top[-1][-1] + coupled_upper = ( + coupled_upper_mat[2, 3] + - ideal_bottom_loc / a3_top + + coupled_upper_mat[2, 2] * upper_boundary + ) * a3_top + ideal_upper = ( + ideal_upper_mat[2, 3] + - ideal_top_loc / a3_top + + ideal_upper_mat[2, 2] * upper_boundary + ) * a3_top + self._strain_coupling_displacement = strain_coupling * ( + coupled_upper - ideal_upper + ) + for i, (uc_t, uc_b) in enumerate(zip(self.top_layers, self.bottom_layers)): + for coupled_mat, ideal_mat, profile_offset in zip( + coupled_top[i], + ideal_top[i], + offset_profile[:, i], + ): + coupled_mat = np.copy(coupled_mat) + ideal_mat = np.copy(ideal_mat) + coupled_mat[2, 3] -= ideal_bottom_loc / a3_top + ideal_mat[2, 3] -= ideal_top_loc / a3_top + interpolated = ideal_mat + strain_coupling * ( + coupled_mat - ideal_mat + ) + top_offset = ( + (1.0 - strain_coupling) * offset_absolute + + strain_coupling * profile_offset + ) + interpolated[2, 3] += top_offset / a3_top + uc_t.coherentDomainMatrix.append(interpolated) + + for coupled_mat, ideal_mat, profile_offset in zip( + coupled_bottom[i], + ideal_bottom[i], + offset_profile[:, i], + ): + coupled_mat = np.copy(coupled_mat) + ideal_mat = np.copy(ideal_mat) + coupled_mat[2, 3] -= ideal_bottom_loc / a3_bottom + ideal_mat[2, 3] -= ideal_bottom_loc / a3_bottom + interpolated = ideal_mat + strain_coupling * ( + coupled_mat - ideal_mat + ) + interpolated[2, 3] += ( + strain_coupling * profile_offset / a3_bottom + ) + uc_b.coherentDomainMatrix.append(interpolated) + + # These domains remove the fixed semi-infinite bulk at the + # same unstrained coordinates used by UnitCell.F_bulk. + for ideal_mat in ideal_bottom[i]: + fixed_bulk_mat = np.copy(ideal_mat) + fixed_bulk_mat[2, 3] -= ideal_bottom_loc / a3_bottom + uc_b.coherentDomainMatrix.append(fixed_bulk_mat) + + self._loc_absolute_ref = 0.0 + self._loc_absolute = self.below_H + _translate_domains(self.top_layers, self.below_H) + _translate_domains(self.bottom_layers, self.below_H) self._end_layer_number = self.layer_order[-1] - # self._loc_absolute = self._loc_absolute_ref + self.below_H self._basis_created = np.copy(self.basis) else: @@ -759,6 +976,20 @@ def zDensity_G(self, z, h, k): rho += uc_b.zDensity_G(z, h, k) return rho + def optical_profile(self): + """Return the combined homogeneous optical profile of the interface. + + :returns: + C-contiguous ``(N, 3)`` array with columns ``z``, ``delta``, and + ``beta``. + :rtype: numpy.ndarray + """ + if np.any(self._basis_created != self.basis): + self.createInterfaceCells() + profiles = [uc.optical_profile() for uc in self.top_layers] + profiles.extend(uc.optical_profile() for uc in self.bottom_layers) + return combine_profiles(*profiles) + def addFitParameter(self, indexarray, limits=(-np.inf, np.inf), **kwarg): """to assign multiple unitcells with the same fitparameter, provide list of unitcell names as kwarg `unitcell` @@ -882,9 +1113,31 @@ def clearParameters(self): self.uc_bottom.clearParameters() def parametersFromDict(self, d, override_values=True): + """Restore fit settings, including v1.5.0 HDF5 companions. + + Two-element legacy baselines are expanded from ``[Width, Skew]`` to + ``[Width, Skew, 1, 0]`` before applying the stored fit parameters. + + :param dict d: + Interface fit settings decoded from the companion HDF5 file. + :param bool override_values: + Apply stored fit-parameter values to the interface basis. + """ self.uc_top.parametersFromDict(d["unitcells"]["top"], override_values) self.uc_bottom.parametersFromDict(d["unitcells"]["bottom"], override_values) - super().parametersFromDict(d, override_values) + interface_parameters = dict(d) + basis_0 = np.asarray(interface_parameters["basis_0"], dtype=np.float64) + if basis_0.size == 2: + # orGUI v1.5.0 HDF5 companions stored only Width and Skew. + # Preserve their fully strain-coupled, zero-offset behavior. + basis_0 = np.concatenate((basis_0, [1.0, 0.0])) + elif basis_0.size != 4: + raise ValueError( + "EpitaxyInterface fit settings require either the v1.5.0 " + "Width/Skew basis or the current four-parameter basis" + ) + interface_parameters["basis_0"] = basis_0 + super().parametersFromDict(interface_parameters, override_values) def updateFromParameters(self): """Update basis from the values stored in the Parameters""" @@ -925,6 +1178,18 @@ def parameter_list(self): @classmethod def fromStr(cls, string): + """Deserialize a current or v1.5.0 text interface. + + The v1.5.0 two-parameter representation is upgraded to a fully + strain-coupled, zero-offset interface. + + :param str string: + Serialized EpitaxyInterface section from an ``.xtal`` or ``.xpr`` + model. + :returns: + Reconstructed interface. + :rtype: EpitaxyInterface + """ with util.StringIO(string) as f: # parse header line = next_skip_comment(f).split() @@ -941,9 +1206,12 @@ def fromStr(cls, string): ep_type = line[1].lower() statistics = dict() + parameter_header = None line = next_skip_comment(f) while "Width" in line or "=" in line: # parameter header or statistics line - if "=" in line: + if "Width" in line: + parameter_header = line.strip() + elif "=" in line: try: splitted = [n.split(",") for n in line.split("=")] splitted = [item for sublist in splitted for item in sublist] @@ -952,6 +1220,15 @@ def fromStr(cls, string): except Exception: print(f"Cannot read statistics string: {line}") line = next_skip_comment(f) + if parameter_header not in ( + cls.parameterOrder, + cls.legacyParameterOrder, + ): + raise ValueError( + "EpitaxyInterface parameter header must be either " + f"'{cls.parameterOrder}' or the v1.5.0 header " + f"'{cls.legacyParameterOrder}'" + ) # epitaxy parameters sline = line.split() if "+-" in sline: @@ -964,6 +1241,18 @@ def fromStr(cls, string): else: basis = np.array(sline, dtype=np.float64) errors = None + expected_size = ( + 2 if parameter_header == cls.legacyParameterOrder else 4 + ) + if basis.size != expected_size: + raise ValueError( + f"EpitaxyInterface header '{parameter_header}' requires " + f"{expected_size} parameter values" + ) + if expected_size == 2: + basis = np.concatenate((basis, [1.0, 0.0])) + if errors is not None: + errors = np.concatenate((errors, [np.nan, np.nan])) # very explicit searching for the lines containing TopUnitCell and BottomUnitCell: # noqa: E501 sp_str = string.splitlines() @@ -1005,20 +1294,16 @@ def fromStr(cls, string): uc_top.name = top_name uc_bottom.name = bottom_name - support_cursor = _parse_text_metadata(string, "support_cursor", "legacy") tail_probability = _parse_float_metadata( string, "tail_probability", DEFAULT_TAIL_PROBABILITY ) - profile = None - if support_cursor == "nominal": - profile = SkellamProfile(basis[0], basis[1], tail_probability) + profile = SkellamProfile(basis[0], basis[1], tail_probability) epit = cls( uc_top, uc_bottom, ep_type, profile=profile, layer_transition=_parse_layer_transition(string), - legacy_support_cursor=(support_cursor != "nominal"), ) epit.statistics = statistics epit.basis = basis @@ -1042,13 +1327,11 @@ def toStr(self, showErrors=True): + "\n" + self.epitToStr(showErrors=showErrors) ) - if not self._legacy_support_cursor: - s += "\nsupport_cursor: nominal" - if ( - self.profile is not None - and self.profile.tail_probability != DEFAULT_TAIL_PROBABILITY - ): - s += f"\ntail_probability: {self.profile.tail_probability:.12g}" + if ( + self.profile is not None + and self.profile.tail_probability != DEFAULT_TAIL_PROBABILITY + ): + s += f"\ntail_probability: {self.profile.tail_probability:.12g}" metadata = self._stacking_metadata_to_str() if metadata: s += "\n" + metadata.rstrip() @@ -1158,6 +1441,15 @@ def loc_absolute(self): return self.pos_absolute def set_below(self, loc, height): + """Place Film above support while retaining its nominal width origin. + + :param float loc: + Nominal lower boundary in Angstrom, used as the origin of + :attr:`basis` width. + :param float height: + Physical upper support height in Angstrom. Generated unstrained + Film layers begin here. + """ self.below_loc = loc self.below_H = height self.createLayers() @@ -1166,6 +1458,8 @@ def set_below(self, loc, height): def height_absolute(self): if np.any(self._basis_created != self.basis): self.createLayers() + if self._layers_to_create == 0: + return self.below_H upper_layer_id = self.end_layer_number upper_layer = self.uc_layers[upper_layer_id] idx = self.layer_ucs.index(upper_layer) @@ -1180,6 +1474,8 @@ def height_absolute(self): def pos_absolute(self): if np.any(self._basis_created != self.basis): self.createLayers() + if self._layers_to_create == 0: + return self.below_H lower_layer = self.layer_ucs[0] matrix = lower_layer.coherentDomainMatrix[0] layer_id = self.layer_order[0] @@ -1203,17 +1499,35 @@ def end_layer_number(self): return self._end_layer_number def createLayers(self): + """Create unstrained Film layers above lower-component support. + + The Film width in :attr:`basis` is measured from ``below_loc``. When + the component below has already generated support up to ``below_H``, + only the remaining width is represented by Film layers. + """ n_layers_in_uc = len(self.unitcell.layers) scaled_width = self.basis[0] - n_layers_in_uc * ( (self.below_H - self.below_loc) / self.unitcell.a[2] ) + if scaled_width < 0 and not np.isclose(scaled_width, 0.0): + raise ValueError( + "Effective film width is shorter than lower-component support" + ) layers_to_create = int(round(scaled_width, 0)) - if layers_to_create <= 0: - raise ValueError("Effective film width <= 0. Cannot create layers") + if layers_to_create < 0: + raise ValueError( + "Effective film width is shorter than lower-component support" + ) for i, uc in enumerate(self.layer_ucs): uc.coherentDomainMatrix = [] uc.coherentDomainOccupancy = [] + self._layers_to_create = layers_to_create + if layers_to_create == 0: + self._end_layer_number = self.layer_order[-1] + self._basis_created = np.copy(self.basis) + return + mat_0 = np.vstack((np.identity(3).T, np.array([0, 0, 0]))).T strain = self.unitcell.coherentDomainMatrix[0][2, 2] occup = self.unitcell.coherentDomainOccupancy[0] @@ -1274,6 +1588,18 @@ def zDensity_G(self, z, h, k): rho += uc.zDensity_G(z, h, k) return rho + def optical_profile(self): + """Return the combined homogeneous optical profile of the Film. + + :returns: + C-contiguous ``(N, 3)`` array with columns ``z``, ``delta``, and + ``beta``. + :rtype: numpy.ndarray + """ + if np.any(self._basis_created != self.basis): + self.createLayers() + return combine_profiles(*(uc.optical_profile() for uc in self.layer_ucs)) + def addFitParameter(self, indexarray, limits=(-np.inf, np.inf), **kwarg): """to assign multiple unitcells with the same fitparameter, provide list of unitcell names as kwarg `unitcell` @@ -1408,28 +1734,18 @@ def fromStr(cls, string): basis = np.array(sline, dtype=np.float64) errors = None - # very explicit searching for the lines containing TopUnitCell and BottomUnitCell: # noqa: E501 sp_str = string.splitlines() uc_pos = -1 - for i, l in enumerate(sp_str): # noqa: E741 - if uc_pos == -1: - if "UnitCell" in l: - uc_pos = i # found it, and save line number - if uc_pos != -1: + for i, line in enumerate(sp_str): + if "UnitCell" in line: + uc_pos = i break - else: - msg = "Cannot create Film. " - if uc_pos < 0: - msg += "No UnitCell provided. " - raise ValueError(msg) + if uc_pos < 0: + raise ValueError("Cannot create Film. No UnitCell provided.") uc_classname, uc_name = sp_str[uc_pos].split(maxsplit=1) - assert uc_classname == "UnitCell" - uc_str = "\n".join(sp_str[uc_pos + 1 :]) - - uc = UnitCell.fromStr(uc_str) - + uc = UnitCell.fromStr("\n".join(sp_str[uc_pos + 1 :])) uc.name = uc_name film = cls(uc, layer_transition=_parse_layer_transition(string)) @@ -1477,22 +1793,20 @@ def filmToStr(self, showErrors=True): class PoissonSurface(_LayerStackingMixin, LinearFitFunctions): - """Signed Poisson growth or etching at a nominal Film boundary. - - For the current API, ``W`` is the signed Poisson mean in structural layers - and ``offset`` is a deterministic height offset. Positive ``W`` models - growth, negative ``W`` models dissolution or etching, and - ``offset = -W`` preserves the original mean Film height. + """Signed step--Poisson growth or etching at a Film boundary. - Legacy files using ``Width/layers deltaW/layers`` remain readable with - their historical absolute-width semantics. + ``W`` is the signed process mean in structural layers. ``alpha`` assigns + a fraction of its magnitude to Poisson roughening and the remainder to + ideal layer-by-layer progression. ``offset`` is an independent, + deterministic structural height shift. Positive ``W`` models growth and + negative ``W`` models dissolution or etching. """ - parameterOrder = "W/layers offset/layers" + parameterOrder = "W/layers alpha offset/layers" - parameterLookup = {"W": 0, "offset": 1, "deltaW": 1} + parameterLookup = {"W": 0, "alpha": 1, "offset": 2} - parameterLookup_inv = {0: "W", 1: "offset"} + parameterLookup_inv = {0: "W", 1: "alpha", 2: "offset"} def __init__(self, unitcell, **kwargs): super().__init__() @@ -1500,17 +1814,16 @@ def __init__(self, unitcell, **kwargs): if profile is not None and not isinstance(profile, PoissonProfile): raise TypeError("PoissonSurface profile must be PoissonProfile") self.profile = profile - self._legacy_absolute_width = kwargs.pop( - "legacy_absolute_width", profile is None - ) self.type = type self.set_ucs(unitcell, **kwargs) if profile is None: - self.basis = np.array([0.0, 0.0]) + self.basis = np.array([0.0, 1.0, 0.0]) else: - self.basis = np.array([profile.mean_change, profile.offset]) - self._basis_created = np.array([np.nan, np.nan]) - self.basis_0 = np.array([0.0, 0.0]) + self.basis = np.array( + [profile.mean_change, profile.alpha, profile.offset] + ) + self._basis_created = np.array([np.nan, np.nan, np.nan]) + self.basis_0 = np.array([0.0, 0.0, 0.0]) self.errors = None if "name" in kwargs: self.name = kwargs["name"] @@ -1520,17 +1833,41 @@ def __init__(self, unitcell, **kwargs): self.below_loc = 0.0 self.below_H = 0.0 self.below_layer = -1.0 + self.underlying_film = None + self._film_layer_ucs_base = [] + self.film_layer_ucs = [] self._initialize_layer_stacking(kwargs) def set_ucs(self, unitcell, **kwargs): - self.unitcell = unitcell - self.uc_layers = self.unitcell.split_in_layers() - self._layer_ids = np.array(list(self.uc_layers)) - self._layer_ucs_base = [self.uc_layers[uc] for uc in self._layer_ids] - self._layerpos_base = np.array( - [self.unitcell.layerpos[i] for i in self._layer_ids] - ) - self.layer_ucs = list(self._layer_ucs_base) + """Set a legacy source cell or explicit termination-cell mapping.""" + self._source_unitcell = None + self.termination_cells = {} + if isinstance(unitcell, Mapping): + if not unitcell: + raise ValueError("PoissonSurface termination mapping cannot be empty") + self.termination_cells = { + float(layer): cell for layer, cell in unitcell.items() + } + self.unitcell = next(iter(self.termination_cells.values())) + self._layer_ids = np.asarray(list(self.termination_cells)) + else: + self._source_unitcell = unitcell + self.unitcell = unitcell + self._layer_ids = np.asarray(unitcell.layer_cycle.layers) + + self._layer_ucs_base = [] + self.layer_ucs = [] + if self.termination_cells: + self._layerpos_base = np.asarray( + [ + self.termination_cells[float(layer)].layerpos[float(layer)] + for layer in self._layer_ids + ] + ) + else: + self._layerpos_base = np.asarray( + [self.unitcell.layerpos[layer] for layer in self._layer_ids] + ) self.layerpos = np.copy(self._layerpos_base) @property @@ -1546,8 +1883,126 @@ def uc_area(self): def _set_layer_order(self, layer_order, order): self.layer_order = np.asarray(layer_order) self._layer_order_indices = np.asarray(order) - self.layer_ucs = [self._layer_ucs_base[i] for i in order] - self.layerpos = _unwrapped_layer_positions(self._layerpos_base, order) + if hasattr(self, "_termination_views"): + self.layer_ucs = [self._termination_views[float(i)] for i in layer_order] + elif self.termination_cells: + self.layer_ucs = [self.termination_cells[float(i)] for i in layer_order] + else: + self.layer_ucs = [] + if self._film_layer_ucs_base: + self.film_layer_ucs = [self._film_layer_ucs_base[i] for i in order] + if self.underlying_film is not None: + self.layerpos = _unwrapped_layer_positions(self._layerpos_base, order) + + def _owned_unitcells(self): + if self._source_unitcell is not None: + return [self._source_unitcell] + return list(self.termination_cells.values()) + + def _refresh_legacy_terminations(self): + if self._source_unitcell is None or self.underlying_film is None: + return + underlying_film = self.underlying_film + self.underlying_film = None + self._bind_underlying_component(underlying_film) + + def _wyckoff_target_unitcells(self, kwargs): + target = kwargs.pop("unitcell", None) + if target is None: + return [self.unitcell] + if isinstance(target, list | tuple): + return [self[item] for item in target] + return [self[target]] + + def _bind_underlying_component(self, component): + """Bind the Film whose exposed structural layers are replaced.""" + if not isinstance(component, Film): + raise ValueError( + "PoissonSurface must be stacked immediately above a Film" + ) + if self.underlying_film is component and hasattr( + self, "_film_termination_ucs" + ): + return + film_uc = component.unitcell + film_layers = film_uc.split_in_layers() + film_layer_ids = np.asarray(list(film_layers)) + + if self._source_unitcell is not None: + source = self._source_unitcell + self.termination_cells = generate_surface_termination_cells( + source, + film_layer_ids, + ) + if set(self.termination_cells) != set(film_layer_ids): + raise ValueError( + "PoissonSurface requires exactly one surface unit cell for " + "each layer in the underlying Film cycle" + ) + + self._layer_ids = film_layer_ids + self._layerpos_base = np.asarray( + [film_uc.layerpos[layer] for layer in film_layer_ids] + ) + self._layer_ucs_base = [ + self.termination_cells[float(layer)] for layer in film_layer_ids + ] + self._termination_views = {} + for layer, cell in self.termination_cells.items(): + view = cell.split_in_layers()[float(layer)] + view.name = cell.name + view.layerpos = dict(cell.layerpos) + view.layer_behavior = "select" + view._start_layer = float(layer) + self._termination_views[layer] = view + self._termination_domain_strain = { + id(self._termination_views[layer]): cell.coherentDomainMatrix[0][2, 2] + for layer, cell in self.termination_cells.items() + } + self._termination_domain_occupancy = { + id(self._termination_views[layer]): cell.coherentDomainOccupancy[0] + for layer, cell in self.termination_cells.items() + } + self._film_termination_ucs = {} + for layer, surface_uc in self.termination_cells.items(): + atom_layers = set(np.asarray(surface_uc.basis[:, 7], dtype=float)) + if atom_layers != {float(layer)}: + raise ValueError( + "Every atom in a surface termination cell must have its " + "termination key as the layer identifier; use " + "UnitCell.as_surface_termination" + ) + if surface_uc.layer_behavior != "select": + raise ValueError( + "Surface termination unit cells must use " + "layer_behavior='select'" + ) + if not np.allclose(surface_uc.a[:2], film_uc.a[:2]) or not np.allclose( + surface_uc.alpha, film_uc.alpha + ): + raise ValueError( + "PoissonSurface and underlying Film must have matching " + "lateral lattices and lattice angles" + ) + repeats_z = surface_uc.a[2] / film_uc.a[2] + if not np.isclose(repeats_z, np.rint(repeats_z)) or repeats_z < 1: + raise ValueError( + "Each surface termination c axis must be a positive integer " + "multiple of the underlying Film c axis" + ) + film_slab = film_uc.supercell((1, 1, int(np.rint(repeats_z)))) + film_terminations = generate_surface_termination_cells( + film_slab, + film_layer_ids, + ) + reference_uc = film_terminations[layer] + reference_uc.name = f"{film_uc.name}_reference_termination_{layer:g}" + self._film_termination_ucs[layer] = reference_uc + + self.underlying_film = component + self._film_layer_ucs_base = [film_layers[layer] for layer in film_layer_ids] + self._set_layer_order(self.layer_order, self._layer_order_indices) + self._basis_created = np.full_like(self.basis, np.nan) def setReferenceUnitCell(self, uc, rotMatrix=np.identity(3)): """Set the reference frame on the surface and generated layers. @@ -1558,9 +2013,12 @@ def setReferenceUnitCell(self, uc, rotMatrix=np.identity(3)): Optional 3-by-3 rotation from the reference frame into the surface crystal frame. """ - self.unitcell.setReferenceUnitCell(uc, rotMatrix) - for layer in self._layer_ucs_base: - layer.setReferenceUnitCell(uc, rotMatrix) + for cell in self._owned_unitcells(): + cell.setReferenceUnitCell(uc, rotMatrix) + for cell in getattr(self, "_termination_views", {}).values(): + cell.setReferenceUnitCell(uc, rotMatrix) + for cell in getattr(self, "_film_termination_ucs", {}).values(): + cell.setReferenceUnitCell(uc, rotMatrix) def setEnergy(self, E): """Set X-ray energy for the surface and generated layers. @@ -1569,9 +2027,12 @@ def setEnergy(self, E): X-ray energy in eV. """ self.E = E - self.unitcell.setEnergy(E) - for layer in self._layer_ucs_base: - layer.setEnergy(E) + for cell in self._owned_unitcells(): + cell.setEnergy(E) + for cell in getattr(self, "_termination_views", {}).values(): + cell.setEnergy(E) + for cell in getattr(self, "_film_termination_ucs", {}).values(): + cell.setEnergy(E) @property def loc_absolute(self): @@ -1586,27 +2047,13 @@ def set_below(self, loc, height): def height_absolute(self): if np.any(self._basis_created != self.basis): self.createLayers() - upper_layer_id = self.end_layer_number - upper_layer = self.uc_layers[upper_layer_id] - idx = self.layer_ucs.index(upper_layer) - pos = upper_layer.coherentDomainMatrix[-1][2, 3] * upper_layer.a[2] - strain = upper_layer.coherentDomainMatrix[-1][2, 2] - layerpos = self.unitcell.layerpos[upper_layer_id] - layer_space = np.diff(self.layerpos, append=self.layerpos[0] + 1) - H = pos + strain * (layerpos + layer_space[idx]) * upper_layer.a[2] - return H + return self._height_absolute @property def pos_absolute(self): if np.any(self._basis_created != self.basis): self.createLayers() - lower_layer = self.layer_ucs[0] - matrix = lower_layer.coherentDomainMatrix[0] - layer_id = self.layer_order[0] - H = ( - matrix[2, 3] + matrix[2, 2] * self.unitcell.layerpos[layer_id] - ) * lower_layer.a[2] - return H + return self.below_H @pos_absolute.setter def pos_absolute(self, pos): @@ -1614,6 +2061,8 @@ def pos_absolute(self, pos): self.createLayers() delta_height = pos - self.pos_absolute _move_domains(self.layer_ucs, delta_height) + _move_domains(self.film_layer_ucs, delta_height) + _move_domains(list(self._film_termination_ucs.values()), delta_height) self.below_H = pos @property @@ -1625,8 +2074,6 @@ def end_layer_number(self): @property def stacking_height_absolute(self): """Return the expected surface height in Angstrom.""" - if self._legacy_absolute_width: - return self.height_absolute return self.mean_height_absolute @property @@ -1637,89 +2084,148 @@ def stacking_loc_absolute(self): @property def mean_height_absolute(self): """Return the expected surface height in Angstrom.""" - layer_height = self.unitcell.a[2] / len(self.unitcell.layers) - return self.below_H + layer_height * (self.basis[0] + self.basis[1]) + film_uc = ( + self.underlying_film.unitcell + if self.underlying_film + else self.unitcell + ) + layer_height = film_uc.a[2] / len(self._layer_ids) + return self.below_H + layer_height * (self.basis[0] + self.basis[2]) def createLayers(self): - """Create layer domains and their Poisson surface occupancies.""" - n_layers_in_uc = len(self.unitcell.layers) + """Create co-located surface and covered-Film layer domains.""" + if self.underlying_film is None: + raise ValueError( + "PoissonSurface must be stacked immediately above a Film " + "before layers can be created" + ) + n_layers_in_uc = len(self._layer_ids) tail_probability = ( self.profile.tail_probability if self.profile is not None else DEFAULT_TAIL_PROBABILITY ) - if self._legacy_absolute_width: - delta_width = self.basis[1] - if delta_width < 0: - raise ValueError("legacy deltaW must be greater than or equal to zero") - profile = PoissonProfile( - mean_change=delta_width, - tail_probability=tail_probability, - ) - mean_width = self.basis[0] - n_layers_in_uc * ( - (self.below_H - self.below_loc) / self.unitcell.a[2] - ) - scaled_width = mean_width + delta_width - layers_to_create = int(round(scaled_width, 0)) - if layers_to_create <= 0: - raise ValueError("Effective film width <= 0. Cannot create layers") - layer_numbers = np.arange(layers_to_create) - if delta_width == 0: - layer_occupancy = np.ones(layers_to_create) - else: - minimum_width = mean_width - delta_width - layer_occupancy = profile.occupancy(layer_numbers - minimum_width) - else: - profile = PoissonProfile( - mean_change=self.basis[0], - offset=self.basis[1], - tail_probability=tail_probability, - ) - support_low, support_high = profile.support() - layer_numbers = np.arange(support_low, support_high + 1) - layer_occupancy = profile.correction(layer_numbers) - represented = np.abs(layer_occupancy) > tail_probability - layer_numbers = layer_numbers[represented] - layer_occupancy = layer_occupancy[represented] - layers_to_create = len(layer_numbers) - for i, uc in enumerate(self.layer_ucs): + profile = PoissonProfile( + mean_change=self.basis[0], + alpha=self.basis[1], + offset=self.basis[2], + tail_probability=tail_probability, + ) + support_low, support_high = profile.support() + layer_numbers = np.arange(support_low, support_high + 1) + material_occupancy = profile.occupancy(layer_numbers) + represented_material = np.flatnonzero( + material_occupancy > tail_probability + ) + if represented_material.size == 0: + raise ValueError("Poisson surface profile has no represented material") + top_stop = represented_material[-1] + 1 + layer_numbers = layer_numbers[:top_stop] + material_occupancy = material_occupancy[:top_stop] + surface_occupancy = profile.surface_occupancy(layer_numbers) + sharp_film_occupancy = (layer_numbers < 0).astype(np.float64) + film_correction_occupancy = material_occupancy - sharp_film_occupancy + represented = (surface_occupancy > tail_probability) | ( + np.abs(film_correction_occupancy) > tail_probability + ) + layer_numbers = layer_numbers[represented] + surface_occupancy = surface_occupancy[represented] + film_correction_occupancy = film_correction_occupancy[represented] + layers_to_create = len(layer_numbers) + for uc in self.layer_ucs: + uc.coherentDomainMatrix = [] + uc.coherentDomainOccupancy = [] + for uc in self.film_layer_ucs: + uc.coherentDomainMatrix = [] + uc.coherentDomainOccupancy = [] + for uc in self._film_termination_ucs.values(): uc.coherentDomainMatrix = [] uc.coherentDomainOccupancy = [] mat_0 = np.vstack((np.identity(3).T, np.array([0, 0, 0]))).T - strain = self.unitcell.coherentDomainMatrix[0][2, 2] - occup = self.unitcell.coherentDomainOccupancy[0] - - layer_occupancy *= occup + film_domain_occupancy = ( + self.underlying_film.unitcell.coherentDomainOccupancy[0] + ) for layer_index, layer_number in enumerate(layer_numbers): order_index = layer_number % n_layers_in_uc cycle_index = layer_number // n_layers_in_uc uc = self.layer_ucs[order_index] + film_uc = self.film_layer_ucs[order_index] + reference_uc = self._film_termination_ucs[ + float(self.layer_order[order_index]) + ] mat_i = np.copy(mat_0) layer_id = self.layer_order[order_index] relative_layer_position = ( cycle_index + self.layerpos[order_index] - self.layerpos[0] ) - layer_offset = relative_layer_position - self.unitcell.layerpos[layer_id] + layer_offset = ( + relative_layer_position + - self.underlying_film.unitcell.layerpos[layer_id] + ) - mat_i[2, 2] = strain - mat_i[2, 3] = layer_offset * strain + film_strain = self.underlying_film.unitcell.coherentDomainMatrix[0][2, 2] + mat_i[2, 2] = film_strain + mat_i[2, 3] = layer_offset * film_strain - uc.coherentDomainMatrix.append(mat_i) - uc.coherentDomainOccupancy.append(layer_occupancy[layer_index]) + film_uc.coherentDomainMatrix.append(np.copy(mat_i)) + film_uc.coherentDomainOccupancy.append( + film_domain_occupancy * film_correction_occupancy[layer_index] + ) + + terrace_height = ( + relative_layer_position * self.underlying_film.unitcell.a[2] + ) + surface_matrix = np.copy(mat_0) + surface_strain = self._termination_domain_strain[id(uc)] + surface_origin = uc.layerpos[float(layer_id)] + surface_matrix[2, 2] = surface_strain + surface_matrix[2, 3] = ( + terrace_height / uc.a[2] - surface_strain * surface_origin + ) + uc.coherentDomainMatrix.append(surface_matrix) + uc.coherentDomainOccupancy.append( + self._termination_domain_occupancy[id(uc)] + * surface_occupancy[layer_index] + ) + + reference_matrix = np.copy(mat_0) + reference_origin = reference_uc.layerpos[float(layer_id)] + reference_matrix[2, 2] = film_strain + reference_matrix[2, 3] = ( + terrace_height / reference_uc.a[2] + - film_strain * reference_origin + ) + reference_uc.coherentDomainMatrix.append(reference_matrix) + reference_uc.coherentDomainOccupancy.append( + -film_domain_occupancy * surface_occupancy[layer_index] + ) if layers_to_create: - upper_layer = uc.basis[0, 7] - self._end_layer_number = upper_layer + self._end_layer_number = self.layer_order[ + int(layer_numbers[-1] % n_layers_in_uc) + ] + top_relative = ( + layer_numbers[-1] // n_layers_in_uc + + self.layerpos[int(layer_numbers[-1] % n_layers_in_uc)] + - self.layerpos[0] + ) + layer_spacing = self.underlying_film.unitcell.a[2] / n_layers_in_uc + self._height_absolute = self.below_H + ( + top_relative * self.underlying_film.unitcell.a[2] + layer_spacing + ) else: self._end_layer_number = self.start_layer_number + self._height_absolute = self.below_H _translate_domains(self.layer_ucs, self.below_H) + _translate_domains(self.film_layer_ucs, self.below_H) + _translate_domains(list(self._film_termination_ucs.values()), self.below_H) self._basis_created = np.copy(self.basis) def F_uc(self, h, k, l): # noqa: E741 - """Return the Poisson surface correction in electrons. + """Return the step--Poisson surface correction in electrons. Positive occupancies add grown material and negative occupancies remove etched material relative to the sharp Film boundary. The amplitude @@ -1740,20 +2246,51 @@ def F_uc(self, h, k, l): # noqa: E741 if ctr_accel_enabled(): h, k, l = _ensure_contiguous(h, k, l, testOnly=False, astype=np.float64) # noqa: E741 F = np.zeros_like(l, dtype=np.complex128) - for uc in self.layer_ucs: - if uc.coherentDomainMatrix: - F += uc.F_uc(h, k, l) + for surface_uc, film_uc in zip(self.layer_ucs, self.film_layer_ucs): + if surface_uc.coherentDomainMatrix: + F += surface_uc.F_uc(h, k, l) + if film_uc.coherentDomainMatrix: + F += film_uc.F_uc(h, k, l) + for film_uc in self._film_termination_ucs.values(): + if film_uc.coherentDomainMatrix: + F += film_uc.F_uc(h, k, l) return F def zDensity_G(self, z, h, k): if np.any(self._basis_created != self.basis): self.createLayers() rho = np.zeros_like(z, dtype=np.complex128) - for uc in self.layer_ucs: - if uc.coherentDomainMatrix: - rho += uc.zDensity_G(z, h, k) + for surface_uc, film_uc in zip(self.layer_ucs, self.film_layer_ucs): + if surface_uc.coherentDomainMatrix: + rho += surface_uc.zDensity_G(z, h, k) + if film_uc.coherentDomainMatrix: + rho += film_uc.zDensity_G(z, h, k) + for film_uc in self._film_termination_ucs.values(): + if film_uc.coherentDomainMatrix: + rho += film_uc.zDensity_G(z, h, k) return rho + def optical_profile(self): + """Return the homogeneous optical profile of the Poisson surface. + + :returns: + C-contiguous ``(N, 3)`` array with columns ``z``, ``delta``, and + ``beta``. + :rtype: numpy.ndarray + """ + if np.any(self._basis_created != self.basis): + self.createLayers() + profiles = [] + for surface_uc, film_uc in zip(self.layer_ucs, self.film_layer_ucs): + if surface_uc.coherentDomainMatrix: + profiles.append(surface_uc.optical_profile()) + if film_uc.coherentDomainMatrix: + profiles.append(film_uc.optical_profile()) + for film_uc in self._film_termination_ucs.values(): + if film_uc.coherentDomainMatrix: + profiles.append(film_uc.optical_profile()) + return combine_profiles(*profiles) + def addFitParameter(self, indexarray, limits=(-np.inf, np.inf), **kwarg): """to assign multiple unitcells with the same fitparameter, provide list of unitcell names as kwarg `unitcell` @@ -1761,7 +2298,9 @@ def addFitParameter(self, indexarray, limits=(-np.inf, np.inf), **kwarg): if len(np.array(indexarray).shape) < 2: return super().addFitParameter(indexarray, limits, **kwarg) - return self.unitcell.addFitParameter(indexarray, limits, **kwarg) + target = kwarg.pop("unitcell", None) + cell = self[target] if target is not None else self.unitcell + return cell.addFitParameter(indexarray, limits, **kwarg) def addRelParameter(self, indexarray, factors, limits=(-np.inf, np.inf), **kwarg): """to assign multiple unitcells with the same fitparameter, provide list of @@ -1769,7 +2308,9 @@ def addRelParameter(self, indexarray, factors, limits=(-np.inf, np.inf), **kwarg """ if len(np.array(indexarray).shape) < 2: return super().addRelParameter(indexarray, factors, limits, **kwarg) - return self.unitcell.addRelParameter(indexarray, factors, limits, **kwarg) + target = kwarg.pop("unitcell", None) + cell = self[target] if target is not None else self.unitcell + return cell.addRelParameter(indexarray, factors, limits, **kwarg) def getStartParamAndLimits(self, force_recalculate=False): # if self.basis_0 is None: @@ -1777,9 +2318,13 @@ def getStartParamAndLimits(self, force_recalculate=False): x0, lower, upper = super().getStartParamAndLimits( force_recalculate ) # absolute and relative - uc_x0, uc_lower, uc_upper = self.unitcell.getStartParamAndLimits( - force_recalculate - ) + uc_values = [ + cell.getStartParamAndLimits(force_recalculate) + for cell in self._owned_unitcells() + ] + uc_x0 = np.concatenate([value[0] for value in uc_values]) + uc_lower = np.concatenate([value[1] for value in uc_values]) + uc_upper = np.concatenate([value[2] for value in uc_values]) return ( np.concatenate([x0, uc_x0]), np.concatenate([lower, uc_lower]), @@ -1788,68 +2333,109 @@ def getStartParamAndLimits(self, force_recalculate=False): def setFitParameters(self, x): abs_rel_no = len(self.parameters["absolute"]) + len(self.parameters["relative"]) - fp_no = len(self.unitcell.fitparnames) super().setFitParameters(x[:abs_rel_no]) if self.profile is not None: self.profile = PoissonProfile( mean_change=self.basis[0], - offset=self.basis[1], + alpha=self.basis[1], + offset=self.basis[2], tail_probability=self.profile.tail_probability, ) - self.unitcell.setFitParameters(x[abs_rel_no : abs_rel_no + fp_no]) + cursor = abs_rel_no + for cell in self._owned_unitcells(): + fp_no = len(cell.fitparnames) + cell.setFitParameters(x[cursor : cursor + fp_no]) + cursor += fp_no + self._refresh_legacy_terminations() def setLimits(self, lim): abs_rel_no = len(self.parameters["absolute"]) + len(self.parameters["relative"]) - fp_no = len(self.unitcell.fitparnames) super().setLimits(lim[:abs_rel_no]) - self.unitcell.setLimits(lim[abs_rel_no : abs_rel_no + fp_no]) + cursor = abs_rel_no + for cell in self._owned_unitcells(): + fp_no = len(cell.fitparnames) + cell.setLimits(lim[cursor : cursor + fp_no]) + cursor += fp_no def setFitErrors(self, errors): abs_rel_no = len(self.parameters["absolute"]) + len(self.parameters["relative"]) - fp_no = len(self.unitcell.fitparnames) super().setFitErrors(errors[:abs_rel_no]) - self.unitcell.setFitErrors(errors[abs_rel_no : abs_rel_no + fp_no]) + cursor = abs_rel_no + for cell in self._owned_unitcells(): + fp_no = len(cell.fitparnames) + cell.setFitErrors(errors[cursor : cursor + fp_no]) + cursor += fp_no def getFitErrors(self): err = super().getFitErrors() - err_uc = self.unitcell.getFitErrors() + err_uc = np.concatenate( + [cell.getFitErrors() for cell in self._owned_unitcells()] + ) return np.concatenate([err, err_uc]) @property def fitparnames(self): - return super().fitparnames + self.unitcell.fitparnames + cells = self._owned_unitcells() + if len(cells) == 1: + names = cells[0].fitparnames + else: + names = [ + f"{cell.name}:{name}" + for cell in cells + for name in cell.fitparnames + ] + return super().fitparnames + names @property def priors(self): - return super().priors + self.unitcell.priors + return super().priors + [ + prior for cell in self._owned_unitcells() for prior in cell.priors + ] def parametersToDict(self): d = super().parametersToDict() - d["unitcells"] = {} - d["unitcells"]["unitcell"] = self.unitcell.parametersToDict() + d["unitcells"] = { + cell.name: cell.parametersToDict() for cell in self._owned_unitcells() + } return d def clearParameters(self): super().clearParameters() - self.unitcell.clearParameters() + for cell in self._owned_unitcells(): + cell.clearParameters() def parametersFromDict(self, d, override_values=True): - self.unitcell.parametersFromDict(d["unitcells"]["unitcell"], override_values) + cells = self._owned_unitcells() + stored = d["unitcells"] + if "unitcell" in stored and len(cells) == 1: + cells[0].parametersFromDict(stored["unitcell"], override_values) + else: + for cell in cells: + cell.parametersFromDict(stored[cell.name], override_values) super().parametersFromDict(d, override_values) def updateFromParameters(self): """Update basis from the values stored in the Parameters""" - self.unitcell.updateFromParameters() + for cell in self._owned_unitcells(): + cell.updateFromParameters() super().updateFromParameters() if self.profile is not None: self.profile = PoissonProfile( mean_change=self.basis[0], - offset=self.basis[1], + alpha=self.basis[1], + offset=self.basis[2], tail_probability=self.profile.tail_probability, ) + self._refresh_legacy_terminations() def __getitem__(self, uc_name_or_index): if isinstance(uc_name_or_index, str): + for layer, cell in self.termination_cells.items(): + if uc_name_or_index.lower() in { + cell.name.lower(), + f"termination_{layer:g}", + }: + return cell if uc_name_or_index.lower() in [ "uc", "unitcell", @@ -1860,11 +2446,22 @@ def __getitem__(self, uc_name_or_index): raise KeyError( f"No unit cell {uc_name_or_index} in PoissonSurface {self.name}" ) + elif isinstance(uc_name_or_index, int | float | np.integer | np.floating): + try: + return self.termination_cells[float(uc_name_or_index)] + except KeyError as exc: + raise KeyError( + f"No termination {uc_name_or_index} in PoissonSurface {self.name}" + ) from exc else: raise ValueError(f"must be str, not {type(uc_name_or_index)}") def parameter_list(self): - return super().parameter_list() + self.unitcell.parameter_list() + return super().parameter_list() + [ + parameter + for cell in self._owned_unitcells() + for parameter in cell.parameter_list() + ] @classmethod def fromStr(cls, string): @@ -1904,65 +2501,53 @@ def fromStr(cls, string): basis = np.array(sline, dtype=np.float64) errors = None - # very explicit searching for the lines containing TopUnitCell and BottomUnitCell: # noqa: E501 sp_str = string.splitlines() - uc_pos = -1 - for i, l in enumerate(sp_str): # noqa: E741 - if uc_pos == -1: - if "UnitCell" in l: - uc_pos = i # found it, and save line number - if uc_pos != -1: - break - else: - msg = "Cannot create Film. " - if uc_pos < 0: - msg += "No UnitCell provided. " - raise ValueError(msg) - - uc_classname, uc_name = sp_str[uc_pos].split(maxsplit=1) - - assert uc_classname == "UnitCell" - uc_str = "\n".join(sp_str[uc_pos + 1 :]) - - uc = UnitCell.fromStr(uc_str) - - uc.name = uc_name + cell_headers = [ + i + for i, line in enumerate(sp_str) + if line.strip().startswith(("UnitCell ", "TerminationUnitCell ")) + ] + if not cell_headers: + raise ValueError("Cannot create PoissonSurface. No UnitCell provided.") + cells = {} + legacy_uc = None + for header_index, end_index in zip( + cell_headers, cell_headers[1:] + [len(sp_str)] + ): + header = sp_str[header_index].split(maxsplit=2) + cell = UnitCell.fromStr( + "\n".join(sp_str[header_index + 1 : end_index]) + ) + if header[0] == "TerminationUnitCell": + if len(header) != 3: + raise ValueError( + "TerminationUnitCell requires a layer identifier and name" + ) + layer = float(header[1]) + cell.name = header[2] + cells[layer] = cell + else: + cell.name = " ".join(header[1:]) + legacy_uc = cell + if cells and legacy_uc is not None: + raise ValueError( + "PoissonSurface cannot mix UnitCell and TerminationUnitCell sections" + ) + unitcells = cells if cells else legacy_uc tail_probability = _parse_float_metadata( string, "tail_probability", DEFAULT_TAIL_PROBABILITY ) - legacy_parameter_names = any("deltaW" in line for line in string.splitlines()) - support_cursor = _parse_text_metadata(string, "support_cursor", None) - legacy_absolute_width = ( - legacy_parameter_names - if support_cursor is None - else support_cursor != "nominal" + profile = PoissonProfile( + mean_change=basis[0], + alpha=basis[1], + offset=basis[2], + tail_probability=tail_probability, ) - if legacy_absolute_width: - profile = PoissonProfile( - mean_change=basis[1], - tail_probability=tail_probability, - ) - elif legacy_parameter_names: - # Migrate the short-lived nominal-cursor format where W was zero - # and deltaW was a positive, mean-preserving roughness parameter. - profile = PoissonProfile( - mean_change=basis[1], - offset=-basis[1], - tail_probability=tail_probability, - ) - basis = np.array([profile.mean_change, profile.offset]) - else: - profile = PoissonProfile( - mean_change=basis[0], - offset=basis[1], - tail_probability=tail_probability, - ) film = cls( - uc, + unitcells, profile=profile, layer_transition=_parse_layer_transition(string), - legacy_absolute_width=legacy_absolute_width, ) film.statistics = statistics film.basis = basis @@ -1980,13 +2565,7 @@ def toStr(self, showErrors=True): :rtype: str """ # s = "type %s" % self.type - if self._legacy_absolute_width: - parameter_order = "Width/layers deltaW/layers" - else: - parameter_order = PoissonSurface.parameterOrder - s = "\n" + parameter_order + "\n" + self.filmToStr(showErrors=showErrors) - if not self._legacy_absolute_width: - s += "\nsupport_cursor: nominal" + s = "\n" + self.parameterOrder + "\n" + self.filmToStr(showErrors=showErrors) if ( self.profile is not None and self.profile.tail_probability != DEFAULT_TAIL_PROBABILITY @@ -1996,8 +2575,13 @@ def toStr(self, showErrors=True): if metadata: s += "\n" + metadata.rstrip() s += "\n\n" - s += f"UnitCell {self.unitcell.name}\n" - s += self.unitcell.toStr(showErrors=showErrors) + "\n" + if self._source_unitcell is not None: + s += f"UnitCell {self._source_unitcell.name}\n" + s += self._source_unitcell.toStr(showErrors=showErrors) + "\n" + else: + for layer, cell in self.termination_cells.items(): + s += f"TerminationUnitCell {layer:g} {cell.name}\n" + s += cell.toStr(showErrors=showErrors) + "\n\n" return s def __repr__(self): diff --git a/orgui/datautils/xrayutils/CTRoptics.py b/orgui/datautils/xrayutils/CTRoptics.py new file mode 100644 index 0000000..1be53ae --- /dev/null +++ b/orgui/datautils/xrayutils/CTRoptics.py @@ -0,0 +1,981 @@ +"""Optical constants derived from CTR structural models.""" + +from dataclasses import dataclass + +import numpy as np + +from .CTRutil import atomic_number + + +HC_KEV_ANGSTROM = 12.398419843320026 + + +@dataclass(frozen=True) +class Wavefield: + """Unperturbed one-dimensional wavefield in Renaud notation. + + Slabs are ordered from the incident medium towards the substrate. + ``A_plus`` and ``A_minus`` are downward- and upward-propagating electric + field amplitudes referenced to ``z_reference`` for each slab. + + :param numpy.ndarray z: + Sample positions in Angstrom, ordered from substrate to ambient. + :param numpy.ndarray psi: + Complex electric field sampled at ``z``. The layer axis is first; + remaining axes match a nonscalar incidence-angle input. + :param numpy.ndarray z_interfaces: + Interface positions in Angstrom, ordered from ambient to substrate. + :param numpy.ndarray z_reference: + Local amplitude-reference position for each slab in Angstrom. + :param numpy.ndarray n: + Complex slab refractive indices, ambient first. + :param numpy.ndarray kz: + Renaud normal wavevectors in inverse Angstrom. The layer axis is first. + :param numpy.ndarray A_plus: + Downward-propagating slab amplitudes. + :param numpy.ndarray A_minus: + Upward-propagating slab amplitudes. + :param complex or numpy.ndarray r_S: + Specular reflection amplitude in the incident medium. + :param complex or numpy.ndarray t_S: + Transmission amplitude at the substrate boundary. + :param str polarization: + ``"s"`` or ``"p"``. + """ + + z: np.ndarray + psi: np.ndarray + z_interfaces: np.ndarray + z_reference: np.ndarray + n: np.ndarray + kz: np.ndarray + A_plus: np.ndarray + A_minus: np.ndarray + r_S: complex + t_S: complex + polarization: str + + +@dataclass(frozen=True) +class StratifiedProfile: + """Optical media centers and their physical interface positions. + + :param numpy.ndarray values: + ``(N, 3)`` array containing layer-center z, delta, and beta. + :param numpy.ndarray boundaries: + ``N - 1`` interfaces in Angstrom, ordered from substrate to ambient. + """ + + values: np.ndarray + boundaries: np.ndarray + + @property + def z(self): + """Return layer-center z coordinates in Angstrom.""" + return self.values[:, 0] + + @property + def delta(self): + """Return layer delta values.""" + return self.values[:, 1] + + @property + def beta(self): + """Return layer beta values.""" + return self.values[:, 2] + + +def optical_profile(unit_cell, include_coherent_domains=True): + """Return homogeneous optical constants for every layer and domain. + + The returned C-contiguous ``(N, 3)`` float64 array has columns ``z`` in + Angstrom, ``delta``, and ``beta``. Each row is a domain-transformed layer + contribution; coherent-domain occupancy is already applied to ``delta`` + and ``beta`` in the convention ``n = 1-delta-i*beta``. + + :param UnitCell unit_cell: + Unit cell with energy-dependent scattering factors populated by + :meth:`UnitCell.setEnergy`. + :param bool include_coherent_domains: + Apply the unit cell's coherent-domain transforms and occupancies. + :returns: + One row for every structural layer and coherent domain. + :rtype: numpy.ndarray + :raises ValueError: + If the unit-cell energy has not populated anomalous scattering factors. + """ + if not hasattr(unit_cell, "_E") or not hasattr(unit_cell, "f"): + raise ValueError( + "Set the UnitCell energy before calculating optical constants." + ) + + layers = unit_cell.split_in_layers(ordered=True) + layer_ids = tuple(layers) + if not layer_ids: + return np.empty((0, 3), dtype=np.float64) + + if hasattr(unit_cell, "_optical_layer_origin"): + if len(layer_ids) != 1: + raise ValueError("Layer optical metadata requires exactly one layer.") + origins = np.asarray([unit_cell._optical_layer_origin], dtype=np.float64) + thickness_fraction = np.asarray( + [unit_cell._optical_layer_thickness_fraction], dtype=np.float64 + ) + else: + origins = [] + for layer_id in layer_ids: + origin = unit_cell.layerpos.get(float(layer_id)) + if origin is None: + atom_mask = unit_cell.basis[:, 7] == layer_id + origin = float(np.mean(unit_cell.basis[atom_mask, 3])) + origins.append(float(origin)) + origins = np.asarray(origins, dtype=np.float64) + thickness_fraction = np.empty(len(layer_ids), dtype=np.float64) + for index in range(len(layer_ids)): + spacing = origins[(index + 1) % len(layer_ids)] - origins[index] + while spacing <= 0.0: + spacing += 1.0 + thickness_fraction[index] = spacing + + wavelength = HC_KEV_ANGSTROM / (unit_cell._E * 1e-3) + scale = 2.8179403262e-5 * wavelength**2 / (2.0 * np.pi) + if include_coherent_domains: + domain_matrices = unit_cell.coherentDomainMatrix + domain_occupancies = unit_cell.coherentDomainOccupancy + else: + domain_matrices = [np.hstack((np.identity(3), np.zeros((3, 1))))] + domain_occupancies = [1.0] + profile = np.empty((len(layer_ids) * len(domain_matrices), 3), dtype=np.float64) + row = 0 + for layer_index, (layer_id, layer_cell) in enumerate(layers.items()): + forward_static_factor = np.asarray( + [atomic_number(name) for name in layer_cell.names], + dtype=np.float64, + ) + ionic_species = np.asarray( + [name.endswith(("+", "-")) for name in layer_cell.names], + dtype=bool, + ) + ionic_forward_factor = ( + np.sum(layer_cell.f[:, :5], axis=1) + layer_cell.f[:, 10] + ) + forward_static_factor[ionic_species] = ionic_forward_factor[ + ionic_species + ] + forward_factor = np.sum( + layer_cell.basis[:, 6] + * ( + forward_static_factor + + layer_cell.f[:, 11] + + 1j * layer_cell.f[:, 12] + ) + ) + layer_volume = unit_cell.volume * thickness_fraction[layer_index] + optical_density = scale * forward_factor / layer_volume + origin_vector = np.array([0.0, 0.0, origins[layer_index]]) + for matrix, occupancy in zip( + domain_matrices, domain_occupancies + ): + domain_matrix = unit_cell.R_mat_inv @ matrix[:, :-1] @ unit_cell.R_mat + domain_origin = domain_matrix @ origin_vector + matrix[:, -1] + normal_stretch = abs(float(domain_matrix[2, 2])) + if not np.isfinite(normal_stretch) or normal_stretch <= 0.0: + raise ValueError( + "Coherent-domain transforms must preserve a finite, " + "nonzero surface-normal layer thickness." + ) + # Domain transforms used by Film and EpitaxyInterface encode their + # physical out-of-plane strain in the transformed layer spacing. + # Preserve each layer's areal electron content when converting + # that spacing to a homogeneous volume density. + contribution = ( + float(occupancy) * optical_density / normal_stretch + ) + profile[row] = [ + domain_origin[2] * unit_cell.a[2], + contribution.real, + contribution.imag, + ] + row += 1 + return profile + + +def optical_profile_asbulk(unit_cell, noUC=30): + """Return the semi-infinite bulk approximation used by CTR densities. + + This mirrors :meth:`UnitCell.zDensity_G_asbulk`: the native bulk unit + cell is repeated towards negative z, with ``noUC`` cells explicitly + represented. Coherent-domain transforms are intentionally not used for + this bulk baseline. + + :param UnitCell unit_cell: + Bulk unit cell with energy-dependent scattering factors populated. + :param int noUC: + Number of unit cells to represent below the termination. + :returns: + C-contiguous ``(N, 3)`` array with columns ``z`` in Angstrom, + ``delta``, and ``beta``. + :rtype: numpy.ndarray + :raises ValueError: + If ``noUC`` is not a positive integer. + """ + if not isinstance(noUC, int | np.integer) or noUC <= 0: + raise ValueError("noUC must be a positive integer.") + + unit_profile = optical_profile(unit_cell, include_coherent_domains=False) + + profiles = [] + for number in range(noUC): + profile = unit_profile.copy() + profile[:, 0] -= number * unit_cell.a[2] + profiles.append(profile) + return np.ascontiguousarray(np.concatenate(profiles, axis=0)) + + +def water_optical_profile(water_model, noUC=30, z_step=None, z_origin=None): + """Sample continuous water optical constants towards positive z. + + The sampled density is calculated directly from + :meth:`WaterModel.zDensity_G` at ``h = k = 0``. This is the continuous + analogue of :func:`optical_profile_asbulk`, whose repeated bulk cells + extend towards negative z. + + :param WaterModel water_model: + Water model with energy-dependent scattering factors populated. + :param int noUC: + Length of the sampled water region in water-model unit cells. + :param float z_step: + Uniform z sampling interval in Angstrom. Defaults to the water-model + lattice height when no atomistic profile supplies a spacing. + :param float z_origin: + Optional origin of the atomistic z grid in Angstrom. When supplied, + the first water sample is aligned to that grid at or above the water + onset. + :returns: + C-contiguous ``(N, 3)`` array with columns ``z`` in Angstrom, + ``delta``, and ``beta``. + :rtype: numpy.ndarray + :raises ValueError: + If energy, sample length, or sample spacing is invalid. + """ + if not hasattr(water_model, "_E") or not hasattr(water_model, "f"): + raise ValueError( + "Set the WaterModel energy before calculating optical constants." + ) + if not isinstance(noUC, int | np.integer) or noUC <= 0: + raise ValueError("noUC must be a positive integer.") + if z_step is None: + z_step = water_model.a[2] + if z_step <= 0.0: + raise ValueError("z_step must be positive.") + + water_onset = water_model.pos_absolute + water_model.basis[0] * water_model.a[2] + z_start = water_onset + if z_origin is not None: + grid_index = np.ceil((water_onset - z_origin) / z_step - 1e-12) + z_start = z_origin + grid_index * z_step + z_stop = water_onset + noUC * water_model.a[2] + z = np.arange(z_start, z_stop + 0.5 * z_step, z_step, dtype=np.float64) + rho_f = water_model.zDensity_G(z, 0.0, 0.0) + wavelength = HC_KEV_ANGSTROM / (water_model._E * 1e-3) + scale = 2.8179403262e-5 * wavelength**2 / (2.0 * np.pi) + profile = np.empty((z.size, 3), dtype=np.float64) + profile[:, 0] = z + profile[:, 1] = scale * rho_f.real + profile[:, 2] = scale * rho_f.imag + return np.ascontiguousarray(profile) + + +def top_layer_spacing(profile): + """Return the spacing between the two highest optical-layer origins. + + :param numpy.ndarray profile: + Atomistic optical profile with z in the first column. + :returns: + Positive top-layer spacing in Angstrom. + :rtype: float + :raises ValueError: + If fewer than two distinct layer origins are available. + """ + z = np.unique(np.asarray(profile[:, 0], dtype=np.float64)) + if z.size < 2: + raise ValueError("At least two atomic layer origins are required for dz.") + dz = float(z[-1] - z[-2]) + if dz <= 0.0: + raise ValueError("Atomic layer spacing must be positive.") + return dz + + +def add_structural_to_sampled_profile( + structural_profile, *sampled_profiles, z_tolerance=0.6 +): + """Add discrete structural layers to a continuous sampled profile. + + A continuous profile must retain each of its sampling points. Therefore + it cannot use :func:`combine_profiles`, which coalesces nearby origins. + Instead, each structural contribution within ``z_tolerance`` Angstrom is + added to the nearest sampled point; structural contributions outside the + sampled region keep their original position. + + :param numpy.ndarray structural_profile: + Discrete layer contributions with columns z, delta, and beta. + :param sampled_profiles: + Continuous sampled profiles with the same columns. + :param float z_tolerance: + Maximum structural-to-sample separation in Angstrom. + :returns: + C-contiguous combined profile sorted by z. + :rtype: numpy.ndarray + """ + if z_tolerance < 0.0: + raise ValueError("z_tolerance must be non-negative.") + samples = [profile for profile in sampled_profiles if profile.size] + if not samples: + return np.ascontiguousarray(structural_profile) + sampled = np.ascontiguousarray(np.concatenate(samples, axis=0), dtype=np.float64) + sampled = sampled[np.argsort(sampled[:, 0], kind="stable")] + remainder = [] + for row in structural_profile: + right = np.searchsorted(sampled[:, 0], row[0]) + candidates = [ + index for index in (right - 1, right) if 0 <= index < len(sampled) + ] + nearest = min(candidates, key=lambda index: abs(sampled[index, 0] - row[0])) + if abs(sampled[nearest, 0] - row[0]) <= z_tolerance: + sampled[nearest, 1:] += row[1:] + else: + remainder.append(row) + if remainder: + profile = np.concatenate((np.asarray(remainder), sampled), axis=0) + else: + profile = sampled + return np.ascontiguousarray(profile[np.argsort(profile[:, 0], kind="stable")]) + + +def combine_profiles(*profiles, z_tolerance=0.6): + """Combine and coalesce homogeneous optical-profile contributions. + + :param profiles: + Arrays returned by :func:`optical_profile`, optionally empty. + :param float z_tolerance: + Maximum separation in Angstrom between layer origins that represent + one homogeneous optical layer. The lowest origin in each merged + group is retained as its coordinate. + :returns: + C-contiguous ``(N, 3)`` array sorted by z, with delta and beta summed + for origins separated by at most ``z_tolerance``. + :rtype: numpy.ndarray + """ + if z_tolerance < 0.0: + raise ValueError("z_tolerance must be non-negative.") + populated = [profile for profile in profiles if profile.size] + if not populated: + return np.empty((0, 3), dtype=np.float64) + stacked = np.ascontiguousarray(np.concatenate(populated, axis=0), dtype=np.float64) + order = np.argsort(stacked[:, 0], kind="stable") + stacked = stacked[order] + group_starts = [0] + for index in range(1, len(stacked)): + group_start = group_starts[-1] + if stacked[index, 0] - stacked[group_start, 0] > z_tolerance: + group_starts.append(index) + group_starts = np.asarray(group_starts, dtype=np.intp) + group_stops = np.r_[group_starts[1:], len(stacked)] + combined = np.empty((len(group_starts), 3), dtype=np.float64) + for index, (start, stop) in enumerate(zip(group_starts, group_stops)): + group = stacked[start:stop] + combined[index] = [group[0, 0], group[:, 1].sum(), group[:, 2].sum()] + return np.ascontiguousarray(combined) + + +def profile_boundaries(profile): + """Return interfaces centered between adjacent optical-profile samples. + + The profile coordinates identify the centers at which ``delta`` and + ``beta`` apply. Each interface is therefore the midpoint of two adjacent + center coordinates. The first and last media remain semi-infinite. + + :param numpy.ndarray profile: + ``(N, 3)`` profile with strictly increasing layer-center z positions + in Angstrom, delta, and beta. + :returns: + ``N - 1`` interfaces in Angstrom, ordered from substrate to ambient. + :rtype: numpy.ndarray + :raises ValueError: + If the profile is not a valid ordered ``(N, 3)`` array. + """ + profile = np.asarray(profile, dtype=np.float64) + if profile.ndim != 2 or profile.shape[1] != 3 or len(profile) < 2: + raise ValueError("profile must be an (N, 3) array with at least two rows.") + if not np.all(np.diff(profile[:, 0]) > 0.0): + raise ValueError("profile z positions must be strictly increasing.") + return np.ascontiguousarray(0.5 * (profile[:-1, 0] + profile[1:, 0])) + + +def stratify_profile(profile, delta_tolerance=None, beta_tolerance=None): + """Create finite optical layers and their midpoint interfaces. + + When simplification is requested, adjacent finite media are merged without + moving their outer interfaces. Their optical constants are weighted by + the physical widths between those inherited interfaces. The substrate + and incident medium are never merged. + + :param numpy.ndarray profile: + ``(N, 3)`` profile with layer-center z in Angstrom, delta, and beta. + :param float delta_tolerance: + Maximum delta range within one merged layer. Pass ``None`` to retain + every sampled medium. + :param float beta_tolerance: + Maximum beta range within one merged layer. Defaults to + ``delta_tolerance`` when simplification is requested. + :returns: + Optical media and their explicit physical boundaries. + :rtype: StratifiedProfile + :raises ValueError: + If the profile or tolerances are invalid. + """ + profile = np.asarray(profile, dtype=np.float64) + boundaries = profile_boundaries(profile) + if delta_tolerance is None: + return StratifiedProfile( + np.ascontiguousarray(profile.copy()), boundaries + ) + if beta_tolerance is None: + beta_tolerance = delta_tolerance + if delta_tolerance < 0.0 or beta_tolerance < 0.0: + raise ValueError("delta and beta tolerances must be non-negative.") + if len(profile) <= 3: + return StratifiedProfile( + np.ascontiguousarray(profile.copy()), boundaries + ) + + values = [profile[0].copy()] + inherited_boundaries = [boundaries[0]] + start = 1 + last_finite = len(profile) - 2 + while start <= last_finite: + stop = start + delta_min = delta_max = profile[start, 1] + beta_min = beta_max = profile[start, 2] + while stop < last_finite: + candidate = stop + 1 + next_delta_min = min(delta_min, profile[candidate, 1]) + next_delta_max = max(delta_max, profile[candidate, 1]) + next_beta_min = min(beta_min, profile[candidate, 2]) + next_beta_max = max(beta_max, profile[candidate, 2]) + if ( + next_delta_max - next_delta_min > delta_tolerance + or next_beta_max - next_beta_min > beta_tolerance + ): + break + stop = candidate + delta_min, delta_max = next_delta_min, next_delta_max + beta_min, beta_max = next_beta_min, next_beta_max + + lower_boundary = boundaries[start - 1] + upper_boundary = boundaries[stop] + layer_boundaries = boundaries[start - 1 : stop + 1] + thicknesses = np.diff(layer_boundaries) + merged = profile[start].copy() + merged[0] = 0.5 * (lower_boundary + upper_boundary) + merged[1:] = np.average( + profile[start : stop + 1, 1:], axis=0, weights=thicknesses + ) + values.append(merged) + inherited_boundaries.append(upper_boundary) + start = stop + 1 + + values.append(profile[-1].copy()) + return StratifiedProfile( + np.ascontiguousarray(np.asarray(values, dtype=np.float64)), + np.ascontiguousarray(np.asarray(inherited_boundaries, dtype=np.float64)), + ) + + +def simplify_profile(profile, delta_tolerance=1e-9, beta_tolerance=None): + """Merge adjacent optically similar finite layers. + + A merged layer receives thickness-weighted delta and beta, preserving the + integrated optical constants through the combined finite thickness. The + semi-infinite substrate and incident-medium rows are always retained. + Grouping is bounded by the full delta/beta range in each group, preventing + a long sequence of small changes from drifting beyond the tolerances. + + :param numpy.ndarray profile: + ``(N, 3)`` profile with strictly increasing z, delta, and beta. + :param float delta_tolerance: + Maximum delta range within one merged layer. + :param float beta_tolerance: + Maximum beta range within one merged layer. Defaults to + ``delta_tolerance``. + :returns: + Simplified C-contiguous optical profile. + :rtype: numpy.ndarray + :raises ValueError: + If the profile or tolerances are invalid. + """ + return stratify_profile( + profile, delta_tolerance, beta_tolerance + ).values + + +def _normal_wavevector(n, k0, alpha_rad): + """Return normal propagation constants for incidence from medium zero.""" + k_parallel_over_k0 = n[0] * np.cos(alpha_rad) + argument = n**2 - k_parallel_over_k0**2 + q = k0 * np.sqrt(argument) + q = np.where(q.imag > 0.0, -q, q) + q = np.where((q.imag == 0.0) & (q.real < 0.0), -q, q) + # Avoid cancellation in n_0**2 - (n_0*cos(alpha))**2 at tiny angles. + q[0] = k0 * n[0] * np.sin(alpha_rad) + return q + + +def _normal_wavevectors(n, k0, alpha_rad): + """Return normal propagation constants for a vector of incidence angles.""" + k_parallel_over_k0 = n[0] * np.cos(alpha_rad) + argument = n[:, np.newaxis] ** 2 - k_parallel_over_k0**2 + q = k0 * np.sqrt(argument) + q = np.where(q.imag > 0.0, -q, q) + q = np.where((q.imag == 0.0) & (q.real < 0.0), -q, q) + q[0] = k0 * n[0] * np.sin(alpha_rad) + return q + + +def _admittance(n, q, polarization): + """Return scalar optical admittance for s or p polarization.""" + if polarization == "s": + return q + if polarization == "p": + return n**2 / q + raise ValueError("polarization must be 's' or 'p'.") + + +def _propagation_terms(n, q, thickness, polarization): + """Return finite state-propagation terms, including the ``q = 0`` limit.""" + phase = q * thickness + cosine = np.cos(phase) + sinc = np.sinc(phase / np.pi) + if polarization == "s": + sin_over_admittance = thickness * sinc + admittance_sin = q**2 * thickness * sinc + else: + sin_over_admittance = q**2 * thickness * sinc / n**2 + admittance_sin = n**2 * thickness * sinc + return cosine, sin_over_admittance, admittance_sin + + +def _propagate_state(field, derivative, n, q, thickness, polarization): + """Propagate the continuous field state downward through one slab.""" + cosine, sin_over_admittance, admittance_sin = _propagation_terms( + n, q, thickness, polarization + ) + return ( + cosine * field - 1j * sin_over_admittance * derivative, + cosine * derivative - 1j * admittance_sin * field, + ) + + +def _propagate_state_upward( + field, derivative, n, q, thickness, polarization +): + """Propagate the continuous field state upward through one slab.""" + cosine, sin_over_admittance, admittance_sin = _propagation_terms( + n, q, thickness, polarization + ) + return ( + cosine * field + 1j * sin_over_admittance * derivative, + cosine * derivative + 1j * admittance_sin * field, + ) + + +def _solve_regular_wavefield(n, q, z_interfaces, z_ascending, polarization): + """Solve angle vectors whose normal wavevectors are all nonzero.""" + admittance = _admittance(n[:, np.newaxis], q, polarization) + interface_r = (admittance[:-1] - admittance[1:]) / ( + admittance[:-1] + admittance[1:] + ) + reflection = np.empty_like(interface_r) + reflection[-1] = interface_r[-1] + for interface in range(len(interface_r) - 2, -1, -1): + thickness = z_interfaces[interface] - z_interfaces[interface + 1] + phase = np.exp(-1j * q[interface + 1] * thickness) + reflected_below = reflection[interface + 1] * phase**2 + reflection[interface] = ( + interface_r[interface] + reflected_below + ) / (1.0 + interface_r[interface] * reflected_below) + + A_plus = np.empty_like(q) + A_minus = np.empty_like(q) + z_reference = np.empty(len(n), dtype=np.float64) + A_plus[0] = 1.0 + A_minus[0] = reflection[0] + z_reference[0] = z_interfaces[0] + + down_at_interface = A_plus[0] + up_at_interface = A_minus[0] + for interface in range(len(interface_r)): + field = down_at_interface + up_at_interface + derivative = admittance[interface] * ( + down_at_interface - up_at_interface + ) + A_plus[interface + 1] = 0.5 * ( + field + derivative / admittance[interface + 1] + ) + A_minus[interface + 1] = 0.5 * ( + field - derivative / admittance[interface + 1] + ) + z_reference[interface + 1] = z_interfaces[interface] + if interface + 1 < len(interface_r): + thickness = z_interfaces[interface] - z_interfaces[interface + 1] + phase = np.exp(-1j * q[interface + 1] * thickness) + down_at_interface = A_plus[interface + 1] * phase + up_at_interface = A_minus[interface + 1] / phase + + psi = np.empty_like(q) + for ascending_index, z_value in enumerate(z_ascending): + medium = len(n) - 1 - ascending_index + depth = z_reference[medium] - z_value + psi[ascending_index] = ( + A_plus[medium] * np.exp(-1j * q[medium] * depth) + + A_minus[medium] * np.exp(1j * q[medium] * depth) + ) + return ( + A_plus, + A_minus, + z_reference, + psi, + A_minus[0], + A_plus[-1], + ) + + +def _solve_critical_wavefield(n, q, z_interfaces, z_ascending, polarization): + """Solve angle vectors containing an exact ``q = 0`` wavevector.""" + # Propagate a substrate radiation-condition state upward. For p + # polarization, scaling [F, D] as [q / n**2, 1] avoids the singular + # admittance n**2 / q and has the finite critical-angle limit [0, 1]. + if polarization == "s": + field_top = np.ones(q.shape[1], dtype=np.complex128) + derivative_top = q[-1].copy() + else: + field_top = q[-1] / n[-1] ** 2 + derivative_top = np.ones(q.shape[1], dtype=np.complex128) + for medium in range(len(n) - 2, 0, -1): + thickness = z_interfaces[medium - 1] - z_interfaces[medium] + field_top, derivative_top = _propagate_state_upward( + field_top, + derivative_top, + n[medium], + q[medium], + thickness, + polarization, + ) + + incident_admittance = _admittance(n[0], q[0], polarization) + normalization = 2.0 / ( + field_top + derivative_top / incident_admittance + ) + field_top *= normalization + derivative_top *= normalization + r_S = 0.5 * ( + field_top - derivative_top / incident_admittance + ) + + field_reference = np.empty_like(q) + derivative_reference = np.empty_like(q) + z_reference = np.empty(len(n), dtype=np.float64) + field_reference[0] = field_top + derivative_reference[0] = derivative_top + z_reference[0] = z_interfaces[0] + + field_at_interface = field_top + derivative_at_interface = derivative_top + for interface in range(len(n) - 1): + field_reference[interface + 1] = field_at_interface + derivative_reference[interface + 1] = derivative_at_interface + z_reference[interface + 1] = z_interfaces[interface] + if interface + 1 < len(n) - 1: + thickness = z_interfaces[interface] - z_interfaces[interface + 1] + field_at_interface, derivative_at_interface = _propagate_state( + field_at_interface, + derivative_at_interface, + n[interface + 1], + q[interface + 1], + thickness, + polarization, + ) + + if polarization == "s": + amplitude_difference = np.divide( + derivative_reference, + q, + out=np.zeros_like(derivative_reference), + where=q != 0.0, + ) + else: + amplitude_difference = ( + derivative_reference * q / n[:, np.newaxis] ** 2 + ) + A_plus = 0.5 * (field_reference + amplitude_difference) + A_minus = 0.5 * (field_reference - amplitude_difference) + A_plus[0] = 1.0 + A_minus[0] = r_S + A_plus[-1] = field_reference[-1] + A_minus[-1] = 0.0 + + psi = np.empty_like(q) + for ascending_index, z_value in enumerate(z_ascending): + medium = len(n) - 1 - ascending_index + depth = z_reference[medium] - z_value + psi[ascending_index], _ = _propagate_state( + field_reference[medium], + derivative_reference[medium], + n[medium], + q[medium], + depth, + polarization, + ) + return ( + A_plus, + A_minus, + z_reference, + psi, + r_S, + field_reference[-1], + ) + + +def _specular_reflection( + profile, energy_eV, alpha, polarization, boundaries=None +): + """Return reflection amplitudes for all angles in one vectorized solve.""" + profile = np.asarray(profile, dtype=np.float64) + alpha = np.asarray(alpha, dtype=np.float64) + if profile.ndim != 2 or profile.shape[1] != 3 or len(profile) < 2: + raise ValueError("profile must be an (N, 3) array with at least two rows.") + if alpha.ndim != 1: + raise ValueError("alpha must be one-dimensional.") + if not np.all(np.isfinite(alpha)) or np.any(alpha <= 0.0) or np.any( + alpha > 90.0 + ): + raise ValueError("alpha must contain values in (0, 90] degrees.") + if energy_eV <= 0.0: + raise ValueError("energy_eV must be positive.") + if polarization not in {"s", "p"}: + raise ValueError("polarization must be 's' or 'p'.") + + if boundaries is None: + boundaries = profile_boundaries(profile) + else: + boundaries = np.asarray(boundaries, dtype=np.float64) + wavelength = HC_KEV_ANGSTROM / (energy_eV * 1e-3) + k0 = 2.0 * np.pi / wavelength + n = np.ascontiguousarray( + (1.0 - profile[:, 1] - 1j * profile[:, 2])[::-1] + ) + z_interfaces = np.ascontiguousarray(boundaries[::-1]) + q = _normal_wavevectors(n, k0, np.deg2rad(alpha)) + reflection = np.empty(len(alpha), dtype=np.complex128) + critical = np.any(q == 0.0, axis=0) + + regular = ~critical + if np.any(regular): + all_regular = np.all(regular) + q_regular = q if all_regular else q[:, regular] + if polarization == "s": + interface_r = ( + q_regular[:-1] - q_regular[1:] + ) / (q_regular[:-1] + q_regular[1:]) + else: + n_squared = n**2 + interface_r = ( + n_squared[:-1, np.newaxis] * q_regular[1:] + - n_squared[1:, np.newaxis] * q_regular[:-1] + ) / ( + n_squared[:-1, np.newaxis] * q_regular[1:] + + n_squared[1:, np.newaxis] * q_regular[:-1] + ) + reflected = interface_r[-1].copy() + for interface in range(len(n) - 3, -1, -1): + thickness = ( + z_interfaces[interface] - z_interfaces[interface + 1] + ) + phase = np.exp( + -1j * q_regular[interface + 1] * thickness + ) + reflected_below = reflected * phase**2 + reflected = ( + interface_r[interface] + reflected_below + ) / (1.0 + interface_r[interface] * reflected_below) + if all_regular: + reflection[:] = reflected + else: + reflection[regular] = reflected + + if np.any(critical): + q_critical = q[:, critical] + if polarization == "s": + field_top = np.ones(np.count_nonzero(critical), dtype=np.complex128) + derivative_top = q_critical[-1].copy() + else: + field_top = q_critical[-1] / n[-1] ** 2 + derivative_top = np.ones( + np.count_nonzero(critical), dtype=np.complex128 + ) + for medium in range(len(n) - 2, 0, -1): + thickness = ( + z_interfaces[medium - 1] - z_interfaces[medium] + ) + field_top, derivative_top = _propagate_state_upward( + field_top, + derivative_top, + n[medium], + q_critical[medium], + thickness, + polarization, + ) + if polarization == "s": + incident_admittance = q_critical[0] + else: + incident_admittance = n[0] ** 2 / q_critical[0] + state_sum = field_top + derivative_top / incident_admittance + reflection[critical] = ( + field_top - derivative_top / incident_admittance + ) / state_sum + + return reflection + + +def solve_wavefield( + profile, energy_eV, alpha, polarization="s", boundaries=None +): + """Solve the layered unperturbed wavefield by stable layered propagation. + + The profile rows define optical media at layer centers. By default, their + interfaces are placed at adjacent-center midpoints. Explicit boundaries + preserve the original outer interfaces of a simplified profile. The first + and last rows represent the semi-infinite substrate and incident media. + Angles are glancing angles measured from the surface inside the incident + medium. The conserved tangential wavevector is therefore + ``k_x = n_0 k_0 cos(alpha)``, where ``n_0`` is the last profile row. + The propagated field and admittance-weighted derivative use their analytic + ``q_z = 0`` limits, including the p-polarized critical angle. + + :param numpy.ndarray profile: + ``(N, 3)`` optical profile with z in Angstrom, delta, and beta. + :param float energy_eV: + X-ray photon energy in eV. + :param float or numpy.ndarray alpha: + Glancing angle or angle array in degrees inside the incident medium. + For arrays, wavefield quantities have the layer axis first followed by + the original angle-array shape. + :param str polarization: + ``"s"`` or ``"p"``. + :param numpy.ndarray boundaries: + Optional ``N - 1`` interfaces in Angstrom, ordered from substrate to + ambient. Defaults to adjacent-center midpoints. + :returns: + Slab amplitudes and sampled electric field. + :rtype: Wavefield + :raises ValueError: + If the profile, energy, angle, or polarization is invalid. + """ + profile = np.asarray(profile, dtype=np.float64) + if profile.ndim != 2 or profile.shape[1] != 3 or len(profile) < 2: + raise ValueError("profile must be an (N, 3) array with at least two rows.") + default_boundaries = profile_boundaries(profile) + if boundaries is None: + boundaries = default_boundaries + else: + boundaries = np.asarray(boundaries, dtype=np.float64) + if boundaries.ndim != 1 or len(boundaries) != len(profile) - 1: + raise ValueError("boundaries must be a one-dimensional (N - 1) array.") + if not np.all(np.diff(boundaries) > 0.0): + raise ValueError("boundaries must be strictly increasing.") + if not np.all(profile[:-1, 0] < boundaries) or not np.all( + boundaries < profile[1:, 0] + ): + raise ValueError("each boundary must lie between adjacent layer centers.") + if energy_eV <= 0.0: + raise ValueError("energy_eV must be positive.") + alpha_array = np.asarray(alpha, dtype=np.float64) + scalar = alpha_array.ndim == 0 + if alpha_array.size == 0 or not np.all(np.isfinite(alpha_array)): + raise ValueError("alpha must contain finite angle values.") + if np.any(alpha_array <= 0.0) or np.any(alpha_array > 90.0): + raise ValueError("alpha must contain values in (0, 90] degrees.") + if polarization not in {"s", "p"}: + raise ValueError("polarization must be 's' or 'p'.") + + wavelength = HC_KEV_ANGSTROM / (energy_eV * 1e-3) + k0 = 2.0 * np.pi / wavelength + angle_shape = alpha_array.shape + alpha_rad = np.deg2rad(np.atleast_1d(alpha_array).ravel()) + + z_ascending = profile[:, 0] + n_ascending = 1.0 - profile[:, 1] - 1j * profile[:, 2] + n = np.ascontiguousarray(n_ascending[::-1]) + z_interfaces = np.ascontiguousarray(boundaries[::-1]) + q = _normal_wavevectors(n, k0, alpha_rad) + critical = np.any(q == 0.0, axis=0) + regular = ~critical + + if np.all(regular): + result = _solve_regular_wavefield( + n, q, z_interfaces, z_ascending, polarization + ) + elif np.all(critical): + result = _solve_critical_wavefield( + n, q, z_interfaces, z_ascending, polarization + ) + else: + A_plus = np.empty_like(q) + A_minus = np.empty_like(q) + psi = np.empty_like(q) + r_S = np.empty(q.shape[1], dtype=np.complex128) + t_S = np.empty(q.shape[1], dtype=np.complex128) + regular_result = _solve_regular_wavefield( + n, q[:, regular], z_interfaces, z_ascending, polarization + ) + critical_result = _solve_critical_wavefield( + n, q[:, critical], z_interfaces, z_ascending, polarization + ) + A_plus[:, regular], A_minus[:, regular], z_reference, psi[ + :, regular + ], r_S[regular], t_S[regular] = regular_result + A_plus[:, critical], A_minus[:, critical], _, psi[ + :, critical + ], r_S[critical], t_S[critical] = critical_result + result = A_plus, A_minus, z_reference, psi, r_S, t_S + A_plus, A_minus, z_reference, psi, r_S, t_S = result + + if scalar: + kz = -q[:, 0] + A_plus = A_plus[:, 0] + A_minus = A_minus[:, 0] + psi = psi[:, 0] + r_S = complex(r_S[0]) + t_S = complex(t_S[0]) + else: + field_shape = (len(n),) + angle_shape + kz = (-q).reshape(field_shape) + A_plus = A_plus.reshape(field_shape) + A_minus = A_minus.reshape(field_shape) + psi = psi.reshape(field_shape) + r_S = r_S.reshape(angle_shape) + t_S = t_S.reshape(angle_shape) + + return Wavefield( + z=np.ascontiguousarray(z_ascending), + psi=psi, + z_interfaces=z_interfaces, + z_reference=np.ascontiguousarray(z_reference), + n=n, + kz=np.ascontiguousarray(kz), + A_plus=np.ascontiguousarray(A_plus), + A_minus=np.ascontiguousarray(A_minus), + r_S=r_S, + t_S=t_S, + polarization=polarization, + ) diff --git a/orgui/datautils/xrayutils/CTRplotutil.py b/orgui/datautils/xrayutils/CTRplotutil.py index 03d6265..e640618 100644 --- a/orgui/datautils/xrayutils/CTRplotutil.py +++ b/orgui/datautils/xrayutils/CTRplotutil.py @@ -580,13 +580,30 @@ def calcAnglesZmode( phi=0.0, **keyargs, ): + """Calculate and store Vlieg z-mode angles in radians. + + :param HKLVlieg.VliegAngles vliegangles: + Angle calculator configured for the CTR reference lattice. + :param float fixedangle: + Fixed incidence or exit angle in rad. + :param str fixed: + Angle constraint: ``"in"``, ``"out"``, or ``"eq"``. + :param float chi: + Fixed chi angle in rad. + :param float phi: + Fixed phi angle in rad. + :returns: + Structured records containing alpha, delta, gamma, omega, chi, + and phi in rad. + :rtype: numpy.recarray + """ l = self.l # noqa: E741 h = self.harr k = self.karr hkl = np.vstack((h, k, l)) pos = vliegangles.anglesZmode( - hkl, fixedangle, fixed="in", chi=0, phi=0, **keyargs + hkl, fixedangle, fixed=fixed, chi=chi, phi=phi, **keyargs ) dt = np.dtype( [ @@ -806,6 +823,45 @@ def convertToF(self): for ctr in self: ctr.convertToF() + def calcAnglesZmode( + self, + vliegangles, + fixedangle=np.deg2rad(0.1), + fixed="in", + chi=0.0, + phi=0.0, + **keyargs, + ): + """Calculate and store Vlieg z-mode angles for every CTR. + + All angle arguments and returned angle records are in rad. + + :param HKLVlieg.VliegAngles vliegangles: + Angle calculator configured for the CTR reference lattice. + :param float fixedangle: + Fixed incidence or exit angle in rad. + :param str fixed: + Angle constraint: ``"in"``, ``"out"``, or ``"eq"``. + :param float chi: + Fixed chi angle in rad. + :param float phi: + Fixed phi angle in rad. + :returns: + One structured angle-record array per CTR. + :rtype: list[numpy.recarray] + """ + return [ + ctr.calcAnglesZmode( + vliegangles, + fixedangle=fixedangle, + fixed=fixed, + chi=chi, + phi=phi, + **keyargs, + ) + for ctr in self + ] + def generateAverage(self, step_size=None, **kwargs): """Creates a new CTRCollection with CTRs, which were individually averaged. diff --git a/orgui/datautils/xrayutils/CTRresolution.py b/orgui/datautils/xrayutils/CTRresolution.py new file mode 100644 index 0000000..0293ebd --- /dev/null +++ b/orgui/datautils/xrayutils/CTRresolution.py @@ -0,0 +1,387 @@ +# /*########################################################################## +# +# 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. +# +# ###########################################################################*/ +"""Optional one-dimensional resolution modeling for calculated CTRs. + +Resolution functions in this module act on intensity, ``abs(F)**2``, along +the CTR L direction. Results are converted back to effective amplitudes so +they remain compatible with :class:`~.CTRplotutil.CTRCollection`. +""" + +from abc import ABC, abstractmethod +import copy +from dataclasses import dataclass + +import numpy as np +from numpy.polynomial.hermite import hermgauss +from numpy.polynomial.legendre import leggauss + +from .CTRplotutil import CTR, CTRCollection + + +@dataclass(frozen=True) +class ResolutionFunction(ABC): + """Base interface for an L-direction CTR resolution function. + + The effective full width is + + ``delta_l_0 + delta_l_1 * abs(sin(gamma))``. + + The full central HKL coordinates and angle record are accepted by + :meth:`width` so future resolution models can depend on more than gamma. + + :param float delta_l_0: + Constant L-width contribution in r.l.u. + :param float delta_l_1: + Gamma-dependent L-width contribution in r.l.u. + """ + + delta_l_0: float + delta_l_1: float = 0.0 + + def __post_init__(self): + for name, value in ( + ("delta_l_0", self.delta_l_0), + ("delta_l_1", self.delta_l_1), + ): + if not np.isscalar(value) or not np.isfinite(value) or value < 0.0: + raise ValueError(f"{name} must be a finite, nonnegative scalar") + + def width(self, h, k, l, angles=None): # noqa: E741 + """Return the effective L width at the supplied CTR points. + + :param numpy.ndarray h: + H coordinates in r.l.u. + :param numpy.ndarray k: + K coordinates in r.l.u. + :param numpy.ndarray l: + L coordinates in r.l.u. + :param angles: + Optional structured angle array containing ``gamma`` in rad. + :returns: + Effective L widths in r.l.u., broadcast to the HKL shape. + :rtype: numpy.ndarray + :raises ValueError: + If HKL shapes are incompatible or gamma is required but missing. + """ + try: + h_array, k_array, l_array = np.broadcast_arrays( + np.asarray(h, dtype=np.float64), + np.asarray(k, dtype=np.float64), + np.asarray(l, dtype=np.float64), + ) + except ValueError as exc: + raise ValueError( + "H, K, and L coordinates must be broadcast-compatible" + ) from exc + + widths = np.full(l_array.shape, float(self.delta_l_0), dtype=np.float64) + if self.delta_l_1 == 0.0: + return widths + + if angles is None: + raise ValueError( + "Gamma-dependent resolution requires CTR angle records; " + "call CTRCollection.calcAnglesZmode first" + ) + try: + gamma = np.asarray(angles["gamma"], dtype=np.float64) + except (KeyError, TypeError, ValueError, IndexError) as exc: + raise ValueError( + "Gamma-dependent resolution requires an angle field named 'gamma'" + ) from exc + try: + gamma = np.broadcast_to(gamma, l_array.shape) + except ValueError as exc: + raise ValueError( + "Gamma angles must have the same shape as the CTR points" + ) from exc + if not np.all(np.isfinite(gamma)): + raise ValueError("Gamma angles must be finite and expressed in radians") + widths += self.delta_l_1 * np.abs(np.sin(gamma)) + return widths + + @abstractmethod + def weights(self, offsets, width): + """Return unnormalized kernel weights for L offsets in r.l.u.""" + + @abstractmethod + def quadrature(self, width, order): + """Return normalized L quadrature offsets and weights. + + :param numpy.ndarray width: + Effective widths in r.l.u. + :param int order: + Quadrature order. + :returns: + Tuple of offsets shaped ``width.shape + (order,)`` and normalized + one-dimensional quadrature weights. + :rtype: tuple[numpy.ndarray, numpy.ndarray] + """ + + +class BoxResolution(ResolutionFunction): + """Box resolution whose ``DeltaL`` is the full support width.""" + + def weights(self, offsets, width): + """Return one inside ``[-DeltaL / 2, DeltaL / 2]`` and zero outside.""" + offsets = np.asarray(offsets, dtype=np.float64) + width = np.asarray(width, dtype=np.float64) + return (np.abs(offsets) <= width / 2.0).astype(np.float64) + + def quadrature(self, width, order): + """Return Gauss-Legendre quadrature for the normalized box.""" + nodes, weights = leggauss(order) + width = np.asarray(width, dtype=np.float64) + offsets = width[..., np.newaxis] * nodes / 2.0 + return offsets, weights / 2.0 + + +class GaussianResolution(ResolutionFunction): + """Gaussian resolution whose ``DeltaL`` is its FWHM.""" + + def weights(self, offsets, width): + """Return Gaussian weights with the configured full width at half maximum.""" + offsets, width = np.broadcast_arrays( + np.asarray(offsets, dtype=np.float64), + np.asarray(width, dtype=np.float64), + ) + result = np.zeros(offsets.shape, dtype=np.float64) + nonzero = width > 0.0 + result[nonzero] = np.exp( + -4.0 + * np.log(2.0) + * (offsets[nonzero] / width[nonzero]) ** 2 + ) + result[~nonzero] = offsets[~nonzero] == 0.0 + return result + + def quadrature(self, width, order): + """Return Gauss-Hermite quadrature for the normalized Gaussian.""" + nodes, weights = hermgauss(order) + width = np.asarray(width, dtype=np.float64) + sigma = width / (2.0 * np.sqrt(2.0 * np.log(2.0))) + offsets = np.sqrt(2.0) * sigma[..., np.newaxis] * nodes + return offsets, weights / np.sqrt(np.pi) + + +def _validate_ctr_arrays(ctr, require_amplitudes): + l_array = np.asarray(ctr.l, dtype=np.float64) + h_array = np.asarray(ctr.harr, dtype=np.float64) + k_array = np.asarray(ctr.karr, dtype=np.float64) + if ( + l_array.ndim != 1 + or h_array.shape != l_array.shape + or k_array.shape != l_array.shape + ): + raise ValueError( + f"{ctr!r} must contain one-dimensional, equal-length HKL arrays" + ) + if ( + not np.all(np.isfinite(h_array)) + or not np.all(np.isfinite(k_array)) + or not np.all(np.isfinite(l_array)) + ): + raise ValueError(f"{ctr!r} contains non-finite HKL coordinates") + if np.unique(l_array).size != l_array.size: + raise ValueError(f"{ctr!r} contains duplicate L coordinates") + + if not require_amplitudes: + return h_array, k_array, l_array, None + + if not np.isrealobj(ctr.sfI): + raise ValueError(f"{ctr!r} amplitudes must be real") + amplitude = np.asarray(ctr.sfI, dtype=np.float64) + if amplitude.shape != l_array.shape: + raise ValueError(f"{ctr!r} amplitudes must have the same shape as L") + if not np.all(np.isfinite(amplitude)) or np.any(amplitude < 0.0): + raise ValueError(f"{ctr!r} amplitudes must be finite and nonnegative") + return h_array, k_array, l_array, amplitude + + +def _validate_widths(widths, shape): + widths = np.asarray(widths, dtype=np.float64) + try: + widths = np.broadcast_to(widths, shape) + except ValueError as exc: + raise ValueError("Resolution widths must match the CTR point shape") from exc + if not np.all(np.isfinite(widths)) or np.any(widths < 0.0): + raise ValueError("Effective resolution widths must be finite and nonnegative") + return widths + + +def _new_collection(ctrs, amplitudes): + result = CTRCollection(name=ctrs.name) + result.plotsett = copy.deepcopy(ctrs.plotsett) + result.plotkeyargs = copy.deepcopy(ctrs.plotkeyargs) + for source, amplitude in zip(ctrs, amplitudes): + target = CTR( + source.hk, + np.copy(source.l), + np.ascontiguousarray(amplitude), + err=None, + phi=None, + name=source.name, + ) + target.harr = np.copy(source.harr) + target.karr = np.copy(source.karr) + target.weight = source.weight + if hasattr(source, "angles"): + target.angles = copy.deepcopy(source.angles) + result.append(target) + return result + + +def fast_convolve(ctrs, resolution): + """Convolve CTR intensity using only the existing irregular L points. + + No interpolation or equidistant resampling is performed. Local composite + trapezoidal weights account for unequal L spacing, and each kernel is + renormalized over the samples available at the rod boundaries. + + :param CTRCollection ctrs: + CTR amplitudes to convolve. Amplitudes must be finite and nonnegative. + :param ResolutionFunction resolution: + Box or Gaussian L-resolution function. + :returns: + A new collection containing effective amplitudes + ``sqrt(convolved(abs(F)**2))``. + :rtype: CTRCollection + """ + if not isinstance(ctrs, CTRCollection): + raise TypeError("ctrs must be a CTRCollection") + if not isinstance(resolution, ResolutionFunction): + raise TypeError("resolution must be a ResolutionFunction") + + convolved = [] + for ctr in ctrs: + h_array, k_array, l_array, amplitude = _validate_ctr_arrays( + ctr, require_amplitudes=True + ) + angles = getattr(ctr, "angles", None) + widths = _validate_widths( + resolution.width(h_array, k_array, l_array, angles), l_array.shape + ) + + if l_array.size < 2: + convolved.append(np.copy(amplitude)) + continue + + order = np.argsort(l_array) + l_sorted = l_array[order] + intensity = amplitude[order] ** 2 + widths_sorted = widths[order] + + integration_weights = np.empty_like(l_sorted) + integration_weights[0] = (l_sorted[1] - l_sorted[0]) / 2.0 + integration_weights[-1] = (l_sorted[-1] - l_sorted[-2]) / 2.0 + integration_weights[1:-1] = (l_sorted[2:] - l_sorted[:-2]) / 2.0 + + convolved_sorted = np.empty_like(intensity) + for index, (center, width) in enumerate(zip(l_sorted, widths_sorted)): + if width == 0.0: + convolved_sorted[index] = intensity[index] + continue + kernel_weights = resolution.weights(l_sorted - center, width) + combined_weights = kernel_weights * integration_weights + normalization = np.sum(combined_weights) + if not np.isfinite(normalization) or normalization <= 0.0: + raise ValueError(f"Resolution kernel has no support for {ctr!r}") + convolved_sorted[index] = ( + np.sum(combined_weights * intensity) / normalization + ) + + convolved_rod = np.empty_like(convolved_sorted) + convolved_rod[order] = np.sqrt(np.maximum(convolved_sorted, 0.0)) + convolved.append(convolved_rod) + + return _new_collection(ctrs, convolved) + + +def sample_structure_factor(ctrs, crystal, resolution, quadrature_order=25): + """Sample crystal intensity around every CTR point along L. + + H and K remain fixed. Box functions use Gauss-Legendre quadrature and + Gaussian functions use Gauss-Hermite quadrature. The effective width is + evaluated from each central point and its central angle record. + + :param CTRCollection ctrs: + Collection supplying the requested HKL points and optional angles. + :param crystal: + Crystal-like object providing ``F(h, k, l)``. + :param ResolutionFunction resolution: + Box or Gaussian L-resolution function. + :param int quadrature_order: + Positive odd number of deterministic quadrature points. Defaults to + 25. + :returns: + A new collection containing effective amplitudes + ``sqrt(integrated(abs(F)**2))``. + :rtype: CTRCollection + """ + if not isinstance(ctrs, CTRCollection): + raise TypeError("ctrs must be a CTRCollection") + if not isinstance(resolution, ResolutionFunction): + raise TypeError("resolution must be a ResolutionFunction") + if not hasattr(crystal, "F") or not callable(crystal.F): + raise TypeError("crystal must provide a callable F(h, k, l) method") + if ( + not isinstance(quadrature_order, int | np.integer) + or isinstance(quadrature_order, bool | np.bool_) + or quadrature_order <= 0 + or quadrature_order % 2 == 0 + ): + raise ValueError("quadrature_order must be a positive odd integer") + + sampled = [] + for ctr in ctrs: + h_array, k_array, l_array, _ = _validate_ctr_arrays( + ctr, require_amplitudes=False + ) + angles = getattr(ctr, "angles", None) + widths = _validate_widths( + resolution.width(h_array, k_array, l_array, angles), l_array.shape + ) + offsets, integration_weights = resolution.quadrature( + widths, int(quadrature_order) + ) + sample_shape = offsets.shape + h_samples = np.broadcast_to(h_array[..., np.newaxis], sample_shape) + k_samples = np.broadcast_to(k_array[..., np.newaxis], sample_shape) + l_samples = l_array[..., np.newaxis] + offsets + structure_factor = np.asarray( + crystal.F(h_samples.ravel(), k_samples.ravel(), l_samples.ravel()) + ) + try: + structure_factor = np.broadcast_to( + structure_factor, (l_samples.size,) + ).reshape(sample_shape) + except ValueError as exc: + raise ValueError("crystal.F returned an incompatible array shape") from exc + intensity = np.abs(structure_factor) ** 2 + if not np.all(np.isfinite(intensity)): + raise ValueError("crystal.F returned non-finite structure factors") + integrated = np.sum(intensity * integration_weights, axis=-1) + sampled.append(np.sqrt(np.maximum(integrated, 0.0))) + + return _new_collection(ctrs, sampled) diff --git a/orgui/datautils/xrayutils/CTRsymmetry.py b/orgui/datautils/xrayutils/CTRsymmetry.py index e11b570..69fb344 100644 --- a/orgui/datautils/xrayutils/CTRsymmetry.py +++ b/orgui/datautils/xrayutils/CTRsymmetry.py @@ -46,6 +46,8 @@ "surface_transform:", "wyckoff_sites:", "wyckoff_atoms:", + "wyckoff_coupling_matrices:", + "wyckoff_site_coupling_matrices:", "wyckoff_couplings:", "wyckoff_site_couplings:", } @@ -392,6 +394,9 @@ def wyckoff_sites(self, parameters=None): "element": site.element, "wyckoff_label": site.wyckoff_label, "variables": variables, + "occ": site.occ, + "iDW": site.iDW, + "oDW": site.oDW, "representative_parent_fractional": ( None if site.representative_parent_fractional is None @@ -965,10 +970,35 @@ def symmetry_metadata_to_lines(model): ] ) ) + coordinate_matrices, coordinate_matrix_ids = _compact_coupling_matrices(model) + site_matrices, site_matrix_ids = _compact_site_coupling_matrices(model) + lines.append("wyckoff_coupling_matrices:") + lines.append(" matrix_id variables factors_by_surface_coordinate") + for matrix_id, variables, matrix in coordinate_matrices: + lines.append( + " " + + " ".join( + [ + matrix_id, + ",".join(variables), + *_format_values(matrix.ravel()).split(), + ] + ) + ) + lines.append("wyckoff_site_coupling_matrices:") + lines.append(" matrix_id parent_to_surface_jacobian") + for matrix_id, matrix in site_matrices: + lines.append( + " " + + " ".join( + [matrix_id, *_format_values(matrix.ravel()).split()] + ) + ) lines.append("wyckoff_atoms:") lines.append( " atom_index element site_id wyckoff_label " - "parent_x parent_y parent_z surface_x surface_y surface_z layer" + "parent_x parent_y parent_z surface_x surface_y surface_z layer " + "coupling_matrix site_coupling_matrix" ) for atom in model.atoms: values = [ @@ -979,42 +1009,64 @@ def symmetry_metadata_to_lines(model): *_format_values(atom.parent_fractional).split(), *_format_values(atom.surface_fractional).split(), _format_float(atom.layer), + coordinate_matrix_ids.get(atom.atom_index, "-"), + site_matrix_ids.get(atom.atom_index, "-"), ] lines.append(" " + " ".join(values)) - lines.append("wyckoff_couplings:") - lines.append(" atom_index coordinate site_id variable constant factor") - for coupling in model.wyckoff_couplings(): - lines.append( - " " - + " ".join( - [ - str(coupling.atom_index), - coupling.coordinate, - coupling.site_id, - coupling.variable, - _format_float(coupling.constant), - _format_float(coupling.factor), - ] - ) - ) - lines.append("wyckoff_site_couplings:") - lines.append(" atom_index coordinate site_id axis factor") - for coupling in model.wyckoff_site_couplings(): - lines.append( - " " - + " ".join( - [ - str(coupling.atom_index), - coupling.coordinate, - coupling.site_id, - coupling.axis, - _format_float(coupling.factor), - ] - ) - ) return lines +def _compact_coupling_matrices(model): + matrices = [] + matrix_ids = {} + known = {} + sites = {site.site_id: site for site in model.sites} + coordinate_index = {name: index for index, name in enumerate(COORDINATE_NAMES)} + for atom in model.atoms: + variables = tuple(sites[atom.site_id].variables) + if not atom.couplings: + continue + variable_index = {name: index for index, name in enumerate(variables)} + matrix = np.zeros((3, len(variables)), dtype=np.float64) + for coupling in atom.couplings: + matrix[ + coordinate_index[coupling.coordinate], + variable_index[coupling.variable], + ] = coupling.factor + key = (variables, tuple(_format_float(value) for value in matrix.ravel())) + matrix_id = known.get(key) + if matrix_id is None: + matrix_id = f"c{len(matrices)}" + known[key] = matrix_id + matrices.append((matrix_id, variables, matrix)) + matrix_ids[atom.atom_index] = matrix_id + return matrices, matrix_ids + + +def _compact_site_coupling_matrices(model): + matrices = [] + matrix_ids = {} + known = {} + coordinate_index = {name: index for index, name in enumerate(COORDINATE_NAMES)} + for atom in model.atoms: + if not atom.site_couplings: + continue + matrix = np.zeros((3, 3), dtype=np.float64) + for coupling in atom.site_couplings: + matrix[ + coordinate_index[coupling.coordinate], + coordinate_index[coupling.axis], + ] = coupling.factor + key = tuple(_format_float(value) for value in matrix.ravel()) + matrix_id = known.get(key) + if matrix_id is None: + matrix_id = f"s{len(matrices)}" + known[key] = matrix_id + matrices.append((matrix_id, matrix)) + matrix_ids[atom.atom_index] = matrix_id + return matrices, matrix_ids + + def symmetry_metadata_from_lines(lines, unitcell=None): """Deserialize plain-text table lines into a symmetry metadata model. @@ -1042,6 +1094,8 @@ def symmetry_metadata_from_lines(lines, unitcell=None): atoms = [] couplings_by_atom = {} site_couplings_by_atom = {} + coupling_matrices = {} + site_coupling_matrices = {} sections = _collect_sections(cleaned) if "spacegroup" in sections: @@ -1102,6 +1156,21 @@ def symmetry_metadata_from_lines(lines, unitcell=None): ) ) + for row in _data_rows(sections.get("wyckoff_coupling_matrices", [])): + parts = row.split() + variables = tuple(parts[1].split(",")) + coupling_matrices[parts[0]] = ( + variables, + np.asarray(parts[2:], dtype=np.float64).reshape(3, len(variables)), + ) + + for row in _data_rows(sections.get("wyckoff_site_coupling_matrices", [])): + parts = row.split() + site_coupling_matrices[parts[0]] = np.asarray( + parts[1:], + dtype=np.float64, + ).reshape(3, 3) + for row in _data_rows(sections.get("wyckoff_couplings", [])): parts = row.split() coupling = WyckoffCoupling( @@ -1128,14 +1197,55 @@ def symmetry_metadata_from_lines(lines, unitcell=None): for row in _data_rows(sections.get("wyckoff_atoms", [])): parts = row.split() atom_index = int(parts[0]) + site_id = parts[2] + surface_fractional = np.asarray(parts[7:10], dtype=np.float64) + if len(parts) >= 13 and parts[11] != "-": + variables, matrix = coupling_matrices[parts[11]] + site = next(site for site in site_specs if site.site_id == site_id) + variable_values = np.asarray( + [site.variables[variable] for variable in variables], + dtype=np.float64, + ) + for coordinate_index, coordinate in enumerate(COORDINATE_NAMES): + constant = surface_fractional[coordinate_index] - ( + matrix[coordinate_index] @ variable_values + ) + for variable_index, variable in enumerate(variables): + factor = matrix[coordinate_index, variable_index] + if not math.isclose(factor, 0.0, abs_tol=1e-12): + couplings_by_atom.setdefault(atom_index, []).append( + WyckoffCoupling( + atom_index=atom_index, + coordinate=coordinate, + variable=variable, + constant=float(constant), + factor=float(factor), + site_id=site_id, + ) + ) + if len(parts) >= 13 and parts[12] != "-": + matrix = site_coupling_matrices[parts[12]] + for coordinate_index, coordinate in enumerate(COORDINATE_NAMES): + for axis_index, axis in enumerate(COORDINATE_NAMES): + factor = matrix[coordinate_index, axis_index] + if not math.isclose(factor, 0.0, abs_tol=1e-12): + site_couplings_by_atom.setdefault(atom_index, []).append( + WyckoffSiteCoupling( + atom_index=atom_index, + coordinate=coordinate, + axis=axis, + factor=float(factor), + site_id=site_id, + ) + ) atoms.append( GeneratedWyckoffAtom( atom_index=atom_index, element=parts[1], - site_id=parts[2], + site_id=site_id, wyckoff_label=parts[3], parent_fractional=np.asarray(parts[4:7], dtype=np.float64), - surface_fractional=np.asarray(parts[7:10], dtype=np.float64), + surface_fractional=surface_fractional, layer=int(float(parts[10])), couplings=tuple(couplings_by_atom.get(atom_index, ())), site_couplings=tuple(site_couplings_by_atom.get(atom_index, ())), @@ -1423,6 +1533,7 @@ def _data_rows(rows): if stripped.split()[0] in { "site_id", "atom_index", + "matrix_id", }: continue yield stripped diff --git a/orgui/datautils/xrayutils/CTRuc.py b/orgui/datautils/xrayutils/CTRuc.py index 964e941..bb4418b 100644 --- a/orgui/datautils/xrayutils/CTRuc.py +++ b/orgui/datautils/xrayutils/CTRuc.py @@ -73,6 +73,112 @@ resolve_upper_start, ) + +_PLOT3D_BACKENDS = ("mayavi", "py3dmol") + + +def _in_jupyter_kernel(): + """Return whether the current process is attached to an IPython kernel.""" + try: + from IPython import get_ipython + except ImportError: + return False + + shell = get_ipython() + return shell is not None and getattr(shell, "kernel", None) is not None + + +def _figure_plot3d_backend(figure): + """Identify an existing plot figure without importing optional backends.""" + backend = getattr(figure, "_orgui_plot3d_backend", None) + if backend in _PLOT3D_BACKENDS: + return backend + + module = type(figure).__module__.lower() + if "py3dmol" in module: + return "py3dmol" + if "mayavi" in module or "tvtk" in module: + return "mayavi" + return None + + +def _prepare_plot3d_figure(backend, figure): + """Resolve a 3D plotting backend and return its figure and renderer.""" + backend = backend.lower() + if backend not in (*_PLOT3D_BACKENDS, "auto"): + raise ValueError( + f"Unknown 3D plotting backend {backend!r}; expected 'auto', " + "'mayavi', or 'py3dmol'." + ) + + figure_backend = None + if figure is not None: + figure_backend = _figure_plot3d_backend(figure) + if backend != "auto" and figure_backend not in (None, backend): + raise TypeError( + f"The supplied figure uses {figure_backend}, not the requested " + f"{backend} backend." + ) + + if backend == "auto": + if figure_backend is not None: + candidates = (figure_backend,) + elif figure is not None: + # Existing callers historically pass Mayavi scene objects. + candidates = ("mayavi",) + elif _in_jupyter_kernel(): + candidates = ("py3dmol", "mayavi") + else: + candidates = ("mayavi", "py3dmol") + else: + candidates = (backend,) + + errors = {} + for candidate in candidates: + try: + if candidate == "py3dmol": + if figure is None: + py3Dmol = importlib.import_module("py3Dmol") + figure = py3Dmol.view() + renderer = None + else: + renderer = importlib.import_module("mayavi.mlab") + if figure is None: + figure = renderer.figure() + except ImportError as error: + errors[candidate] = error + continue + + try: + figure._orgui_plot3d_backend = candidate + except AttributeError: + pass + return candidate, figure, renderer + + if backend == "auto": + message = ( + "No supported 3D plotting backend is installed. Install py3Dmol " + "(included in orGUI[full]) for notebooks or Mayavi for desktop use." + ) + elif backend == "py3dmol": + message = ( + "The py3dmol backend requires py3Dmol. Install orGUI[full] or " + "install py3Dmol separately." + ) + else: + message = "The mayavi backend requires Mayavi." + raise ImportError(message) from next(iter(errors.values()), None) + + +def _rgb_to_hex(color): + """Convert an RGB triple in the Mayavi range to a CSS hexadecimal color.""" + rgb = np.asarray(color, dtype=np.float64) + if rgb.shape != (3,): + raise ValueError("Atom colors must be RGB triples.") + rgb = np.clip(np.rint(rgb * 255), 0, 255).astype(np.uint8) + return "#" + "".join(f"{channel:02x}" for channel in rgb) + + def _import_cpp_accel(): try: return importlib.import_module("orgui.datautils.xrayutils._CTRcalc_cpp") @@ -179,6 +285,72 @@ def ctr_accel_enabled(): return CTR_ACCEL_BACKEND != "numpy" +def form_factor_cache_stats(): + """Return process-global C++ Waasmaier form-factor cache statistics. + + The cache is used only by accelerated :meth:`UnitCell.F_uc` calls. Its + resident-byte accounting includes retained float64 Q-squared and real + form-factor vectors, but not short-lived call snapshots or output arrays. + Reuse requires bitwise-identical computed Q-squared arrays and complete + 13-value scattering rows; numerically near grids intentionally miss. + + :returns: Cache hits, misses, evictions, capacity, and residency details. + :rtype: dict + :raises RuntimeError: If the C++ acceleration module is unavailable. + """ + if not HAS_CPP_ACCEL: + raise RuntimeError("C++ form-factor cache is not available") + return _CTRcalc_cpp.form_factor_cache_stats() + + +def clear_form_factor_cache(): + """Clear all process-global C++ Waasmaier form-factor cache entries.""" + if not HAS_CPP_ACCEL: + raise RuntimeError("C++ form-factor cache is not available") + _CTRcalc_cpp.clear_form_factor_cache() + + +def reset_form_factor_cache_stats(): + """Reset process-global C++ Waasmaier form-factor cache counters. + + Retained entries and the configured capacity are unchanged. Pair this + with :func:`clear_form_factor_cache` to start a measurement from an empty + cache and zero counters. + """ + if not HAS_CPP_ACCEL: + raise RuntimeError("C++ form-factor cache is not available") + _CTRcalc_cpp.reset_form_factor_cache_stats() + + +def set_form_factor_cache_budget(bytes): + """Set the process-global C++ form-factor cache capacity in bytes. + + :param int bytes: Non-negative cache residency limit in bytes. + :raises RuntimeError: If the C++ acceleration module is unavailable. + :raises ValueError: If ``bytes`` is negative. + """ + if bytes < 0: + raise ValueError("form-factor cache budget must be non-negative") + if not HAS_CPP_ACCEL: + raise RuntimeError("C++ form-factor cache is not available") + _CTRcalc_cpp.set_form_factor_cache_budget(bytes) + + +def form_factor_cache_expected_bytes(points, species): + """Return retained float64 bytes for one matching Q-squared grid. + + :param int points: Number of reflections in the Q-squared vector. + :param int species: Number of distinct complete 13-value scattering rows. + :returns: Q-grid plus one real form-factor vector per species. + :rtype: int + """ + if points < 0 or species < 0: + raise ValueError("points and species must be non-negative") + if not HAS_CPP_ACCEL: + raise RuntimeError("C++ form-factor cache is not available") + return _CTRcalc_cpp.form_factor_cache_expected_bytes(points, species) + + def _ctr_accel_module(): if CTR_ACCEL_BACKEND == "numpy": return None @@ -191,6 +363,20 @@ def _ctr_accel_module(): ) +def _coherent_domain_arrays(coherent_domain_matrix, coherent_domain_occupancy): + """Return accel-ready coherent domain arrays, handling zero domains. + + ``np.asarray`` on an empty list collapses to shape ``(0,)`` instead of + the ``(0, 3, 4)`` the accel bindings require, so the empty case is + reshaped explicitly. + """ + matrix = np.asarray(coherent_domain_matrix) + if matrix.size == 0: + matrix = matrix.reshape((0, 3, 4)) + occupancy = np.asarray(coherent_domain_occupancy) + return matrix, occupancy + + class WaterModel(Lattice, LinearFitFunctions): # path = os.path.split(__file__)[0] @@ -273,6 +459,50 @@ def pos_absolute(self): def pos_absolute(self, pos): self._pos_absolute = pos + @property + def height_absolute(self): + """Return the water onset height in Angstrom. + + Water is continuous towards positive z and therefore does not advance + the structural stacking cursor beyond its onset. + """ + return self.pos_absolute + + @property + def loc_absolute(self): + """Return the water onset location in Angstrom.""" + return self.pos_absolute + + @property + def stacking_height_absolute(self): + """Return the height passed to components above the water model.""" + return self.pos_absolute + + @property + def stacking_loc_absolute(self): + """Return the location passed to components above the water model.""" + return self.pos_absolute + + @property + def end_layer_number(self): + """Preserve the structural layer number below continuous water.""" + return getattr(self, "start_layer_number", -1) + + def stack_on(self, below_loc, below_height, below_layer=-1, below_state=None): + """Place the water onset at the top of the object below it. + + :param float below_loc: + Reference location of the object below in Angstrom. + :param float below_height: + Top height of the object below in Angstrom. + :param float below_layer: + Top structural layer identifier of the object below. + :param below_state: + Accepted for compatibility with other stackable components. + """ + self.start_layer_number = below_layer + self.pos_absolute = below_height + def F_uc(self, h, k, l): # noqa: E741 """Return the water-model structure factor in electrons. @@ -443,6 +673,32 @@ def lookupScatteringFactors(self, E): for i, name in enumerate(["O", "H", "O2-"]): self.f[i, :11] = readWaasmaier(name) self.f[i, 11:] = readDispersion(name, E) + self.wat_dispersion = ( + self.f[0, 11] + + 2.0 * self.f[1, 11] + + 1j * (self.f[0, 12] + 2.0 * self.f[1, 12]) + ) + + def optical_profile(self, noUC=30, z_step=None, z_origin=None): + """Return continuous water optical constants sampled towards positive z. + + :param int noUC: + Length of the sampled water region in water-model unit cells. + :param float z_step: + Uniform z sampling interval in Angstrom. Defaults to this model's + lattice height. + :param float z_origin: + Optional atomistic-grid origin in Angstrom used to align samples. + :returns: + ``(N, 3)`` float64 array containing z in Angstrom, delta, and + beta. + :rtype: numpy.ndarray + """ + from .CTRoptics import water_optical_profile + + return water_optical_profile( + self, noUC=noUC, z_step=z_step, z_origin=z_origin + ) # f1,f2 = UnitCell.special_formfactors['H2O'][1](E) # self.wat_dispersion = f1 + 1j*f2 @@ -511,9 +767,9 @@ def zDensity_G(self, z, h, k): + erf((z - zpos * self._a[2]) / (np.sqrt(2) * sigma_0 * self._a[2])) ) ) - rho = gaussian_filter1d(np.abs(rho), 0.2547 / zstep_mean).astype( - np.complex128 - ) # estimation of water molecular form factor + rho_real = gaussian_filter1d(rho.real, 0.2547 / zstep_mean) + rho_imag = gaussian_filter1d(rho.imag, 0.2547 / zstep_mean) + rho = rho_real + 1j * rho_imag # estimation of water molecular form factor elif self.type == "layered": zrange_waterstructure = np.amax(z) / self._a[2] - zpos # lattice units nogausseans_inrange = np.ceil(zrange_waterstructure / d_layering) @@ -582,9 +838,9 @@ def zDensity_G(self, z, h, k): + erf((z - zpos * self._a[2]) / (np.sqrt(2) * sigma_0 * self._a[2])) ) ) - rho = gaussian_filter1d(np.abs(rho), 0.2547 / zstep_mean).astype( - np.complex128 - ) # estimation of water molecular form factor + rho_real = gaussian_filter1d(rho.real, 0.2547 / zstep_mean) + rho_imag = gaussian_filter1d(rho.imag, 0.2547 / zstep_mean) + rho = rho_real + 1j * rho_imag # estimation of water molecular form factor rho_layer, mu_offset = self._1layer_firstGauss() rho += rho_layer * np.exp( -((z / self._a[2] - zpos - mu_offset) ** 2) / (2 * sigma2_rel) @@ -801,9 +1057,24 @@ def fromStr(string): class UnitCell(Lattice): - parameterOrder = ( - "Name x/frac y/frac z/frac iDW oDW occup layerIdx" + _atom_column_names = ( + "Name", + "x/frac", + "y/frac", + "z/frac", + "iDW", + "oDW", + "occup", + "layerIdx", ) + _atom_column_widths = (6, 12, 12, 12, 10, 10, 10, 10) + _atom_error_column_widths = (6, 24, 24, 24, 22, 22, 22, 14) + parameterOrder = "".join( + f"{name:<{width}}" if index == 0 else f"{name:>{width}}" + for index, (name, width) in enumerate( + zip(_atom_column_names, _atom_column_widths) + ) + ).rstrip() parameterLookup = {"x": 1, "y": 2, "z": 3, "iDW": 4, "oDW": 5, "occ": 6, "layer": 7} @@ -928,7 +1199,8 @@ def updateFromParameters(self): no_errors = True for par in self.parameters["relative"]: if par.value is not None: - self.basis[par.indices] += par.factors * par.value + value = self._relative_internal_value(par, par.value) + self.basis[par.indices] += par.factors * value else: raise ValueError( f"Can not set basis values from parameters. Value of Parameter {par.name} is None." # noqa: E501 @@ -945,6 +1217,14 @@ def updateFromParameters(self): if not no_errors: self._errors_parvalues = np.copy(self.errors) + @staticmethod + def _relative_internal_value(parameter, value): + """Convert an exposed parameter value to its internal relative delta.""" + wyckoff = parameter.settings.get("wyckoff", {}) + if wyckoff.get("value_kind") == "absolute": + return value - wyckoff["reference_value"] + return value + def clearParameters(self): for p in self.parameters: self.parameters[p] = [] @@ -1077,12 +1357,34 @@ def insertAtom( """ def split_in_layers(self, ordered=True): + """Return structural layers as views into a contiguously ordered basis. + + Layered ``UnitCell`` objects require atoms in each layer to occupy one + contiguous basis block. Native CTR text models must already satisfy + this invariant. CIF files imported through ASE are the sole exception: + their atom-site order is not crystallographically significant, so their + basis may be normalized here when necessary. + """ layer_numbers = np.sort(np.unique(self.basis[:, 7])) + noncontiguous_layers = [] + for layer in layer_numbers: + indices = np.flatnonzero(self.basis[:, 7] == layer) + if indices.size > 1 and np.any(np.diff(indices) != 1): + noncontiguous_layers.append(layer) + if noncontiguous_layers: + if not getattr(self, "_allow_layer_order_normalization", False): + raise ValueError( + "UnitCell layers must occupy contiguous basis rows; " + f"noncontiguous layers: {noncontiguous_layers!r}. " + "Native .xtal/.xpr and manually constructed UnitCells " + "must be written in layer order." + ) + self._reorder_atoms(np.argsort(self.basis[:, 7], kind="stable")) + layers = OrderedDict() - if not hasattr(self, "f"): - self.setEnergy( - 10000.0 - ) # populate f with some values to enable in-place modification + has_form_factors = ( + hasattr(self, "f") and self.f.shape == (self.basis.shape[0], 13) + ) for l in layer_numbers: # noqa: E741 where = (self.basis[:, 7] == l).nonzero()[0] @@ -1096,13 +1398,16 @@ def split_in_layers(self, ordered=True): uc.basis_0 = self.basis_0[idx_low:idx_high] uc.dw_increase_constraint = self.dw_increase_constraint[idx_low:idx_high] uc._test_special_formfactors() - uc.f = self.f[idx_low:idx_high] + if has_form_factors: + # Each layer owns its array: layer-level fitting must not alter + # its parent or sibling form factors. + uc.f = np.array(self.f[idx_low:idx_high], copy=True) + if has_form_factors and hasattr(self, "_E"): + uc._E = self._E + uc._formfactor_state = uc._formfactor_state_token() uc.refRealTransform = self.refRealTransform uc.refHKLTransform = self.refHKLTransform layers[l] = uc - if len(layer_numbers) == 1: - return layers - if self._explicit_layer_cycle is not None: cycle = tuple(self._explicit_layer_cycle) missing = set(layers) - set(cycle) @@ -1111,7 +1416,7 @@ def split_in_layers(self, ordered=True): f"layer_cycle omits unit-cell layers {sorted(missing)!r}" ) layers = OrderedDict((n, layers[n]) for n in cycle) - elif ordered: + elif ordered and len(layer_numbers) > 1: avg_height = [] uc_nms = [] for uc_l in layers: @@ -1124,8 +1429,58 @@ def split_in_layers(self, ordered=True): layers = OrderedDict([(n, layers[n]) for n in uc_nms]) + ordered_layers = tuple(layers) + origins = [] + for layer in ordered_layers: + origin = self.layerpos.get(float(layer)) + if origin is None: + atom_mask = self.basis[:, 7] == layer + origin = float(np.mean(self.basis[atom_mask, 3])) + origins.append(float(origin)) + for index, layer in enumerate(ordered_layers): + spacing = origins[(index + 1) % len(ordered_layers)] - origins[index] + while spacing <= 0.0: + spacing += 1.0 + layers[layer]._optical_layer_origin = origins[index] + layers[layer]._optical_layer_thickness_fraction = spacing + return layers + def optical_profile(self): + """Return homogeneous optical constants for every layer and domain. + + The returned ``(N, 3)`` float64 array has columns ``z`` in Angstrom, + ``delta``, and ``beta``. Each row is a domain-transformed layer + contribution with coherent-domain occupancy already applied. + + :returns: + One row for every structural layer and coherent domain. + :rtype: numpy.ndarray + :raises ValueError: + If :meth:`setEnergy` has not populated the anomalous scattering + factors. + """ + from .CTRoptics import optical_profile + + return optical_profile(self) + + def optical_profile_asbulk(self, noUC=30): + """Return a finite representation of the semi-infinite bulk profile. + + The unit cell is repeated towards negative z exactly as in + :meth:`zDensity_G_asbulk`. + + :param int noUC: + Number of unit cells to represent below the termination. + :returns: + ``(N, 3)`` float64 array containing z in Angstrom, delta, and + beta. + :rtype: numpy.ndarray + """ + from .CTRoptics import optical_profile_asbulk + + return optical_profile_asbulk(self, noUC=noUC) + @property def layers(self): return np.sort(np.unique(self.basis[:, 7])) @@ -1178,6 +1533,47 @@ def _remap_parameter_atom_indices(parameter, old_to_new): atoms = np.asarray(atoms, dtype=np.intp) parameter.indices = (old_to_new[atoms], parindexes) + def _reorder_atoms(self, order): + """Reorder atom-aligned state and remap atom-indexed metadata. + + :param numpy.ndarray order: + Permutation mapping new basis rows to their old row indices. + """ + order = np.asarray(order, dtype=np.intp) + atom_count = self.basis.shape[0] + if order.shape != (atom_count,) or not np.array_equal( + np.sort(order), np.arange(atom_count, dtype=np.intp) + ): + raise ValueError("order must be a permutation of UnitCell atom indices.") + + old_to_new = np.empty(atom_count, dtype=np.intp) + old_to_new[order] = np.arange(atom_count, dtype=np.intp) + for attribute in ( + "basis", + "basis_0", + "_basis_parvalues", + "errors", + "_errors_parvalues", + "f", + "dw_increase_constraint", + ): + values = getattr(self, attribute, None) + if values is not None and np.size(values): + setattr(self, attribute, np.asarray(values)[order]) + self.names = [self.names[index] for index in order] + + for parameters in self.parameters.values(): + for parameter in parameters: + self._remap_parameter_atom_indices(parameter, old_to_new) + layer_map = {layer: layer for layer in np.unique(self.basis[:, 7])} + self.symmetry_metadata = self._transform_symmetry_metadata( + self.symmetry_metadata, + old_to_new, + layer_map, + np.zeros(2, dtype=np.float64), + {layer: 0.0 for layer in layer_map}, + ) + @staticmethod def _update_absolute_parameter_values_after_basis_transform( transformed, @@ -1612,6 +2008,59 @@ def affine_layer_transform(self, translation, name=None): transformed._explicit_layer_cycle = tuple(cycle) return transformed + def as_surface_termination(self, layer, name=None, origin=None): + """Return a copy whose complete structure is one surface termination. + + The atomic ``layer`` column is used only as a stacking/termination + selector in the returned cell. All atoms therefore receive the same + layer identifier, while their fractional coordinates and fit + parameters remain unchanged. This permits a multi-layer relaxed slab + to be selected as one termination with ``layer_behavior='select'``. + + :param float layer: + Layer identifier in the primitive Film stacking cycle. + :param str name: + Optional name for the returned unit cell. + :param float origin: + Fractional z coordinate in this cell that is placed at the exposed + terrace height. By default the highest structural-layer origin is + used. + :returns: + Independent termination-cell copy. + :rtype: UnitCell + """ + transformed = copy.deepcopy(self) + if name is not None: + transformed.name = name + layer = float(layer) + if origin is None: + origins = list(self.layerpos.values()) + if origins: + origin = max(origins) + elif self.basis.size: + origin = float(np.max(self.basis[:, 3])) + else: + origin = 0.0 + origin = float(origin) + + for values in ( + transformed.basis, + transformed.basis_0, + transformed._basis_parvalues, + ): + if values is not None and values.size: + values[:, 7] = layer + if transformed.symmetry_metadata is not None: + transformed.symmetry_metadata.atoms = tuple( + dataclasses.replace(atom, layer=layer) + for atom in transformed.symmetry_metadata.atoms + ) + transformed.layerpos = {layer: origin} + transformed._explicit_layer_cycle = (layer,) + transformed.layer_behavior = "select" + transformed._start_layer = layer + return transformed + def supercell(self, repeats, symmetry="preserve", name=None): """Return a repeated unit-cell copy. @@ -2003,9 +2452,28 @@ def setEnergy(self, E): for dispersion and absorption correction in eV """ + shape = (self.basis.shape[0], 13) + state = self._formfactor_state_token() + if ( + getattr(self, "_E", None) == E + and hasattr(self, "f") + and self.f.shape == shape + and getattr(self, "_formfactor_state", None) == state + ): + return self._E = E self.lookupScatteringFactors(E) + def _formfactor_state_token(self): + """Return the species and special-callback state used by ``self.f``.""" + special = tuple( + (name, id(UnitCell.special_formfactors[name][0]), + id(UnitCell.special_formfactors[name][1])) + for name in self.names + if name in UnitCell.special_formfactors + ) + return tuple(self.names), special + def addFitParameter(self, indexarray, limits=(-np.inf, np.inf), **keyargs): """ @@ -2161,6 +2629,224 @@ def wyckoff_sites(self): return [] return self.symmetry_metadata.wyckoff_sites(self.parameters) + def wyckoff(self, site_id): + """Return metadata for one Wyckoff site. + + :param str site_id: + Site identifier returned by :meth:`wyckoff_sites`. + :returns: + The matching site metadata dictionary. + :rtype: + dict + :raises ValueError: + If this unit cell has no symmetry metadata or the site is unknown. + """ + return self._wyckoff_site(site_id) + + def set_wyckoff_atom_parameter(self, site_id, parameter, value): + """Set one field on every generated atom in a Wyckoff site. + + Coordinates are fractional coordinates in the surface unit cell. + Coordinate values may be a scalar, applied to every atom, or one value + per atom in the order reported by + ``wyckoff(site_id)["atom_indices"]``. The site-wide ``iDW``, ``oDW``, + and ``occ`` fields require a scalar. Both the active basis and its + unfitted baseline are updated. + + :param str site_id: + Site identifier returned by :meth:`wyckoff_sites`. + :param str parameter: + One of ``"x"``, ``"y"``, ``"z"``, ``"iDW"``, ``"oDW"``, + or ``"occ"``. + :param value: + Scalar value or one value per generated atom. + :raises ValueError: + If the parameter is invalid or the value cannot be broadcast to + the atoms in the site. + """ + allowed = {"x", "y", "z", "iDW", "oDW", "occ"} + if parameter not in allowed: + raise ValueError( + "parameter must be one of 'x', 'y', 'z', 'iDW', 'oDW', or 'occ'." + ) + site = self.wyckoff(site_id) + atoms = np.asarray(site["atom_indices"], dtype=np.intp) + if not len(atoms): + raise ValueError(f"Wyckoff site {site_id} has no generated atoms.") + value_array = np.asarray(value, dtype=np.float64) + if parameter in {"iDW", "oDW", "occ"} and value_array.ndim: + raise ValueError(f"{parameter} must be a scalar site-wide value.") + try: + values = np.broadcast_to( + value_array, + atoms.shape, + ) + except ValueError as error: + raise ValueError( + f"value must be scalar or contain one value for each of the " + f"{len(atoms)} atoms in Wyckoff site {site_id}." + ) from error + + column = self.parameterLookup[parameter] + self.basis_0[atoms, column] = values + self.basis[atoms, column] = values + self._update_wyckoff_metadata_from_atom_values( + site_id, + parameter, + atoms, + values, + ) + self._refresh_after_baseline_change() + + def set_wyckoff_site_parameter(self, site_id, parameter, value): + """Set one representative coordinate or site-wide physical parameter. + + An absolute parent conventional fractional coordinate is propagated to + all generated surface-cell atoms through their stored space-group and + surface-transform couplings. ``iDW``, ``oDW``, and ``occ`` are applied + as scalar values to every atom in the site. Both the active basis and + its unfitted baseline are updated. + + :param str site_id: + Site identifier returned by :meth:`wyckoff_sites`. + :param str parameter: + Parent conventional fractional axis ``"x"``, ``"y"``, or + ``"z"``; or site-wide ``"iDW"``, ``"oDW"``, or ``"occ"``. + :param float value: + New absolute coordinate in parent fractional units or scalar + physical parameter value. + :raises ValueError: + If the parameter is invalid or required symmetry metadata is absent. + """ + allowed = {"x", "y", "z", "iDW", "oDW", "occ"} + if parameter not in allowed: + raise ValueError( + "parameter must be one of 'x', 'y', 'z', 'iDW', 'oDW', or 'occ'." + ) + if parameter in {"iDW", "oDW", "occ"}: + self.set_wyckoff_atom_parameter(site_id, parameter, value) + return + + site = self.wyckoff(site_id) + representative = site.get("representative_parent_fractional") + if representative is None: + raise ValueError( + f"Wyckoff site {site_id} has no representative parent coordinate." + ) + + axis_index = {"x": 0, "y": 1, "z": 2}[parameter] + value = float(value) + delta = value - representative[axis_index] + couplings = [ + coupling + for coupling in self.wyckoff_site_couplings(site_id) + if coupling.axis == parameter + ] + if not couplings: + raise ValueError( + f"No site-displacement couplings found for Wyckoff site " + f"{site_id} and parent axis {parameter}." + ) + + surface_deltas = {} + for coupling in couplings: + column = self.parameterLookup[coupling.coordinate] + change = delta * coupling.factor + self.basis_0[coupling.atom_index, column] += change + self.basis[coupling.atom_index, column] += change + surface_deltas.setdefault( + coupling.atom_index, + np.zeros(3, dtype=np.float64), + )[{"x": 0, "y": 1, "z": 2}[coupling.coordinate]] += change + + self._update_wyckoff_metadata_from_site_value( + site_id, + axis_index, + value, + surface_deltas, + ) + self._refresh_after_baseline_change() + + def _refresh_after_baseline_change(self): + self.errors = None + self._basis_parvalues = None + self._errors_parvalues = None + + def _update_wyckoff_metadata_from_atom_values( + self, + site_id, + parameter, + atoms, + values, + ): + model = self.symmetry_metadata + if parameter in {"iDW", "oDW", "occ"}: + model.sites = tuple( + dataclasses.replace(site, **{parameter: float(values[0])}) + if site.site_id == site_id + else site + for site in model.sites + ) + return + + coordinate = {"x": 0, "y": 1, "z": 2}[parameter] + values_by_atom = dict(zip(atoms.tolist(), values.tolist())) + transform = model.surface_spec.transform + updated_atoms = [] + for atom in model.atoms: + if atom.atom_index not in values_by_atom: + updated_atoms.append(atom) + continue + surface = np.array(atom.surface_fractional, copy=True) + surface_delta = values_by_atom[atom.atom_index] - surface[coordinate] + surface[coordinate] = values_by_atom[atom.atom_index] + parent = np.array(atom.parent_fractional, copy=True) + parent += transform[:, coordinate] * surface_delta + updated_atoms.append( + dataclasses.replace( + atom, + surface_fractional=surface, + parent_fractional=parent, + ) + ) + model.atoms = updated_atoms + + def _update_wyckoff_metadata_from_site_value( + self, + site_id, + axis_index, + value, + surface_deltas, + ): + model = self.symmetry_metadata + model.sites = tuple( + dataclasses.replace( + site, + representative_parent_fractional=tuple( + value if index == axis_index else coordinate + for index, coordinate in enumerate( + site.representative_parent_fractional + ) + ), + ) + if site.site_id == site_id + else site + for site in model.sites + ) + transform = model.surface_spec.transform + model.atoms = [ + dataclasses.replace( + atom, + surface_fractional=np.asarray(atom.surface_fractional) + + surface_deltas[atom.atom_index], + parent_fractional=np.asarray(atom.parent_fractional) + + transform @ surface_deltas[atom.atom_index], + ) + if atom.atom_index in surface_deltas + else atom + for atom in model.atoms + ] + def wyckoff_couplings(self, site_id=None): """Return symmetry couplings for generated Wyckoff atom coordinates. @@ -2288,25 +2974,22 @@ def addWyckoffParameter( absolute_limits=None, **keyargs, ): - """Add a symmetry-preserving fit parameter for a Wyckoff variable. + """Add an absolute symmetry-preserving Wyckoff variable parameter. - The stored fit value is the change in the Wyckoff variable from the - generated coordinates. For example, fitting rutile oxygen ``u`` adds - ``factor * delta_u`` to every generated coordinate that depends on - ``u``. Multi-variable Wyckoff sites are fitted by adding one parameter - per independent coordinate variable. + The exposed fit value is the absolute Wyckoff variable. Internally, + its difference from the site's reference value is propagated through + every affine coupling, preserving symmetry-equivalent coordinates. :param str site_id: Site identifier returned by :meth:`wyckoff_sites`. :param str variable: Wyckoff variable name, for example ``"u"``. :param tuple limits: - Fit limits for the variable change in fractional units. + Absolute variable limits in parent fractional units. :param tuple absolute_limits: - Optional absolute variable limits. These are converted to change - limits around the metadata variable value. + Deprecated alias for ``limits`` retained for compatibility. :returns: - Created relative fit parameter. + Parameter exposing the absolute Wyckoff variable value. :rtype: CTRutil.Parameter :raises ValueError: @@ -2320,17 +3003,17 @@ def addWyckoffParameter( if variable not in site["variables"]: raise ValueError(f"Wyckoff site {site_id} has no variable {variable}.") - limits = self._delta_limits( - limits, - absolute_limits, - site["variables"][variable], - ) + if absolute_limits is not None: + if limits != (-np.inf, np.inf): + raise ValueError("Use either limits or absolute_limits, not both.") + limits = absolute_limits + reference_value = site["variables"][variable] couplings = [ coupling for coupling in self.wyckoff_couplings(site_id) if coupling.variable == variable ] - return self._add_wyckoff_relative_parameter( + parameter = self._add_wyckoff_relative_parameter( site_id, "variable", variable, @@ -2344,6 +3027,13 @@ def addWyckoffParameter( ), keyargs, ) + parameter.settings["wyckoff"].update( + { + "value_kind": "absolute", + "reference_value": reference_value, + } + ) + return parameter def addWyckoffParameters( self, @@ -2353,7 +3043,7 @@ def addWyckoffParameters( absolute_limits=None, **keyargs, ): - """Add symmetry-preserving fit parameters for Wyckoff variables. + """Add absolute symmetry-preserving Wyckoff variable parameters. :param str site_id: Site identifier returned by :meth:`wyckoff_sites`. @@ -2363,12 +3053,12 @@ def addWyckoffParameters( :type variables: iterable or None :param limits: - Either one ``(lower, upper)`` tuple applied to every variable or a - dictionary mapping variable names to delta limits. + One absolute ``(lower, upper)`` tuple for every variable or a + dictionary mapping variable names to absolute limits. :param absolute_limits: - Optional dictionary mapping variable names to absolute limits. + Deprecated alias for absolute ``limits`` retained for compatibility. :returns: - Created relative fit parameters in variable order. + Created absolute variable parameters in variable order. :rtype: list :raises ValueError: @@ -2411,12 +3101,12 @@ def addWyckoffShift( absolute_limits=None, **keyargs, ): - """Add a symmetry-lowering shift for representative site motion. + """Add a relative shift from a symmetry site along a parent direction. - ``axis`` is a parent conventional fractional coordinate of the - representative atom. The stored fit value is a delta from the - representative coordinate; generated atoms move through the stored - space-group operation and surface-cell transform factors. + ``axis`` is a parent conventional-cell direction. The exposed value is + a relative displacement from the representative symmetry-site + coordinate, not an absolute parent coordinate. Generated atoms move + through the stored space-group operation and surface-cell transform. :param str site_id: Site identifier returned by :meth:`wyckoff_sites`. @@ -2426,8 +3116,8 @@ def addWyckoffShift( :param tuple limits: Fit limits for the coordinate change in parent fractional units. :param tuple absolute_limits: - Optional absolute parent-coordinate limits, converted to changes - around the stored representative coordinate. + Optional absolute parent-coordinate bounds converted to relative + shift bounds. The fitted value remains a relative displacement. :returns: Created relative fit parameter. :rtype: @@ -2478,7 +3168,11 @@ def addWyckoffShifts( absolute_limits=None, **keyargs, ): - """Add representative site-displacement shifts for several axes. + """Add relative symmetry-site shifts along parent-cell directions. + + Every exposed value is a displacement from the representative + symmetry-site coordinate along the selected parent conventional-cell + direction; it is not an absolute coordinate. :param str site_id: Site identifier returned by :meth:`wyckoff_sites`. @@ -2490,7 +3184,8 @@ def addWyckoffShifts( Either one ``(lower, upper)`` tuple applied to every axis or a dictionary mapping axis names to delta limits. :param absolute_limits: - Optional dictionary mapping axis names to absolute limits. + Optional absolute parent-coordinate bounds converted to relative + shift bounds. The fitted values remain relative displacements. :returns: Created relative fit parameters in axis order. :rtype: @@ -2599,6 +3294,9 @@ def fitparToStr(self, par): val = np.mean( (self.basis[par.indices] - basis_0[par.indices]) / par.factors ) + wyckoff = par.settings.get("wyckoff", {}) + if wyckoff.get("value_kind") == "absolute": + val += wyckoff["reference_value"] # if isinstance(par.indices[1], (np.integer,int)): # parameternames = (UnitCell.parameterLookup_inv[par.indices[1]] + '_r',) # atoms = (f"{par.indices[0]}_{self.names[par.indices[0]]}",) @@ -2645,12 +3343,14 @@ def getStartParamAndLimits(self, force_recalculate=False): for par in relpar: if recalculate or par.value is None: - x0.append( - np.mean( - (self.basis[par.indices] - self.basis_0[par.indices]) - / par.factors - ) + value = np.mean( + (self.basis[par.indices] - self.basis_0[par.indices]) + / par.factors ) + wyckoff = par.settings.get("wyckoff", {}) + if wyckoff.get("value_kind") == "absolute": + value += wyckoff["reference_value"] + x0.append(value) else: x0.append(par.value) lower.append(par.limits[0]) @@ -2666,7 +3366,8 @@ def setFitParameters(self, x): self.basis[par.indices] = val par.value = val for val, par in zip(x_r, self.parameters["relative"]): - self.basis[par.indices] += par.factors * val + internal_value = self._relative_internal_value(par, val) + self.basis[par.indices] += par.factors * internal_value par.value = val self._basis_parvalues = np.copy(self.basis) @@ -2725,6 +3426,12 @@ def getFitErrors(self): return err0 def build_selected_basis(self): + # Preserve the legacy default-energy behavior for callers that compute + # a structure factor before selecting an energy, while allowing layer + # construction itself to remain free of unnecessary database work. + if not hasattr(self, "f") or self.f.shape != (self.basis.shape[0], 13): + self.setEnergy(getattr(self, "_E", 10000.0)) + if self.layer_behaviour == "select": if self.start_layer_number == -1.0: warnings.warn( @@ -2773,6 +3480,9 @@ def F_uc_bulk(self, h, k, l, atten=0): # noqa: E741 if ctr_accel_enabled(): h, k, l = _ensure_contiguous(h, k, l, testOnly=False, astype=np.float64) # noqa: E741 accel = _ctr_accel_module() + domain_matrix, domain_occupancy = _coherent_domain_arrays( + self.coherentDomainMatrix, self.coherentDomainOccupancy + ) F = accel.unitcell_F_uc_bulk( h, k, @@ -2784,8 +3494,8 @@ def F_uc_bulk(self, h, k, l, atten=0): # noqa: E741 self.B_mat, self.R_mat, self.R_mat_inv, - np.asarray(self.coherentDomainMatrix), - np.asarray(self.coherentDomainOccupancy), + domain_matrix, + domain_occupancy, self.uc_area, ) return F @@ -2898,6 +3608,9 @@ def F_uc(self, h, k, l): # noqa: E741 if ctr_accel_enabled() and not self._special_formfactors_present: h, k, l = _ensure_contiguous(h, k, l, testOnly=False, astype=np.float64) # noqa: E741 accel = _ctr_accel_module() + domain_matrix, domain_occupancy = _coherent_domain_arrays( + self.coherentDomainMatrix, self.coherentDomainOccupancy + ) F = accel.unitcell_F_uc( h, k, @@ -2908,8 +3621,8 @@ def F_uc(self, h, k, l): # noqa: E741 self.B_mat, self.R_mat, self.R_mat_inv, - np.asarray(self.coherentDomainMatrix), - np.asarray(self.coherentDomainOccupancy), + domain_matrix, + domain_occupancy, self.uc_area, ) return F @@ -2958,7 +3671,9 @@ def F_bulk(self, h, k, l, atten=0): # noqa: E741 """Return the semi-infinite bulk structure factor in electrons. The amplitude represents one lateral bulk unit cell. The geometric - lattice sum is applied only along the out-of-plane direction. + lattice sum is applied only along the out-of-plane direction. Its + phase and attenuation follow the bulk-cell repeat after conversion + from the configured reference unit cell. :param numpy.ndarray h: Reference-frame reciprocal coordinate in r.l.u. @@ -2967,35 +3682,42 @@ def F_bulk(self, h, k, l, atten=0): # noqa: E741 :param numpy.ndarray l: Reference-frame reciprocal coordinate in r.l.u. :param float atten: - Dimensionless attenuation exponent per bulk unit cell. + Dimensionless attenuation exponent per reference-cell + out-of-plane repeat. :returns: Complex bulk amplitude in electrons per lateral bulk cell. :rtype: numpy.ndarray """ basis, formf, names = self.build_selected_basis() + # The reciprocal transform maps reference r.l.u. to bulk r.l.u.; + # (2, 2) is the bulk/reference out-of-plane repeat ratio. + repeat_atten = atten * abs(self.refHKLTransform[2, 2]) if ctr_accel_enabled(): h, k, l = _ensure_contiguous(h, k, l, testOnly=False, astype=np.float64) # noqa: E741 accel = _ctr_accel_module() + domain_matrix, domain_occupancy = _coherent_domain_arrays( + self.coherentDomainMatrix, self.coherentDomainOccupancy + ) F = accel.unitcell_F_bulk( h, k, l, - atten, + repeat_atten, basis, formf, self.refHKLTransform, self.B_mat, self.R_mat, self.R_mat_inv, - np.asarray(self.coherentDomainMatrix), - np.asarray(self.coherentDomainOccupancy), + domain_matrix, + domain_occupancy, self.uc_area, ) return F else: hkl = self.refHKLTransform @ np.vstack((h, k, l)) - Fuc = self.F_uc_bulk_direct(*hkl, atten) - return Fuc / (1 - np.exp(-2j * np.pi * l - atten)) + Fuc = self.F_uc_bulk_direct(*hkl, repeat_atten) + return Fuc / (1 - np.exp(-2j * np.pi * hkl[2] - repeat_atten)) SQRT2pi = np.sqrt(2 * np.pi) @@ -3039,6 +3761,27 @@ def zDensity_G(self, z, h, k): "This will result in numerical errors in electron density calculation!" ) + # Special electron-density models are Python callbacks followed by a + # SciPy convolution, so they deliberately retain the reference path. + # The native kernel implements the standard atomic density expression. + if CTR_ACCEL_BACKEND == "cpp" and not any( + name in UnitCell.special_eDensity for name in names + ): + return _CTRcalc_cpp.unitcell_zdensity_g( + np.ascontiguousarray(z, dtype=np.float64), + h, + k, + Qpara2, + self._a[2], + basis, + formf, + self.R_mat, + self.R_mat_inv, + np.asarray(self.coherentDomainMatrix), + np.asarray(self.coherentDomainOccupancy), + self.uc_area, + ) + rho = np.zeros_like(z, dtype=np.complex128) rho_i = np.empty_like(z, dtype=np.complex128) @@ -3135,13 +3878,20 @@ def lookupScatteringFactors(self, E): else: self.f = np.empty((self.basis.shape[0], 13), dtype=np.float64) - for i, name in enumerate(self.names): + species_indices = OrderedDict() + for index, name in enumerate(self.names): + species_indices.setdefault(name, []).append(index) + + for name, indices in species_indices.items(): if name in UnitCell.special_formfactors: - self.f[i, :11] = 0.0 - self.f[i, 11:] = UnitCell.special_formfactors[name][1](E) + row = np.zeros(13, dtype=np.float64) + row[11:] = UnitCell.special_formfactors[name][1](E) else: - self.f[i, :11] = readWaasmaier(name) - self.f[i, 11:] = readDispersion(name, E) + row = np.empty(13, dtype=np.float64) + row[:11] = readWaasmaier(name) + row[11:] = readDispersion(name, E) + self.f[indices] = row + self._formfactor_state = self._formfactor_state_token() return def plot3d( @@ -3154,18 +3904,50 @@ def plot3d( figure=None, translate=np.array([0.0, 0.0, 0.0]), domain=0, + backend="auto", + radius_scale=1.0, **keyargs, ): + """Plot atoms as covalent-radius spheres in a 3D viewer. + + Coordinates and radii passed to either backend are in Angstrom. + Reusing the returned figure adds atoms to the existing scene. In a + notebook, an already displayed py3Dmol view is updated in place. + Assign the result or terminate an incremental call with a semicolon to + prevent Jupyter from also rendering the returned view as a new output. + The ``resolution`` keyword controls Mayavi spheres only; py3Dmol uses + its native sphere rendering. + + :param int ucx: Number of cells along the first lattice direction. + :param int ucy: Number of cells along the second lattice direction. + :param int ucz: Number of cells along the third lattice direction. + :param bool dwon: + Sample atom positions using the stored Debye-Waller disorder. + :param bool occuon: + Randomly omit atoms according to their occupancies. + :param figure: + Existing Mayavi figure or py3Dmol view to extend. + :param numpy.ndarray translate: + Fractional translation vector or coherent-domain transform. + :param int domain: Index of the coherent-domain matrix. + :param str backend: + ``"auto"``, ``"mayavi"``, or ``"py3dmol"``. Automatic selection + prefers py3Dmol in an IPython kernel and Mayavi otherwise. + :param float radius_scale: + Positive dimensionless multiplier applied to covalent radii. + :returns: The selected backend's figure or view. + :raises ValueError: If ``radius_scale`` is not positive and finite. + """ try: - from mayavi import mlab - except ImportError: - warnings.warn("can not import mayavi: 3D plotting not supported") - return + radius_scale = float(radius_scale) + except (TypeError, ValueError) as error: + raise ValueError("radius_scale must be positive and finite.") from error + if not np.isfinite(radius_scale) or radius_scale <= 0: + raise ValueError("radius_scale must be positive and finite.") - if figure is None: - figure = mlab.figure() + backend, figure, mlab = _prepare_plot3d_figure(backend, figure) if keyargs.get("useSelected", True): - basis, formf, names = self.build_selected_basis() + basis, _formf, names = self.build_selected_basis() else: basis, _formf, names = self.basis, self.f, self.names if ucx == 0 or ucy == 0 or ucz == 0: @@ -3191,7 +3973,10 @@ def color_generator(name): mat = self.coherentDomainMatrix[domain] domainmatrix = self.R_mat_inv @ mat[:, :-1] @ self.R_mat for i, params in enumerate(basis): - radius = cov_radii_array[atomic_number(names[i]) - 1][2] * 2 + covalent_radius = float( + cov_radii_array[atomic_number(names[i]) - 1][2] + ) + sphere_radius = covalent_radius * radius_scale # elcolor_c = keyargs.get('color') # if elcolor_c is None: # elcolor_c = elements.rgb(int(params[0])) @@ -3247,18 +4032,49 @@ def color_generator(name): if dwon: position_cart = np.random.default_rng().normal(position_cart, sigmas) - mlab.points3d( - *position_cart, - scale_factor=radius, - color=elcolors[i], - resolution=resolution, - figure=figure, - ) + if backend == "mayavi": + mlab.points3d( + *position_cart, + scale_factor=2 * sphere_radius, + color=elcolors[i], + resolution=resolution, + figure=figure, + ) + else: + valid = np.all(np.isfinite(position_cart), axis=0) + positions_valid = position_cart[:, valid].T + if positions_valid.size == 0: + continue + symbol = cov_radii_array[atomic_number(names[i]) - 1][1] + xyz_lines = [ + str(len(positions_valid)), + f"orGUI UnitCell {self.name}", + ] + xyz_lines.extend( + f"{symbol} {position[0]:.16g} {position[1]:.16g} " + f"{position[2]:.16g}" + for position in positions_valid + ) + figure.addModel("\n".join(xyz_lines) + "\n", "xyz") + figure.getModel().setStyle( + {}, + { + "sphere": { + "color": _rgb_to_hex(elcolors[i]), + "radius": sphere_radius, + } + }, + ) atomlist = keyargs.get("atomlist") if atomlist is not None: atomlist.append(self.pos_cart_all(ucx, ucy, ucz, translate)) + if backend == "py3dmol" and not keyargs.get("_defer_update", False): + figure.zoomTo() + if getattr(figure, "uniqueid", None) is not None: + figure.update() + return figure def pos_cart_all( @@ -3365,20 +4181,43 @@ def atomToStr(self, no, showErrors=True): name = self.names[no] if (self.errors is not None) and showErrors: err = self.errors[no][1:] - l = [] # noqa: E741 - for t in zip(param, err): - [l.append(ti) for ti in t] - return ( - "{} ({:.5f} +- {:.5f}) ({:.5f} +- {:.5f}) ({:.5f} +- {:.5f}) ({:.4f} +- {:.4f}) ({:.4f} +- {:.4f}) ({:.4f} +- {:.4f}) ({:.0f} +- {:.0f})".format( # noqa: E501 - name, *l - ) # noqa: E501 + values = [ + f"({param[index]:.5f} +- {err[index]:.5f})" + for index in range(3) + ] + values.extend( + f"({param[index]:.4f} +- {err[index]:.4f})" + for index in range(3, 6) ) - else: - return "{} {:.5f} {:.5f} {:.5f} {:.4f} {:.4f} {:.4f} {:.0f}".format( # noqa: E501 - name, - *param, + values.append(f"({param[6]:.0f} +- {err[6]:.0f})") + return f"{name:<{self._atom_error_column_widths[0]}}" + "".join( + f"{value:>{width}}" + for value, width in zip( + values, + self._atom_error_column_widths[1:], + ) ) + formats = (".5f", ".5f", ".5f", ".4f", ".4f", ".4f", ".0f") + values = [format(value, spec) for value, spec in zip(param, formats)] + return f"{name:<{self._atom_column_widths[0]}}" + "".join( + f"{value:>{width}}" + for value, width in zip(values, self._atom_column_widths[1:]) + ) + + def _parameter_header(self, showErrors=True): + widths = ( + self._atom_error_column_widths + if self.errors is not None and showErrors + else self._atom_column_widths + ) + return "".join( + f"{name:<{width}}" if index == 0 else f"{name:>{width}}" + for index, (name, width) in enumerate( + zip(self._atom_column_names, widths) + ) + ).rstrip() + def domainsToStr(self): s = "" for mat, occu in zip(self.coherentDomainMatrix, self.coherentDomainOccupancy): @@ -3433,7 +4272,7 @@ def parameterStrRod(self): def __str__(self): st = repr(self) + "\n" - st += "id " + UnitCell.parameterOrder + "\n" + st += "id " + self._parameter_header() + "\n" return st + self.parameterStr() def writeSURfile(self, filename): @@ -3470,7 +4309,7 @@ def toStr(self, showErrors=True): + self.domainsToStr() + self.latticeRODStr() + "\n" - + UnitCell.parameterOrder + + self._parameter_header(showErrors) + "\n" + self.parameterStr(showErrors=showErrors) ) @@ -3541,10 +4380,23 @@ def fromFile(filename): f"File {filename} contains no valid crystal lattice parameters" ) uc = UnitCell(atoms.cell.lengths(), atoms.cell.angles()) - coord = atoms.get_scaled_positions() + coord = np.mod(atoms.get_scaled_positions(), 1.0) symb = atoms.get_chemical_symbols() - for sym, xyz in zip(symb, coord): - uc.addAtom(sym, xyz, 0.0, 0.0, 1.0) + if ext.lower() == ".cif": + order = sorted( + range(len(symb)), + key=lambda index: ( + coord[index, 2], + coord[index, 1], + coord[index, 0], + symb[index], + ), + ) + uc._allow_layer_order_normalization = True + else: + order = range(len(symb)) + for index in order: + uc.addAtom(symb[index], coord[index], 0.0, 0.0, 1.0) return uc @classmethod diff --git a/orgui/datautils/xrayutils/CTRutil.py b/orgui/datautils/xrayutils/CTRutil.py index cd982b8..0797aa8 100644 --- a/orgui/datautils/xrayutils/CTRutil.py +++ b/orgui/datautils/xrayutils/CTRutil.py @@ -32,6 +32,7 @@ import xraydb import warnings import json +from functools import cache, lru_cache # random.seed(45) from collections import OrderedDict @@ -525,6 +526,96 @@ def next_skip_comment(it, comment=("//", "return")): return line +def generate_surface_termination_cells( + surface_supercell, + film_cycle, + name_template=None, +): + """Generate one complete surface-slab cell per Film termination. + + The returned cells are suitable for any roughness model that selects a + surface structure from the primitive Film stacking cycle. Each variant is + first shifted with ``UnitCell.affine_layer_transform`` so the requested + primitive-cycle member is the uppermost internal layer. All atoms are + then assigned that one termination identifier with + ``UnitCell.as_surface_termination``. Internal slab planes remain encoded + by their fractional z coordinates and may be fitted independently. + + :param UnitCell surface_supercell: + Surface slab produced by ``UnitCell.supercell``. Its number of + structural layers must be an integer multiple of the Film-cycle + length. + :param film_cycle: + A Film, UnitCell, LayerCycle, or iterable of primitive Film layer + identifiers, ordered from bottom to top. + :param str name_template: + Optional ``str.format`` template for generated names. The fields + ``name`` (the source-cell name) and ``layer`` are available. The + default is ``"{name}_termination_"``. + :returns: + Mapping from primitive Film layer identifier to an independent, + complete termination unit cell. + :rtype: dict + :raises ValueError: + If either cycle is empty, contains duplicate identifiers, or the slab + layer count is not an integer multiple of the Film cycle. + :raises TypeError: + If the supplied surface cell does not provide the required layered + transformation methods. + """ + required_methods = ("affine_layer_transform", "as_surface_termination") + if any(not hasattr(surface_supercell, method) for method in required_methods): + raise TypeError( + "surface_supercell must be a layered UnitCell with affine and " + "surface-termination transforms" + ) + + cycle = getattr(film_cycle, "layer_cycle", film_cycle) + cycle = getattr(cycle, "layers", cycle) + film_layers = tuple(cycle) + if not film_layers: + raise ValueError("Film termination cycle cannot be empty") + if len(set(film_layers)) != len(film_layers): + raise ValueError("Film termination cycle identifiers must be unique") + + internal_layers = tuple(surface_supercell.layer_cycle.layers) + if not internal_layers: + raise ValueError("Surface supercell layer cycle cannot be empty") + if len(internal_layers) % len(film_layers): + raise ValueError( + "Surface slab layer count must be an integer multiple of the " + "Film layer-cycle length" + ) + + terminations = {} + for termination_index, termination in enumerate(film_layers): + source_index = list( + range(termination_index, len(internal_layers), len(film_layers)) + )[-1] + shift = len(internal_layers) - 1 - source_index + if name_template is None: + layer_label = f"{termination:g}" if isinstance( + termination, int | float | np.integer | np.floating + ) else str(termination) + name = f"{surface_supercell.name}_termination_{layer_label}" + else: + name = name_template.format( + name=surface_supercell.name, + layer=termination, + ) + transformed = surface_supercell.affine_layer_transform( + [0, 0, shift], + name=name, + ) + origin = max(transformed.layerpos.values()) + terminations[termination] = transformed.as_surface_termination( + termination, + name=name, + origin=origin, + ) + return terminations + + SQRT2pi = np.sqrt(2 * np.pi) @@ -554,7 +645,16 @@ def DWtoDisorder(dw): # returns a,b,c for Q = 4pi/lambda * sin th (instead of s) -def readWaasmaier(element): +def _normalize_species(element): + """Return a stable lookup key for an element or ion name.""" + if isinstance(element, int): + return int(element) + return str(element).strip().title() + + +@cache +def _readWaasmaier_cached(element): + """Read immutable Waasmaier coefficients for a normalized species.""" xraydb_t = xraydb.get_xraydb() wtab = xraydb_t.tables["Waasmaier"] @@ -562,22 +662,43 @@ def readWaasmaier(element): if isinstance(element, int): row = row.filter(wtab.c.atomic_number == element).one() else: - row = row.filter(wtab.c.ion == element.title()).one() - # if len(row) > 0: - # row = row[0] + row = row.filter(wtab.c.ion == element).one() c = row.offset a = json.loads(row.scale) b = np.array(json.loads(row.exponents)) / (4 * np.pi) ** 2 xraydb_t.close() - return np.concatenate((a, b, [c])) + return tuple(np.concatenate((a, b, [c]))) + + +def readWaasmaier(element): + """Return Waasmaier coefficients for an element or ion. + + The database result is cached by normalized species name. A new array is + returned on each call so callers cannot modify the cache. + """ + return np.array(_readWaasmaier_cached(_normalize_species(element))) + + +@lru_cache(maxsize=100_000) +def _readDispersion_cached(element, energy): + """Read immutable dispersion terms for a normalized species and energy. + + Unlike :func:`_readWaasmaier_cached`, whose key space is bounded by the + small set of known elements/ions, ``energy`` is a continuous value, so + an unbounded cache could grow without limit over an energy scan in a + long-running batch job. Bounded with an LRU eviction policy instead. + """ + return ( + xraydb.f1_chantler(atomic_number(element), energy), + xraydb.f2_chantler(atomic_number(element), energy), + ) # incorrect for ions!! def readDispersion(element, E): - return xraydb.f1_chantler(atomic_number(element), E), xraydb.f2_chantler( - atomic_number(element), E - ) + """Return dispersion terms for an element or ion at energy ``E`` in eV.""" + return _readDispersion_cached(_normalize_species(element), float(E)) def atomic_number(elementname): diff --git a/orgui/datautils/xrayutils/_CTRcalc_accel.py b/orgui/datautils/xrayutils/_CTRcalc_accel.py index ebbc87e..01e7d5f 100644 --- a/orgui/datautils/xrayutils/_CTRcalc_accel.py +++ b/orgui/datautils/xrayutils/_CTRcalc_accel.py @@ -142,7 +142,7 @@ def _unitcell_F_core( domain_factor *= math.exp(atten * z_rel) amplitude += domain_factor * form_factor if apply_bulk_lattice_sum: - denominator_phase = -two_pi * l[p] + denominator_phase = -two_pi * ll denominator = 1.0 - math.exp(-atten) * ( math.cos(denominator_phase) + 1j * math.sin(denominator_phase) ) diff --git a/orgui/datautils/xrayutils/__init__.py b/orgui/datautils/xrayutils/__init__.py index 5a5560f..7ebc780 100644 --- a/orgui/datautils/xrayutils/__init__.py +++ b/orgui/datautils/xrayutils/__init__.py @@ -30,7 +30,9 @@ __all__ = [ "CTRcalc", + "CTRoptics", "CTRplotutil", + "CTRresolution", "CTRsymmetry", "DetectorCalibration", "HKLVlieg", diff --git a/orgui/datautils/xrayutils/cpp/CTRcalc_cpp.cpp b/orgui/datautils/xrayutils/cpp/CTRcalc_cpp.cpp index a1634f5..b3aff99 100644 --- a/orgui/datautils/xrayutils/cpp/CTRcalc_cpp.cpp +++ b/orgui/datautils/xrayutils/cpp/CTRcalc_cpp.cpp @@ -1,7 +1,17 @@ +#include +#include #include #include +#include +#include +#include #include +#include #include +#include +#include + +#include #include #include @@ -14,6 +24,224 @@ using Array1D = py::array_t; using Array2D = py::array_t; using Array3D = py::array_t; +constexpr std::size_t form_factor_cache_default_budget = 256ULL * 1024 * 1024; + +struct Hash128 { + std::uint64_t low; + std::uint64_t high; +}; + +Hash128 hash_values(const double *values, const std::size_t count) { + const XXH128_hash_t hash = XXH3_128bits(values, count * sizeof(double)); + return {hash.low64, hash.high64}; +} + +bool same_hash(const Hash128 &left, const Hash128 &right) { + return left.low == right.low && left.high == right.high; +} + +struct CachedSpecies { + std::array row; + Hash128 hash; + std::shared_ptr> values; + // Valid once inserted into FormFactorCache::lru_; lets touch() splice + // this entry to the LRU tail in O(1) instead of scanning lru_. + std::list>::iterator lru_it; +}; + +struct CachedQGrid { + std::shared_ptr> q2; + Hash128 hash; + std::vector> species; +}; + +class FormFactorCache { +public: + std::shared_ptr> find( + const std::vector &q2, + const std::array &row + ) { + const Hash128 q_hash = hash_values(q2.data(), q2.size()); + const Hash128 row_hash = hash_values(row.data(), row.size()); + std::lock_guard lock(mutex_); + auto grid = find_grid(q2, q_hash); + if (grid == grids_.end()) { + ++misses_; + return nullptr; + } + for (const auto &species : (*grid)->species) { + if (same_hash(species->hash, row_hash) + && std::memcmp(species->row.data(), row.data(), sizeof(row)) == 0) { + touch(species); + ++hits_; + return species->values; + } + } + ++misses_; + return nullptr; + } + + std::shared_ptr> insert_or_get( + const std::vector &q2, + const std::array &row, + std::vector values + ) { + const Hash128 q_hash = hash_values(q2.data(), q2.size()); + const Hash128 row_hash = hash_values(row.data(), row.size()); + std::lock_guard lock(mutex_); + auto grid = find_grid(q2, q_hash); + if (grid != grids_.end()) { + for (const auto &species : (*grid)->species) { + if (same_hash(species->hash, row_hash) + && std::memcmp(species->row.data(), row.data(), sizeof(row)) == 0) { + touch(species); + ++hits_; + return species->values; + } + } + } + + const std::size_t values_bytes = values.size() * sizeof(double); + const std::size_t q_bytes = grid == grids_.end() ? q2.size() * sizeof(double) : 0; + if (values_bytes + q_bytes > budget_bytes_) { + return std::make_shared>(std::move(values)); + } + if (grid == grids_.end()) { + auto new_grid = std::make_shared(); + new_grid->q2 = std::make_shared>(q2); + new_grid->hash = q_hash; + grids_.push_back(new_grid); + grid = std::prev(grids_.end()); + grid_index_[q_hash.low].push_back(grid); + resident_bytes_ += q_bytes; + } + auto species = std::make_shared(); + species->row = row; + species->hash = row_hash; + species->values = std::make_shared>(std::move(values)); + (*grid)->species.push_back(species); + lru_.push_back(species); + species->lru_it = std::prev(lru_.end()); + resident_bytes_ += values_bytes; + evict_to_budget(); + return species->values; + } + + void clear() { + std::lock_guard lock(mutex_); + grids_.clear(); + grid_index_.clear(); + lru_.clear(); + resident_bytes_ = 0; + } + + void reset_statistics() { + std::lock_guard lock(mutex_); + hits_ = 0; + misses_ = 0; + evictions_ = 0; + } + + void set_budget(const std::size_t bytes) { + std::lock_guard lock(mutex_); + budget_bytes_ = bytes; + evict_to_budget(); + } + + py::dict statistics() { + std::lock_guard lock(mutex_); + py::dict result; + result["hits"] = hits_; + result["misses"] = misses_; + result["evictions"] = evictions_; + result["resident_bytes"] = resident_bytes_; + result["budget_bytes"] = budget_bytes_; + result["q_grids"] = grids_.size(); + result["species_entries"] = lru_.size(); + return result; + } + +private: + using GridList = std::list>; + + // Bucketed by the low 64 bits of the Q-grid's hash; find_grid still + // verifies the full 128-bit hash and the raw Q2 values within a bucket, + // so a same-bucket collision can only cost extra comparisons, never a + // wrong result. + GridList::iterator find_grid(const std::vector &q2, const Hash128 &hash) { + const auto bucket = grid_index_.find(hash.low); + if (bucket == grid_index_.end()) { + return grids_.end(); + } + for (const auto &grid : bucket->second) { + const auto &stored = *(*grid)->q2; + if (same_hash((*grid)->hash, hash) && stored.size() == q2.size() + && std::memcmp(stored.data(), q2.data(), q2.size() * sizeof(double)) == 0) { + return grid; + } + } + return grids_.end(); + } + + void touch(const std::shared_ptr &species) { + lru_.splice(lru_.end(), lru_, species->lru_it); + } + + void evict_to_budget() { + while (resident_bytes_ > budget_bytes_ && !lru_.empty()) { + const auto species = lru_.front(); + lru_.pop_front(); + for (auto grid = grids_.begin(); grid != grids_.end(); ++grid) { + auto &entries = (*grid)->species; + for (auto entry = entries.begin(); entry != entries.end(); ++entry) { + if (*entry == species) { + resident_bytes_ -= (*entry)->values->size() * sizeof(double); + entries.erase(entry); + ++evictions_; + if (entries.empty()) { + resident_bytes_ -= (*grid)->q2->size() * sizeof(double); + auto &bucket = grid_index_[(*grid)->hash.low]; + bucket.erase( + std::remove(bucket.begin(), bucket.end(), grid), + bucket.end() + ); + if (bucket.empty()) { + grid_index_.erase((*grid)->hash.low); + } + grids_.erase(grid); + } + goto evicted; + } + } + } + evicted:; + } + } + + std::mutex mutex_; + GridList grids_; + std::unordered_map> grid_index_; + std::list> lru_; + std::size_t budget_bytes_ = form_factor_cache_default_budget; + std::size_t resident_bytes_ = 0; + std::uint64_t hits_ = 0; + std::uint64_t misses_ = 0; + std::uint64_t evictions_ = 0; +}; + +FormFactorCache &form_factor_cache() { + static FormFactorCache cache; + return cache; +} + +py::dict form_factor_cache_stats() { + return form_factor_cache().statistics(); +} + +void reset_form_factor_cache_stats() { + form_factor_cache().reset_statistics(); +} + struct Matrix3 { double value[3][3]; }; @@ -148,11 +376,15 @@ inline double get3( return data[(i * info.shape[1] + j) * info.shape[2] + k]; } -std::unique_ptr effective_domain_matrices(const Inputs &inputs) { - const auto n_domains = inputs.coherent_domain_matrix.shape[0]; - const double *domain = ptr(inputs.coherent_domain_matrix); - const double *r_mat = ptr(inputs.r_mat); - const double *r_mat_inv = ptr(inputs.r_mat_inv); +std::unique_ptr effective_domain_matrices( + const py::buffer_info &coherent_domain_matrix, + const py::buffer_info &r_mat_info, + const py::buffer_info &r_mat_inv_info +) { + const auto n_domains = coherent_domain_matrix.shape[0]; + const double *domain = ptr(coherent_domain_matrix); + const double *r_mat = ptr(r_mat_info); + const double *r_mat_inv = ptr(r_mat_inv_info); auto matrices = std::make_unique(n_domains); for (py::ssize_t d = 0; d < n_domains; ++d) { @@ -165,7 +397,7 @@ std::unique_ptr effective_domain_matrices(const Inputs &inputs) { r_mat_inv[row * 3 + left] * get3( domain, - inputs.coherent_domain_matrix, + coherent_domain_matrix, d, left, right @@ -181,6 +413,14 @@ std::unique_ptr effective_domain_matrices(const Inputs &inputs) { return matrices; } +std::unique_ptr effective_domain_matrices(const Inputs &inputs) { + return effective_domain_matrices( + inputs.coherent_domain_matrix, + inputs.r_mat, + inputs.r_mat_inv + ); +} + py::array_t unitcell_core( const Array1D &h, const Array1D &k, @@ -320,7 +560,7 @@ py::array_t unitcell_core( } } if (apply_bulk_lattice_sum) { - const double denominator_phase = -two_pi * l_data[p]; + const double denominator_phase = -two_pi * ll; const double lattice_factor = std::exp(-atten); const double denominator_real = ( 1.0 - lattice_factor * std::cos(denominator_phase) @@ -464,23 +704,326 @@ py::array_t unitcell_F_uc( const Array1D &coherent_domain_occupancy, const double /* uc_area */ ) { - return unitcell_core( - h, - k, - l, - 0.0, - true, - false, - false, - basis, - f_factors, - ref_hkl_transform, - b_mat, - r_mat, - r_mat_inv, - coherent_domain_matrix, - coherent_domain_occupancy + const Inputs inputs = request_inputs( + h, k, l, basis, f_factors, ref_hkl_transform, b_mat, r_mat, + r_mat_inv, coherent_domain_matrix, coherent_domain_occupancy ); + const auto n_points = inputs.h.shape[0]; + const auto n_atoms = inputs.basis.shape[0]; + const auto n_domains = inputs.coherent_domain_matrix.shape[0]; + auto result = py::array_t(n_points); + auto *out = static_cast(result.request().ptr); + + const double *h_data = ptr(inputs.h); + const double *k_data = ptr(inputs.k); + const double *l_data = ptr(inputs.l); + const double *basis_data = ptr(inputs.basis); + const double *ff_data = ptr(inputs.f_factors); + const double *ref_data = ptr(inputs.ref_hkl_transform); + const double *b_data = ptr(inputs.b_mat); + const double *domain_data = ptr(inputs.coherent_domain_matrix); + const double *domain_occupancy = ptr(inputs.coherent_domain_occupancy); + const auto domain_matrices = effective_domain_matrices(inputs); + + struct PointGeometry { + double h; + double k; + double l; + double q_para2; + double q_perp2; + }; + struct AtomDomainGeometry { + double x; + double y; + double z; + double weight; + }; + struct SpeciesGroup { + std::array row; + std::vector atoms; + }; + + std::vector points(n_points); + std::vector q2(n_points); + std::vector atom_domains(n_atoms * n_domains); + std::vector species; + species.reserve(n_atoms); + for (py::ssize_t i = 0; i < n_atoms; ++i) { + std::array row; + std::memcpy(row.data(), ff_data + i * inputs.f_factors.shape[1], sizeof(row)); + auto group = species.end(); + for (auto candidate = species.begin(); candidate != species.end(); ++candidate) { + if (std::memcmp(candidate->row.data(), row.data(), sizeof(row)) == 0) { + group = candidate; + break; + } + } + if (group == species.end()) { + species.push_back({row, {}}); + group = std::prev(species.end()); + } + group->atoms.push_back(i); + } + + constexpr double pi = 3.141592653589793238462643383279502884; + constexpr double two_pi = 2.0 * pi; + constexpr double dw_denominator = 16.0 * pi * pi; + py::gil_scoped_release release; + + // These snapshots are deliberately made before hashing: exact numerical + // reuse is defined by the computed float64 Q^2 grid, not source h/k/l. + for (py::ssize_t p = 0; p < n_points; ++p) { + const double h_in = h_data[p]; + const double k_in = k_data[p]; + const double l_in = l_data[p]; + const double hh = ref_data[0] * h_in + ref_data[1] * k_in + ref_data[2] * l_in; + const double kk = ref_data[3] * h_in + ref_data[4] * k_in + ref_data[5] * l_in; + const double ll = ref_data[6] * h_in + ref_data[7] * k_in + ref_data[8] * l_in; + const double qx = b_data[0] * hh + b_data[1] * kk + b_data[2] * ll; + const double qy = b_data[3] * hh + b_data[4] * kk + b_data[5] * ll; + const double qz = b_data[6] * hh + b_data[7] * kk + b_data[8] * ll; + points[p] = {hh, kk, ll, qx * qx + qy * qy, qz * qz}; + q2[p] = points[p].q_para2 + points[p].q_perp2; + } + for (py::ssize_t i = 0; i < n_atoms; ++i) { + const double x = get2(basis_data, inputs.basis, i, 1); + const double y = get2(basis_data, inputs.basis, i, 2); + const double z = get2(basis_data, inputs.basis, i, 3); + for (py::ssize_t d = 0; d < n_domains; ++d) { + const Matrix3 &mat = domain_matrices[d]; + atom_domains[i * n_domains + d] = { + mat.value[0][0] * x + mat.value[0][1] * y + mat.value[0][2] * z + + get3(domain_data, inputs.coherent_domain_matrix, d, 0, 3), + mat.value[1][0] * x + mat.value[1][1] * y + mat.value[1][2] * z + + get3(domain_data, inputs.coherent_domain_matrix, d, 1, 3), + mat.value[2][0] * x + mat.value[2][1] * y + mat.value[2][2] * z + + get3(domain_data, inputs.coherent_domain_matrix, d, 2, 3), + domain_occupancy[d], + }; + } + } + + std::vector>> factors; + factors.reserve(species.size()); + for (const auto &group : species) { + auto values = form_factor_cache().find(q2, group.row); + if (!values) { + std::vector calculated(n_points); + for (py::ssize_t p = 0; p < n_points; ++p) { + double value = group.row[10] + group.row[11]; + for (int term = 0; term < 5; ++term) { + value += group.row[term] * std::exp(-group.row[term + 5] * q2[p]); + } + calculated[p] = value; + } + values = form_factor_cache().insert_or_get(q2, group.row, std::move(calculated)); + } + factors.push_back(std::move(values)); + } + + for (py::ssize_t p = 0; p < n_points; ++p) { + double amplitude_real = 0.0; + double amplitude_imag = 0.0; + for (std::size_t g = 0; g < species.size(); ++g) { + const double f_real = (*factors[g])[p]; + const double f_imag = species[g].row[12]; + for (const py::ssize_t i : species[g].atoms) { + const double disorder = std::exp(-( + get2(basis_data, inputs.basis, i, 4) * points[p].q_para2 + + get2(basis_data, inputs.basis, i, 5) * points[p].q_perp2 + ) / dw_denominator) * get2(basis_data, inputs.basis, i, 6); + for (py::ssize_t d = 0; d < n_domains; ++d) { + const auto &atom = atom_domains[i * n_domains + d]; + const double phase = two_pi * ( + points[p].h * atom.x + points[p].k * atom.y + points[p].l * atom.z + ); + const double domain_real = atom.weight * std::cos(phase); + const double domain_imag = atom.weight * std::sin(phase); + amplitude_real += disorder * ( + domain_real * f_real - domain_imag * f_imag + ); + amplitude_imag += disorder * ( + domain_real * f_imag + domain_imag * f_real + ); + } + } + } + out[p] = complex128{amplitude_real, amplitude_imag}; + } + return result; +} + +py::array_t unitcell_zdensity_g( + const Array1D &z, + const double h, + const double k, + const double q_para2, + const double c, + const Array2D &basis, + const Array2D &f_factors, + const Array2D &r_mat, + const Array2D &r_mat_inv, + const Array3D &coherent_domain_matrix, + const Array1D &coherent_domain_occupancy, + const double uc_area +) { + const auto z_info = z.request(); + const auto basis_info = basis.request(); + const auto ff_info = f_factors.request(); + const auto r_info = r_mat.request(); + const auto r_inv_info = r_mat_inv.request(); + const auto domain_info = coherent_domain_matrix.request(); + const auto occupancy_info = coherent_domain_occupancy.request(); + + require_shape(z_info, 1, "z"); + require_shape(basis_info, 2, "basis"); + require_shape(ff_info, 2, "f_factors"); + require_shape(r_info, 2, "R_mat"); + require_shape(r_inv_info, 2, "R_mat_inv"); + require_shape(domain_info, 3, "coherentDomainMatrix"); + require_shape(occupancy_info, 1, "coherentDomainOccupancy"); + if (basis_info.shape[1] < 7) { + throw py::value_error("basis must have at least 7 columns"); + } + if (ff_info.shape[0] != basis_info.shape[0] || ff_info.shape[1] < 13) { + throw py::value_error( + "f_factors must have one row per basis atom and at least 13 columns" + ); + } + if (r_info.shape[0] != 3 || r_info.shape[1] != 3 + || r_inv_info.shape[0] != 3 || r_inv_info.shape[1] != 3) { + throw py::value_error("transform matrices must be shape (3, 3)"); + } + if (domain_info.shape[1] != 3 || domain_info.shape[2] != 4) { + throw py::value_error("coherentDomainMatrix must have shape (N, 3, 4)"); + } + if (occupancy_info.shape[0] != domain_info.shape[0]) { + throw py::value_error( + "coherentDomainOccupancy length must match coherentDomainMatrix" + ); + } + + const auto domain_matrices = effective_domain_matrices( + domain_info, + r_info, + r_inv_info + ); + const auto n_z = z_info.shape[0]; + const auto n_atoms = basis_info.shape[0]; + const auto n_domains = domain_info.shape[0]; + const auto *z_data = static_cast(z_info.ptr); + const auto *basis_data = static_cast(basis_info.ptr); + const auto *ff_data = static_cast(ff_info.ptr); + const auto *domain_data = static_cast(domain_info.ptr); + const auto *occupancy_data = static_cast(occupancy_info.ptr); + auto result = py::array_t(n_z); + auto *out = static_cast(result.request().ptr); + + constexpr double pi = 3.141592653589793238462643383279502884; + constexpr double two_pi = 2.0 * pi; + struct DensityAtomDomain { + double delta_z2; + double delta_para_q; + double center_z; + double base_real; + double base_imag; + std::array term_coefficients; + std::array exp_dpara_q; + std::array exp_dz; + double phase_real; + double phase_imag; + }; + + // zDensity_G evaluates the same atom/domain geometry for every profile + // sample. Keep the z-independent work outside the profile loop; the + // original Python implementation obtains the same benefit by operating + // on a whole z vector for each atom/domain pair. + std::vector atom_domains; + atom_domains.reserve(n_atoms * n_domains); + for (py::ssize_t i = 0; i < n_atoms; ++i) { + const double delta_z2 = get2(basis_data, basis_info, i, 5) + / (8.0 * pi * pi); + const double delta_para2 = get2(basis_data, basis_info, i, 4) + / (8.0 * pi * pi); + const double x = get2(basis_data, basis_info, i, 1); + const double y = get2(basis_data, basis_info, i, 2); + const double z_frac = get2(basis_data, basis_info, i, 3); + for (py::ssize_t d = 0; d < n_domains; ++d) { + const Matrix3 &mat = domain_matrices[d]; + const double x_rel = mat.value[0][0] * x + mat.value[0][1] * y + + mat.value[0][2] * z_frac + + get3(domain_data, domain_info, d, 0, 3); + const double y_rel = mat.value[1][0] * x + mat.value[1][1] * y + + mat.value[1][2] * z_frac + + get3(domain_data, domain_info, d, 1, 3); + const double z_rel = mat.value[2][0] * x + mat.value[2][1] * y + + mat.value[2][2] * z_frac + + get3(domain_data, domain_info, d, 2, 3); + DensityAtomDomain entry{ + delta_z2, + delta_para2 * q_para2, + z_rel * c, + (get2(ff_data, ff_info, i, 10) + get2(ff_data, ff_info, i, 11)) + / std::sqrt(two_pi * delta_z2), + get2(ff_data, ff_info, i, 12) / std::sqrt(two_pi * delta_z2), + {}, {}, {}, + 0.0, 0.0, + }; + for (int term = 0; term < 5; ++term) { + const double exponent = get2(ff_data, ff_info, i, term + 5); + const double exp_dpara = exponent + 0.5 * delta_para2; + entry.exp_dz[term] = exponent + 0.5 * delta_z2; + entry.term_coefficients[term] = get2(ff_data, ff_info, i, term) + / std::sqrt(4.0 * pi * entry.exp_dz[term]); + entry.exp_dpara_q[term] = exp_dpara * q_para2; + } + const double phase = -two_pi * (h * x_rel + k * y_rel); + const double weight = get2(basis_data, basis_info, i, 6) + * occupancy_data[d]; + entry.phase_real = weight * std::cos(phase); + entry.phase_imag = weight * std::sin(phase); + atom_domains.push_back(entry); + } + } + + py::gil_scoped_release release; + std::fill(out, out + n_z, complex128{0.0, 0.0}); + for (const auto &entry : atom_domains) { + for (py::ssize_t p = 0; p < n_z; ++p) { + const double dz = z_data[p] - entry.center_z; + const double dz_squared = dz * dz; + const double core = std::exp(-0.5 * ( + entry.delta_para_q + dz_squared / entry.delta_z2 + )); + double atom_real = entry.base_real * core; + const double atom_imag = entry.base_imag * core; + for (int term = 0; term < 5; ++term) { + atom_real += entry.term_coefficients[term] * std::exp( + -entry.exp_dpara_q[term] + - dz_squared / (4.0 * entry.exp_dz[term]) + ); + } + out[p] += complex128{ + entry.phase_real * atom_real - entry.phase_imag * atom_imag, + entry.phase_real * atom_imag + entry.phase_imag * atom_real, + }; + } + } + for (py::ssize_t p = 0; p < n_z; ++p) { + out[p] /= uc_area; + } + return result; +} + +void set_form_factor_cache_budget(const std::size_t bytes) { + form_factor_cache().set_budget(bytes); +} + +std::size_t form_factor_cache_expected_bytes( + const std::size_t points, + const std::size_t species +) { + return points * (species + 1) * sizeof(double); } PYBIND11_MODULE(_CTRcalc_cpp, module) { @@ -489,4 +1032,10 @@ PYBIND11_MODULE(_CTRcalc_cpp, module) { module.def("unitcell_F_uc_bulk_direct", &unitcell_F_uc_bulk_direct); module.def("unitcell_F_bulk", &unitcell_F_bulk); module.def("unitcell_F_uc", &unitcell_F_uc); + module.def("unitcell_zdensity_g", &unitcell_zdensity_g); + module.def("form_factor_cache_stats", &form_factor_cache_stats); + module.def("clear_form_factor_cache", []() { form_factor_cache().clear(); }); + module.def("reset_form_factor_cache_stats", &reset_form_factor_cache_stats); + module.def("set_form_factor_cache_budget", &set_form_factor_cache_budget, py::call_guard()); + module.def("form_factor_cache_expected_bytes", &form_factor_cache_expected_bytes); } diff --git a/orgui/datautils/xrayutils/test/test_CTRcalc.py b/orgui/datautils/xrayutils/test/test_CTRcalc.py index d3ea957..4ec59c1 100644 --- a/orgui/datautils/xrayutils/test/test_CTRcalc.py +++ b/orgui/datautils/xrayutils/test/test_CTRcalc.py @@ -30,6 +30,9 @@ __maintainer__ = "Timo Fuchs" __email__ = "fuchs@physik.uni-kiel.de" +import dataclasses +import copy +import importlib.util import unittest from unittest import mock import os @@ -39,7 +42,212 @@ from ... import util from .. import CTRcalc, CTRfilm, CTRplotutil, CTRsymmetry, CTRuc -from ..CTRdistributions import PoissonProfile, SkellamProfile +from ..CTRdistributions import PoissonProfile, SkellamProfile, SurfaceProfile +from ..CTRutil import generate_surface_termination_cells + + +HAS_ASE = importlib.util.find_spec("ase") is not None + + +class _FakePy3DmolModel: + def __init__(self): + self.styles = [] + + def setStyle(self, selection, style): + self.styles.append((selection, style)) + + +class _FakePy3DmolView: + def __init__(self, displayed=False): + self._orgui_plot3d_backend = "py3dmol" + self.uniqueid = "displayed" if displayed else None + self.models = [] + self.zoom_count = 0 + self.update_count = 0 + + def addModel(self, data, file_format): + self.models.append((data, file_format, _FakePy3DmolModel())) + + def getModel(self): + return self.models[-1][2] + + def zoomTo(self): + self.zoom_count += 1 + + def update(self): + self.update_count += 1 + + +class TestPlot3d(unittest.TestCase): + def setUp(self): + self.cell = CTRuc.UnitCell( + [2.0, 3.0, 4.0], + [90.0, 90.0, 90.0], + name="plot-test", + ) + self.cell.addAtom("C", [0.5, 0.0, 0.0], 0.1, 0.1, 1.0) + self.cell.addAtom("O", [0.0, 0.5, 0.0], 0.1, 0.1, 1.0) + self.cell.f = np.empty((2, 13), dtype=np.float64) + + @staticmethod + def _xyz_positions(model_data): + lines = model_data.splitlines() + return np.array( + [[float(value) for value in line.split()[1:]] for line in lines[2:]] + ) + + def test_py3dmol_models_preserve_positions_styles_and_increment(self): + view = _FakePy3DmolView(displayed=True) + + result = self.cell.plot3d( + 2, + 1, + 1, + figure=view, + backend="py3dmol", + ) + + self.assertIs(result, view) + self.assertEqual(len(view.models), 2) + self.assertEqual(view.update_count, 1) + self.assertEqual(view.zoom_count, 1) + carbon_xyz, file_format, carbon_model = view.models[0] + self.assertEqual(file_format, "xyz") + self.assertEqual(carbon_xyz.splitlines()[0], "2") + expected = self.cell.pos_cart_all(2, 1, 1) + expected_carbon = np.column_stack( + ( + expected["x"][expected["name"] == "C"], + expected["y"][expected["name"] == "C"], + expected["z"][expected["name"] == "C"], + ) + ) + np.testing.assert_allclose( + self._xyz_positions(carbon_xyz), + expected_carbon, + atol=1e-14, + ) + selection, style = carbon_model.styles[0] + self.assertEqual(selection, {}) + self.assertEqual(style["sphere"]["radius"], 0.76) + self.assertRegex(style["sphere"]["color"], r"^#[0-9a-f]{6}$") + + second_result = self.cell.plot3d(figure=view) + self.assertIs(second_result, view) + self.assertEqual(len(view.models), 4) + self.assertEqual(view.update_count, 2) + + def test_radius_scale_applies_to_py3dmol_radius(self): + view = _FakePy3DmolView() + + self.cell.plot3d( + figure=view, + backend="py3dmol", + radius_scale=0.5, + ) + + radii = [ + model.styles[0][1]["sphere"]["radius"] + for _, _, model in view.models + ] + self.assertEqual(radii, [0.38, 0.33]) + + def test_py3dmol_omits_unoccupied_atoms(self): + self.cell.basis[0, 6] = 0.0 + view = _FakePy3DmolView() + + self.cell.plot3d( + figure=view, + backend="py3dmol", + occuon=True, + ) + + self.assertEqual(len(view.models), 1) + self.assertTrue(view.models[0][0].splitlines()[2].startswith("O ")) + + def test_mayavi_backend_keeps_points3d_contract(self): + figure = mock.Mock() + figure._orgui_plot3d_backend = "mayavi" + mlab = mock.Mock() + + with mock.patch.object(CTRuc.importlib, "import_module", return_value=mlab): + result = self.cell.plot3d( + figure=figure, + backend="mayavi", + color=(0.1, 0.2, 0.3), + resolution=17, + ) + + self.assertIs(result, figure) + self.assertEqual(mlab.points3d.call_count, 2) + diameters = [ + call.kwargs["scale_factor"] for call in mlab.points3d.call_args_list + ] + self.assertEqual(diameters, [1.52, 1.32]) + kwargs = mlab.points3d.call_args.kwargs + self.assertEqual(kwargs["color"], (0.1, 0.2, 0.3)) + self.assertEqual(kwargs["resolution"], 17) + self.assertIs(kwargs["figure"], figure) + + def test_backend_validation_and_missing_dependency(self): + for radius_scale in (0, -1, np.nan, np.inf, None, "invalid"): + with self.subTest(radius_scale=radius_scale): + with self.assertRaisesRegex(ValueError, "positive and finite"): + self.cell.plot3d(radius_scale=radius_scale) + + with self.assertRaisesRegex(ValueError, "Unknown 3D plotting backend"): + self.cell.plot3d(backend="unknown") + + mayavi_figure = mock.Mock() + mayavi_figure._orgui_plot3d_backend = "mayavi" + with self.assertRaisesRegex(TypeError, "uses mayavi"): + self.cell.plot3d(figure=mayavi_figure, backend="py3dmol") + + with mock.patch.object( + CTRuc.importlib, + "import_module", + side_effect=ImportError("simulated missing dependency"), + ): + with self.assertRaisesRegex(ImportError, r"orGUI\[full\]"): + self.cell.plot3d(backend="py3dmol") + + def test_auto_prefers_py3dmol_in_kernel(self): + view = _FakePy3DmolView() + py3dmol = mock.Mock() + py3dmol.view.return_value = view + + with ( + mock.patch.object(CTRuc, "_in_jupyter_kernel", return_value=True), + mock.patch.object( + CTRuc.importlib, + "import_module", + return_value=py3dmol, + ) as import_module, + ): + result = self.cell.plot3d(backend="auto") + + self.assertIs(result, view) + import_module.assert_called_once_with("py3Dmol") + self.assertEqual(len(view.models), 2) + + def test_crystal_updates_one_shared_view_once(self): + surface = copy.deepcopy(self.cell) + crystal = CTRcalc.SXRDCrystal(self.cell, surface) + view = _FakePy3DmolView(displayed=True) + + result = crystal.plot3d( + 1, + 1, + 1, + figure=view, + backend="py3dmol", + radius_scale=0.5, + ) + + self.assertIs(result, view) + self.assertEqual(len(view.models), 4) + self.assertEqual(view.update_count, 1) + self.assertEqual(view.models[0][2].styles[0][1]["sphere"]["radius"], 0.38) class TestReadSXRDCrystal(unittest.TestCase): @@ -131,6 +339,42 @@ def testFromFile(self): self.assertEqual(str(restored), serialized) +@unittest.skipUnless(HAS_ASE, "ase is not installed") +class TestUnitCellCifImport(unittest.TestCase): + def test_cif_import_orders_atoms_by_fractional_coordinate(self): + cif = """data_ordering +_cell_length_a 5 +_cell_length_b 5 +_cell_length_c 5 +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_space_group_name_H-M_alt P1 +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +O1 O 0.3 0.4 0.8 +O2 O 0.1 0.8 0.2 +C1 C 0.4 0.2 0.2 +Fe1 Fe 0.9 0.1 0.2 +""" + with tempfile.TemporaryDirectory() as directory: + cif_path = os.path.join(directory, "ordering.cif") + with open(cif_path, "w") as handle: + handle.write(cif) + unitcell = CTRcalc.UnitCell.fromFile(cif_path) + + self.assertEqual(unitcell.names, ["Fe", "C", "O", "O"]) + np.testing.assert_allclose( + unitcell.basis[:, 1:4], + [[0.9, 0.1, 0.2], [0.4, 0.2, 0.2], [0.1, 0.8, 0.2], [0.3, 0.4, 0.8]], + ) + self.assertTrue(unitcell._allow_layer_order_normalization) + + class TestPoissonSurface(unittest.TestCase): def setUp(self): self.unitcell = CTRcalc.UnitCell( @@ -142,11 +386,24 @@ def setUp(self): self.unitcell.addAtom("C", [0.0, 0.0, 0.5], 0.1, 0.1, 1.0, layer=1) self.unitcell.coherentDomainOccupancy = [0.75] + def bind_surface(self, surface, loc=0.0, height=4.0): + film = CTRfilm.Film(copy.deepcopy(self.unitcell), name="underlying_film") + film.basis[0] = 2.0 + film.createLayers() + surface.stack_on( + loc, + height, + film.end_layer_number, + below_state=film.layer_state, + below_component=film, + ) + return film + def test_serialization_round_trip(self): surface = CTRfilm.PoissonSurface(self.unitcell) - surface.basis = np.array([4.0, 1.0]) + surface.basis = np.array([4.0, 1.0, 0.25]) surface.basis_0 = np.copy(surface.basis) - surface.errors = np.array([0.2, 0.1]) + surface.errors = np.array([0.2, 0.1, 0.05]) restored = CTRfilm.PoissonSurface.fromStr(surface.toStr()) @@ -160,57 +417,157 @@ def test_serialization_round_trip(self): self.assertEqual(restored.unitcell.name, self.unitcell.name) self.assertEqual(restored.toStr(), surface.toStr()) - def test_create_layers_assigns_poisson_occupancies(self): - surface = CTRfilm.PoissonSurface(self.unitcell) - surface.basis = np.array([4.0, 1.0]) + def test_create_layers_assigns_convolved_occupancies(self): + surface = CTRfilm.PoissonSurface( + self.unitcell, + profile=PoissonProfile(mean_change=2.0, alpha=0.5), + ) - surface.set_below(0.0, self.unitcell.a[2]) + self.bind_surface(surface) - expected = 0.75 * np.array( + lower, upper = surface.profile.support() + offsets = np.arange(lower, upper + 1) + material = surface.profile.occupancy(offsets) + top_stop = np.flatnonzero( + material > surface.profile.tail_probability + )[-1] + 1 + offsets = offsets[:top_stop] + material = material[:top_stop] + expected_surface = 0.75 * surface.profile.surface_occupancy(offsets) + expected_film = 0.75 * (material - (offsets < 0)) + expected_reference = -expected_surface + represented = (expected_surface > surface.profile.tail_probability) | ( + np.abs(expected_film) > surface.profile.tail_probability + ) + actual_surface = np.concatenate( + [uc.coherentDomainOccupancy for uc in surface.layer_ucs] + ) + actual_film = np.concatenate( + [uc.coherentDomainOccupancy for uc in surface.film_layer_ucs] + ) + actual_reference = np.concatenate( [ - 1.0, - 1.0 - np.exp(-1.0), - 1.0 - 2.0 * np.exp(-1.0), + uc.coherentDomainOccupancy + for uc in surface._film_termination_ucs.values() ] ) np.testing.assert_allclose( - surface.layer_ucs[0].coherentDomainOccupancy, - expected[[0, 2]], + np.sort(actual_surface), np.sort(expected_surface[represented]) ) np.testing.assert_allclose( - surface.layer_ucs[1].coherentDomainOccupancy, - expected[[1]], + np.sort(actual_film), np.sort(expected_film[represented]) ) - self.assertEqual( - [len(uc.coherentDomainMatrix) for uc in surface.layer_ucs], - [2, 1], + np.testing.assert_allclose( + np.sort(actual_reference), np.sort(expected_reference[represented]) ) def test_profile_support_and_serialization_are_numerical_only(self): profile = PoissonProfile( mean_change=1.0, - offset=-1.0, + alpha=0.25, + offset=0.25, tail_probability=1e-8, ) surface = CTRfilm.PoissonSurface(self.unitcell, profile=profile) surface.basis_0[:] = surface.basis - surface.set_below(7.0, 11.0) + self.bind_surface(surface, loc=7.0, height=11.0) self.assertEqual(surface.pos_absolute, 11.0) - self.assertEqual(surface.stacking_height_absolute, 11.0) - self.assertGreater(surface.height_absolute, 11.0) + self.assertEqual(surface.stacking_height_absolute, 13.5) + self.assertGreater(surface.height_absolute, 13.5) self.assertLessEqual( surface.layer_ucs[-1].coherentDomainOccupancy[-1], self.unitcell.coherentDomainOccupancy[0], ) restored = CTRfilm.PoissonSurface.fromStr(surface.toStr()) - self.assertFalse(restored._legacy_absolute_width) self.assertAlmostEqual(restored.profile.mean_change, 1.0) - self.assertAlmostEqual(restored.profile.offset, -1.0) + self.assertAlmostEqual(restored.profile.alpha, 0.25) + self.assertAlmostEqual(restored.profile.offset, 0.25) self.assertAlmostEqual(restored.profile.tail_probability, 1e-8) self.assertEqual(restored.toStr(), surface.toStr()) + def test_supercell_termination_bank_selects_complete_relaxed_slabs(self): + slab = self.unitcell.supercell((1, 1, 2), symmetry="independent") + termination_1 = slab.affine_layer_transform([0, 0, 0]).as_surface_termination( + 1, + name="surface_termination_1", + ) + termination_0 = slab.affine_layer_transform([0, 0, 1]).as_surface_termination( + 0, + name="surface_termination_0", + ) + top_atom = np.argmax(termination_0.basis[:, 3]) + termination_0.basis[top_atom, 3] += 0.025 + termination_0.basis_0[top_atom, 3] += 0.025 + + surface = CTRfilm.PoissonSurface( + {0: termination_0, 1: termination_1}, + profile=PoissonProfile(1.0, alpha=1.0), + name="surface", + ) + serialized = surface.toStr() + restored = CTRfilm.PoissonSurface.fromStr(serialized) + self.assertEqual(set(restored.termination_cells), {0.0, 1.0}) + self.assertIn("TerminationUnitCell 0", restored.toStr()) + + self.bind_surface(surface) + self.assertEqual( + {cell.basis.shape[0] for cell in surface.termination_cells.values()}, + {4}, + ) + for layer, cell in surface.termination_cells.items(): + np.testing.assert_array_equal(cell.basis[:, 7], layer) + self.assertEqual(cell.layer_behavior, "select") + self.assertTrue( + all( + cell.coherentDomainMatrix + for cell in surface.layer_ucs + ) + ) + self.assertEqual(surface.toStr(), serialized) + + def test_surface_termination_helper_accepts_film_cycle_owner(self): + film = CTRfilm.Film(copy.deepcopy(self.unitcell)) + slab = self.unitcell.supercell((1, 1, 2), symmetry="independent") + + terminations = generate_surface_termination_cells( + slab, + film, + name_template="relaxed_{layer:g}", + ) + + self.assertEqual(set(terminations), {0.0, 1.0}) + self.assertEqual( + [terminations[layer].name for layer in (0.0, 1.0)], + ["relaxed_0", "relaxed_1"], + ) + for layer, cell in terminations.items(): + self.assertEqual(cell.basis.shape[0], 4) + np.testing.assert_array_equal(cell.basis[:, 7], layer) + self.assertEqual(cell.layer_behavior, "select") + self.assertEqual(tuple(cell.layer_cycle.layers), (layer,)) + + with self.assertRaisesRegex(ValueError, "integer multiple"): + generate_surface_termination_cells(slab, (0, 1, 2)) + + def test_termination_bank_requires_one_cell_per_film_cycle(self): + termination = self.unitcell.as_surface_termination(0) + surface = CTRfilm.PoissonSurface( + {0: termination}, + profile=PoissonProfile(0.0), + ) + film = CTRfilm.Film(copy.deepcopy(self.unitcell)) + film.basis[0] = 2.0 + crystal = CTRcalc.SXRDCrystal( + self.unitcell, + film, + surface, + stacking=np.array([1, 2]), + ) + with self.assertRaisesRegex(ValueError, "exactly one surface unit cell"): + crystal.apply_stacking() + def test_distribution_support_bounds_omitted_tail(self): poisson_profile = PoissonProfile(2.5, tail_probability=1e-9) _, upper = poisson_profile.support() @@ -221,32 +578,92 @@ def test_distribution_support_bounds_omitted_tail(self): self.assertLess(lower, 0) self.assertGreater(upper, 0) - def test_signed_poisson_process_changes_mean_surface_height(self): - for width, offset in ( - (2.0, 0.0), - (-2.0, 0.0), - (2.0, -2.0), - (-2.0, 2.0), + def test_step_poisson_convolution_preserves_signed_mean(self): + for width in (2.3, -2.3): + for alpha in (0.0, 0.25, 1.0): + offset = 0.4 + profile = PoissonProfile( + mean_change=width, + alpha=alpha, + offset=offset, + tail_probability=1e-12, + ) + lower, upper = profile.support() + layers = np.arange(lower, upper + 1) + self.assertAlmostEqual( + np.sum(profile.correction(layers)), + width + offset, + places=10, + ) + + def test_surface_occupancy_is_neighbor_difference(self): + for profile in ( + PoissonProfile(2.5, alpha=0.4, tail_probability=1e-12), + PoissonProfile(-2.5, alpha=0.4, tail_probability=1e-12), ): - profile = PoissonProfile( - mean_change=width, - offset=offset, - tail_probability=1e-12, - ) lower, upper = profile.support() - layers = np.arange(lower, upper + 1) - self.assertAlmostEqual( - np.sum(profile.correction(layers)), - width + offset, - places=10, + offsets = np.arange(lower, upper + 1) + material = profile.occupancy(offsets) + exposed = profile.surface_occupancy(offsets) + + np.testing.assert_allclose(exposed[:-1], material[:-1] - material[1:]) + self.assertEqual(exposed[-1], material[-1]) + self.assertTrue(np.all(exposed >= 0.0)) + self.assertAlmostEqual(np.sum(exposed), material[0]) + + def test_surface_profile_rejects_nonmonotonic_occupancy(self): + class InvalidProfile(SurfaceProfile): + def support(self): + return 0, 2 + + def occupancy(self, offsets): + return np.array([0.5, 0.6, 0.1]) + + with self.assertRaisesRegex(ValueError, "must not increase"): + InvalidProfile().surface_occupancy(np.arange(3)) + + def test_probability_is_step_poisson_convolution(self): + profile = PoissonProfile(mean_change=2.0, alpha=0.25) + changes = np.arange(0, 8) + poisson_probability = np.exp(-0.5) * 0.5 ** np.arange(7) / np.array( + [1, 1, 2, 6, 24, 120, 720] + ) + expected = np.zeros_like(changes, dtype=np.float64) + expected[1:] += 0.5 * poisson_probability + expected[2:] += 0.5 * poisson_probability[:-1] + np.testing.assert_allclose(profile.probability(changes), expected) + + dissolution = PoissonProfile(mean_change=-2.0, alpha=0.25) + np.testing.assert_allclose( + dissolution.probability(-changes), + expected, + ) + + def test_alpha_selects_layer_by_layer_and_poisson_limits(self): + layer_by_layer = PoissonProfile(mean_change=-1.25, alpha=0.0) + np.testing.assert_allclose( + layer_by_layer.probability([-1, -2]), + [0.75, 0.25], + ) + + poisson_only = PoissonProfile(mean_change=1.25, alpha=1.0) + np.testing.assert_allclose( + poisson_only.probability([0, 1, 2]), + np.exp(-1.25) * np.array([1.0, 1.25, 1.25**2 / 2.0]), + ) + + with self.assertRaisesRegex(ValueError, "alpha"): + PoissonProfile( + mean_change=1.0, + alpha=1.01, ) def test_poisson_surface_growth_and_etching_are_signed(self): growth = CTRfilm.PoissonSurface( self.unitcell, - profile=PoissonProfile(mean_change=1.0), + profile=PoissonProfile(mean_change=1.0, alpha=0.4), ) - growth.set_below(0.0, 8.0) + self.bind_surface(growth, height=8.0) growth_occupancy = np.concatenate( [layer.coherentDomainOccupancy for layer in growth.layer_ucs] ) @@ -255,39 +672,51 @@ def test_poisson_surface_growth_and_etching_are_signed(self): etching = CTRfilm.PoissonSurface( self.unitcell, - profile=PoissonProfile(mean_change=-1.0), + profile=PoissonProfile(mean_change=-1.0, alpha=0.4), ) - etching.set_below(0.0, 8.0) - etching_occupancy = np.concatenate( + self.bind_surface(etching, height=8.0) + etching_surface_occupancy = np.concatenate( [layer.coherentDomainOccupancy for layer in etching.layer_ucs] ) - self.assertTrue(np.all(etching_occupancy < 0)) + etching_film_occupancy = np.concatenate( + [layer.coherentDomainOccupancy for layer in etching.film_layer_ucs] + ) + self.assertTrue(np.all(etching_surface_occupancy > 0)) + self.assertTrue(np.all(etching_film_occupancy < 0)) self.assertAlmostEqual(etching.mean_height_absolute, 6.0) - compensated = CTRfilm.PoissonSurface( + def test_offset_is_an_independent_fit_parameter(self): + surface = CTRfilm.PoissonSurface( self.unitcell, - profile=PoissonProfile(mean_change=1.0, offset=-1.0), - ) - compensated.set_below(0.0, 8.0) - compensated_occupancy = np.concatenate( - [layer.coherentDomainOccupancy for layer in compensated.layer_ucs] + profile=PoissonProfile(mean_change=1.0, alpha=0.4, offset=0.25), ) - self.assertTrue(np.any(compensated_occupancy < 0)) - self.assertTrue(np.any(compensated_occupancy > 0)) - self.assertAlmostEqual(compensated.mean_height_absolute, 8.0) + surface.basis_0[:] = surface.basis + surface.addFitParameter("offset", limits=(-2.0, 2.0)) - def test_zero_width_poisson_surface_stacks_without_domains(self): + np.testing.assert_allclose(surface.getStartParamAndLimits()[0], [0.25]) + surface.setFitParameters([-0.5]) + + self.assertAlmostEqual(surface.profile.mean_change, 1.0) + self.assertAlmostEqual(surface.profile.alpha, 0.4) + self.assertAlmostEqual(surface.profile.offset, -0.5) + self.assertAlmostEqual(surface.profile.expected_height_change, 0.5) + + def test_zero_width_identical_surface_is_zero_net_correction(self): + film = CTRfilm.Film(copy.deepcopy(self.unitcell), name="film") + film.basis[0] = 2.0 surface = CTRfilm.PoissonSurface( - self.unitcell, + copy.deepcopy(self.unitcell), profile=PoissonProfile(mean_change=0.0), ) - crystal = CTRcalc.SXRDCrystal(self.unitcell, surface, stacking=np.array([1])) + crystal = CTRcalc.SXRDCrystal( + self.unitcell, film, surface, stacking=np.array([1, 2]) + ) crystal.apply_stacking() - self.assertEqual(surface.stacking_height_absolute, 0.0) + self.assertEqual(surface.stacking_height_absolute, film.height_absolute) self.assertTrue( - all(not layer.coherentDomainMatrix for layer in surface.layer_ucs) + any(layer.coherentDomainMatrix for layer in surface.layer_ucs) ) h = np.zeros(3) ell = np.linspace(0.1, 0.3, 3) @@ -300,26 +729,98 @@ def test_zero_width_poisson_surface_stacks_without_domains(self): surface.zDensity_G(np.linspace(-1.0, 1.0, 5), 0, 0), 0.0, ) + np.testing.assert_allclose(surface.optical_profile()[:, 1:], 0.0) - def test_nominal_delta_width_serialization_migrates_to_offset(self): - transitional = CTRfilm.PoissonSurface( - self.unitcell, - profile=PoissonProfile(mean_change=1.0), - ).toStr() - transitional = transitional.replace( - "W/layers offset/layers", - "Width/layers deltaW/layers", - ).replace( - "1.00000 0.00000", - "0.00000 1.00000", + def test_zero_width_replaces_top_film_layer_with_surface_structure(self): + film_cell = copy.deepcopy(self.unitcell) + surface_cell = copy.deepcopy(self.unitcell) + surface_cell.names[:] = ["O", "O"] + surface_cell.basis[:, 0] = 8 + surface_cell.setEnergy(10000.0) + film = CTRfilm.Film(film_cell, name="film") + film.basis[0] = 2.0 + surface = CTRfilm.PoissonSurface( + surface_cell, + profile=PoissonProfile(mean_change=0.0), + name="surface", + ) + crystal = CTRcalc.SXRDCrystal( + self.unitcell, film, surface, stacking=np.array([1, 2]) ) + crystal.apply_stacking() - restored = CTRfilm.PoissonSurface.fromStr(transitional) + surface_occupancies = np.concatenate( + [layer.coherentDomainOccupancy for layer in surface.layer_ucs] + ) + film_corrections = np.concatenate( + [layer.coherentDomainOccupancy for layer in surface.film_layer_ucs] + ) + reference_corrections = np.concatenate( + [ + cell.coherentDomainOccupancy + for cell in surface._film_termination_ucs.values() + ] + ) + np.testing.assert_allclose(surface_occupancies, [0.75]) + np.testing.assert_allclose(film_corrections, [0.0]) + np.testing.assert_allclose(reference_corrections, [-0.75]) + h = np.zeros(3) + ell = np.linspace(0.1, 0.3, 3) + self.assertTrue(np.any(np.abs(surface.F_uc(h, h, ell)) > 0.0)) - np.testing.assert_allclose(restored.basis, [1.0, -1.0]) - self.assertAlmostEqual(restored.profile.expected_height_change, 0.0) - self.assertIn("W/layers offset/layers", restored.toStr()) + def test_surface_requires_compatible_underlying_film(self): + surface = CTRfilm.PoissonSurface( + copy.deepcopy(self.unitcell), profile=PoissonProfile(1.0) + ) + crystal = CTRcalc.SXRDCrystal( + self.unitcell, surface, stacking=np.array([1]) + ) + with self.assertRaisesRegex(ValueError, "immediately above a Film"): + crystal.apply_stacking() + mismatched = copy.deepcopy(self.unitcell) + mismatched.a[2] *= 1.1 + film = CTRfilm.Film(mismatched) + film.basis[0] = 2.0 + crystal = CTRcalc.SXRDCrystal( + self.unitcell, film, surface, stacking=np.array([1, 2]) + ) + with self.assertRaisesRegex(ValueError, "integer multiple"): + crystal.apply_stacking() + + def test_repeated_stacking_refreshes_underlying_film_views(self): + film = CTRfilm.Film(copy.deepcopy(self.unitcell), name="film") + film.basis[0] = 2.0 + surface = CTRfilm.PoissonSurface( + copy.deepcopy(self.unitcell), + profile=PoissonProfile(1.0, alpha=0.5), + name="surface", + ) + crystal = CTRcalc.SXRDCrystal( + self.unitcell, film, surface, stacking=np.array([1, 2]) + ) + crystal.apply_stacking() + first_counts = [ + len(layer.coherentDomainMatrix) for layer in surface.film_layer_ucs + ] + + film.unitcell.basis[:, 6] *= 0.5 + crystal.apply_stacking() + + self.assertEqual( + [len(layer.coherentDomainMatrix) for layer in surface.film_layer_ucs], + first_counts, + ) + self.assertTrue( + all( + np.shares_memory(layer.basis, film.unitcell.basis) + for layer in surface.film_layer_ucs + ) + ) + np.testing.assert_allclose( + np.concatenate([layer.basis[:, 6] for layer in surface.film_layer_ucs]), + 0.5, + ) class TestLayerStacking(unittest.TestCase): @staticmethod @@ -334,6 +835,70 @@ def make_layered_unitcell(name="layered"): unitcell.layerpos[float(layer)] = z return unitcell + def test_split_in_layers_rejects_interleaved_native_layers(self): + unitcell = CTRcalc.UnitCell([3.0, 3.0, 6.0], [90.0, 90.0, 90.0]) + for name, z, layer in (("Fe", 0.1, 1), ("Re", 0.6, 2), ("O", 0.2, 1)): + unitcell.addAtom(name, [0.0, 0.0, z], 0.1, 0.1, 1.0, layer=layer) + + with self.assertRaisesRegex(ValueError, "must be written in layer order"): + unitcell.split_in_layers() + + def test_split_in_layers_normalizes_cif_metadata_and_couplings(self): + unitcell = CTRcalc.UnitCell([3.0, 3.0, 6.0], [90.0, 90.0, 90.0]) + for name, z, layer in (("Fe", 0.1, 1), ("Re", 0.6, 2), ("O", 0.2, 1), ("O", 0.7, 2)): + unitcell.addAtom(name, [0.0, 0.0, z], 0.1, 0.1, 1.0, layer=layer) + unitcell._allow_layer_order_normalization = True + unitcell.symmetry_metadata = CTRsymmetry.SurfaceSymmetryModel( + CTRsymmetry.SurfaceCellSpec( + (3.0, 3.0, 6.0), + (90.0, 90.0, 90.0), + np.identity(3), + layer_origins=(0.0, 0.5), + ), + (), + atoms=[ + CTRsymmetry.GeneratedWyckoffAtom( + atom_index=2, + element="O", + site_id="O_1", + wyckoff_label="1a", + parent_fractional=np.array([0.0, 0.0, 0.2]), + surface_fractional=np.array([0.0, 0.0, 0.2]), + layer=1, + couplings=( + CTRsymmetry.WyckoffCoupling( + atom_index=2, + coordinate="z", + variable="u", + constant=0.2, + factor=1.0, + site_id="O_1", + ), + ), + site_couplings=( + CTRsymmetry.WyckoffSiteCoupling( + atom_index=2, + coordinate="z", + axis="z", + factor=1.0, + site_id="O_1", + ), + ), + ), + ], + ) + + layers = unitcell.split_in_layers() + + self.assertEqual(unitcell.names, ["Fe", "O", "Re", "O"]) + self.assertEqual(layers[1.0].names, ["Fe", "O"]) + self.assertEqual(layers[2.0].names, ["Re", "O"]) + self.assertTrue(np.shares_memory(layers[1.0].basis, unitcell.basis)) + self.assertTrue(np.shares_memory(layers[2.0].basis, unitcell.basis)) + atom = unitcell.atom_wyckoff_metadata(1) + self.assertEqual(atom.couplings[0].atom_index, 1) + self.assertEqual(atom.site_couplings[0].atom_index, 1) + def test_unitcell_selects_layer_after_below_layer(self): unitcell = self.make_layered_unitcell() unitcell.layer_behavior = "select" @@ -822,8 +1387,9 @@ def test_unitcell_supercell_preserves_shared_wyckoff_site(self): [0.5, 0.5], ) - supercell.addWyckoffParameter("C_1", "u", limits=(-0.2, 0.2)) - supercell.setFitParameters([0.1]) + supercell.addWyckoffParameter("C_1", "u", limits=(0.1, 0.4)) + np.testing.assert_allclose(supercell.getInitialParameters(), [0.25]) + supercell.setFitParameters([0.35]) np.testing.assert_allclose(supercell.basis[:, 1], [0.175, 0.675]) @@ -845,9 +1411,9 @@ def test_unitcell_supercell_independent_wyckoff_sites_fit_separately(self): supercell.addWyckoffParameter( "C_1_copy0", "u", - limits=(-0.2, 0.2), + limits=(0.1, 0.4), ) - supercell.setFitParameters([0.1]) + supercell.setFitParameters([0.35]) np.testing.assert_allclose(supercell.basis[:, 1], [0.175, 0.625]) @@ -860,12 +1426,29 @@ def test_unitcell_supercell_independent_wyckoff_sites_fit_separately(self): def test_film_forwards_wyckoff_parameters_to_template_unitcell(self): film = CTRfilm.Film(self.make_single_wyckoff_cell()) - film.addWyckoffParameter("C_1", "u", limits=(-0.2, 0.2)) - film.setFitParameters([0.1]) + film.addWyckoffParameter("C_1", "u", limits=(0.1, 0.4)) + np.testing.assert_allclose(film.getInitialParameters(), [0.25]) + film.setFitParameters([0.35]) self.assertEqual(film.unitcell.wyckoff_sites()[0]["status"], "symmetry_preserving") np.testing.assert_allclose(film.unitcell.basis[:, 1], [0.35]) + def test_film_forwards_absolute_plural_wyckoff_parameters(self): + film = CTRfilm.Film(self.make_single_wyckoff_cell()) + + parameters = film.addWyckoffParameters( + "C_1", + limits={"u": (0.1, 0.4)}, + ) + + self.assertEqual(len(parameters), 1) + np.testing.assert_allclose( + film.getStartParamAndLimits(), + ([0.25], [0.1], [0.4]), + ) + film.setFitParameters([0.3]) + np.testing.assert_allclose(film.unitcell.basis[:, 1], [0.3]) + def test_poisson_surface_forwards_wyckoff_shifts_to_template_unitcell(self): surface = CTRfilm.PoissonSurface(self.make_single_wyckoff_cell()) @@ -906,10 +1489,11 @@ def test_epitaxy_interface_accepts_unitcell_list_for_wyckoff_parameters(self): parameters = interface.addWyckoffParameter( "C_1", "u", - limits=(-0.2, 0.2), + limits=(0.1, 0.4), unitcell=["top", "bottom"], ) - interface.setFitParameters([0.1, 0.1]) + np.testing.assert_allclose(interface.getInitialParameters(), [0.25, 0.25]) + interface.setFitParameters([0.35, 0.35]) self.assertEqual(len(parameters), 2) self.assertEqual(interface.uc_top.wyckoff_sites()[0]["status"], "symmetry_preserving") @@ -932,9 +1516,22 @@ def test_sxrdcrystal_links_wyckoff_parameters_across_unitcells(self): "film2": ("C_1", "u"), }, name="shared_u", - limits=(-0.2, 0.2), + limits=(0.1, 0.4), ) - crystal.setParameters([0.1]) + np.testing.assert_allclose( + crystal.getStartParamAndLimits(), + ([0.25], [0.1], [0.4]), + ) + np.testing.assert_allclose( + film1.getStartParamAndLimits()[1:], + ([0.1], [0.4]), + ) + crystal.setLimits([(0.2, 0.3)]) + np.testing.assert_allclose( + film1.getStartParamAndLimits()[1:], + ([0.2], [0.3]), + ) + crystal.setParameters([0.35]) self.assertEqual(parameter.name, "shared_u") self.assertEqual(crystal.fitparnames, ["shared_u"]) @@ -988,9 +1585,10 @@ def test_sxrdcrystal_links_wyckoff_parameters_inside_interface(self): } }, name="shared_interface_u", - limits=(-0.2, 0.2), + limits=(0.1, 0.4), ) - crystal.setParameters([0.1]) + np.testing.assert_allclose(crystal.getInitialParameters(), [0.25]) + crystal.setParameters([0.35]) self.assertEqual(crystal.fitparnames, ["shared_interface_u"]) self.assertEqual(interface.uc_top.wyckoff_sites()[0]["status"], "symmetry_preserving") @@ -998,6 +1596,34 @@ def test_sxrdcrystal_links_wyckoff_parameters_inside_interface(self): np.testing.assert_allclose(interface.uc_top.basis[:, 1], [0.35]) np.testing.assert_allclose(interface.uc_bottom.basis[:, 1], [0.35]) + def test_sxrdcrystal_distributes_absolute_wyckoff_parameter(self): + bulk = self.make_single_wyckoff_cell() + film1 = self.make_single_wyckoff_cell() + film2 = self.make_single_wyckoff_cell() + bulk.name = "bulk_template" + film1.name = "film1" + film2.name = "film2" + film2.basis[:, 1] = 0.27 + film2.basis_0[:, 1] = 0.27 + film2.symmetry_metadata.sites = ( + dataclasses.replace( + film2.symmetry_metadata.sites[0], + variables={"u": 0.27}, + ), + ) + crystal = CTRcalc.SXRDCrystal(bulk, film1, film2) + + crystal.addWyckoffParameter( + {"film1": ("C_1", "u"), "film2": ("C_1", "u")}, + name="mean_u", + limits=(0.2, 0.35), + ) + + np.testing.assert_allclose(crystal.getInitialParameters(), [0.26]) + crystal.setParameters([0.28]) + np.testing.assert_allclose(film1.basis[:, 1], [0.28]) + np.testing.assert_allclose(film2.basis[:, 1], [0.28]) + def test_sxrdcrystal_links_wyckoff_shift_to_interface_unitcell_key(self): bulk = self.make_single_wyckoff_cell() top = self.make_single_wyckoff_cell() @@ -1035,7 +1661,8 @@ def test_film_and_poisson_rotate_cyclic_layer_order(self): self.assertAlmostEqual(film.height_absolute, 4.0) surface = CTRfilm.PoissonSurface(self.make_layered_unitcell("surface")) - surface.basis[:] = [2, 0] + surface.basis[:] = [2, 0, 0] + surface._bind_underlying_component(film) surface.start_layer_number = 2 surface.createLayers() @@ -1048,7 +1675,7 @@ def test_film_and_poisson_rotate_cyclic_layer_order(self): ) self.assertAlmostEqual( surface.layer_ucs[1].coherentDomainMatrix[0][2, 3], - 1 / 3, + -1 / 3, ) def test_epitaxy_interface_rotates_cyclic_layer_order(self): @@ -1057,13 +1684,14 @@ def test_epitaxy_interface_rotates_cyclic_layer_order(self): self.make_layered_unitcell("bottom"), fixed_ucs=3, ) - interface.basis[:] = [0.5, 0.0] + interface.basis[:2] = [0.5, 0.0] interface.stack_on(0.0, 12.0, 1) self.assertEqual(interface.start_layer_number, 2) self.assertEqual(interface.end_layer_number, 1) np.testing.assert_array_equal(interface.layer_order, [2, 3, 1]) - self.assertAlmostEqual(interface.pos_absolute, 12.0) + self.assertAlmostEqual(interface.loc_absolute, 12.0) + self.assertLess(interface.pos_absolute, interface.loc_absolute) self.assertGreater(interface.height_absolute, 12.0) self.assertGreater( interface.top_layers[-1].coherentDomainMatrix[0][2, 3], @@ -1082,7 +1710,7 @@ def test_sxrdcrystal_passes_top_layer_to_object_above(self): surface = CTRfilm.PoissonSurface(self.make_layered_unitcell("surface")) surface.name = "surface" - surface.basis[:] = [5, 0] + surface.basis[:] = [5, 0, 0] crystal = CTRcalc.SXRDCrystal( self.make_layered_unitcell("bulk"), @@ -1179,7 +1807,7 @@ def test_rotated_cycle_is_compatible_without_transition(self): self.assertEqual(lower.end_layer_number, 3) self.assertEqual(upper.start_layer_number, 1) - def test_profile_interface_does_not_advance_nominal_cursor(self): + def test_profile_interface_support_is_not_owned_by_film(self): lower = self.make_layered_unitcell("lower") interface = CTRfilm.EpitaxyInterface( self.make_layered_unitcell("top"), @@ -1187,7 +1815,7 @@ def test_profile_interface_does_not_advance_nominal_cursor(self): profile=SkellamProfile(0.5), ) film = CTRfilm.Film(self.make_layered_unitcell("film")) - film.basis[0] = 3 + film.basis[0] = 18 crystal = CTRcalc.SXRDCrystal( lower, interface, @@ -1197,16 +1825,84 @@ def test_profile_interface_does_not_advance_nominal_cursor(self): crystal.apply_stacking() - self.assertEqual(interface.stacking_height_absolute, 0.0) - self.assertEqual(film.pos_absolute, 0.0) - self.assertAlmostEqual(film.height_absolute, 6.0) + self.assertAlmostEqual( + interface.stacking_height_absolute, interface.height_absolute + ) + self.assertAlmostEqual( + interface.stacking_loc_absolute, + interface.loc_absolute + + interface._strain_coupling_displacement, + ) + self.assertAlmostEqual(film.pos_absolute, interface.height_absolute) + self.assertGreater(film.pos_absolute, interface.loc_absolute) + self.assertAlmostEqual( + film.height_absolute - interface.loc_absolute, + film.basis[0] * film.unitcell.a[2] / len(film.layers), + ) + layer_cycle = interface.layer_cycle.layers + next_index = (layer_cycle.index(interface.end_layer_number) + 1) % len( + layer_cycle + ) + self.assertEqual(film.start_layer_number, layer_cycle[next_index]) restored = CTRfilm.EpitaxyInterface.fromStr(interface.toStr()) - self.assertFalse(restored._legacy_support_cursor) self.assertIsInstance(restored.profile, SkellamProfile) np.testing.assert_allclose(restored.basis, interface.basis) + self.assertNotIn("support_cursor", interface.toStr()) + + def test_profile_interface_can_consume_entire_film_width(self): + lower = self.make_layered_unitcell("lower") + interface = CTRfilm.EpitaxyInterface( + self.make_layered_unitcell("top"), + self.make_layered_unitcell("bottom"), + profile=SkellamProfile(0.5), + ) + interface.stack_on( + 0.0, + 0.0, + lower.end_layer_number, + below_state=lower.layer_state, + ) + film = CTRfilm.Film(self.make_layered_unitcell("film")) + film.basis[0] = ( + (interface.height_absolute - interface.loc_absolute) + * len(film.layers) / film.unitcell.a[2] + ) + crystal = CTRcalc.SXRDCrystal( + lower, interface, film, stacking=np.array([1, 2]) + ) + + crystal.apply_stacking() + + self.assertAlmostEqual(film.pos_absolute, interface.height_absolute) + self.assertAlmostEqual(film.height_absolute, interface.height_absolute) + self.assertEqual( + sum(len(layer.coherentDomainMatrix) for layer in film.layer_ucs), 0 + ) + np.testing.assert_allclose( + film.F_uc( + np.array([0.0]), np.array([0.0]), np.array([1.0]) + ), + 0.0, + ) - def test_profile_interface_is_a_correction_not_extra_material(self): + def test_profile_interface_rejects_film_narrower_than_support(self): + lower = self.make_layered_unitcell("lower") + interface = CTRfilm.EpitaxyInterface( + self.make_layered_unitcell("top"), + self.make_layered_unitcell("bottom"), + profile=SkellamProfile(0.5), + ) + film = CTRfilm.Film(self.make_layered_unitcell("film")) + film.basis[0] = 3 + crystal = CTRcalc.SXRDCrystal( + lower, interface, film, stacking=np.array([1, 2]) + ) + + with self.assertRaisesRegex(ValueError, "shorter than lower-component"): + crystal.apply_stacking() + + def test_profile_interface_owns_upper_support_material(self): unitcell = self.make_layered_unitcell("material") interface = CTRfilm.EpitaxyInterface( self.make_layered_unitcell("top"), @@ -1227,47 +1923,536 @@ def test_profile_interface_is_a_correction_not_extra_material(self): [layer.coherentDomainOccupancy for layer in interface.bottom_layers] ) - self.assertTrue(np.any(top_occupancy < 0.0)) + self.assertTrue(np.all(top_occupancy >= 0.0)) + self.assertTrue(np.any(top_occupancy > 0.9)) self.assertTrue(np.any(bottom_occupancy < 0.0)) - np.testing.assert_allclose( - np.sort(top_occupancy), - np.sort(-bottom_occupancy), - atol=1e-15, + self.assertTrue(np.any(bottom_occupancy > 0.0)) + split = bottom_occupancy.size // 2 + total_occupancy = ( + top_occupancy + bottom_occupancy[:split] + bottom_occupancy[split:] ) + self.assertTrue(np.any(np.isclose(total_occupancy, 0.0))) + self.assertTrue(np.any(np.isclose(total_occupancy, 1.0))) + @staticmethod + def make_epitaxy_cells(): + top = TestLayerStacking.make_layered_unitcell("top") + top.a = np.array([3.0, 3.0, 4.0]) + bottom = TestLayerStacking.make_layered_unitcell("bottom") + return top, bottom + + def make_epitaxy_interface(self, strain_coupling=1.0, offset=0.0): + top, bottom = self.make_epitaxy_cells() + interface = CTRfilm.EpitaxyInterface( + top, + bottom, + profile=SkellamProfile(0.5), + fixed_ucs=6, + ) + interface.basis[:] = [0.5, 0.0, strain_coupling, offset] + interface.createInterfaceCells() + return interface -class TestLegacyLayeredCTR(unittest.TestCase): - def test_legacy_xtal_reconstructs_reference_interface(self): - repository_root = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + def test_epitaxy_bulk_replacement_subtracts_fixed_lattice_positions(self): + interface = self.make_epitaxy_interface(strain_coupling=1.0) + + for layer in interface.bottom_layers: + split = len(layer.coherentDomainMatrix) // 2 + additions = layer.coherentDomainMatrix[:split] + fixed_bulk = layer.coherentDomainMatrix[split:] + np.testing.assert_allclose( + [matrix[2, 2] for matrix in fixed_bulk], + 1.0, + ) + self.assertTrue( + any( + not np.allclose(addition[2], fixed[2]) + for addition, fixed in zip(additions, fixed_bulk) + ) + ) + self.assertTrue( + np.any(np.asarray(layer.coherentDomainOccupancy[split:]) < 0.0) + ) + + def test_epitaxy_strain_coupled_field_is_anchored_to_deep_bulk(self): + top, bottom = self.make_epitaxy_cells() + width = 30.0 / bottom.a[2] + interface = CTRfilm.EpitaxyInterface( + top, + bottom, + profile=SkellamProfile( + width, + tail_probability=1e-4, + ), + ) + interface.basis[:] = [width, 0.0, 1.0, 0.0] + interface.createInterfaceCells() + + lower_layer = interface.bottom_layers[0] + split = len(lower_layer.coherentDomainMatrix) // 2 + addition = lower_layer.coherentDomainMatrix[0] + fixed_bulk = lower_layer.coherentDomainMatrix[split] + layer_id = interface.layer_order[0] + layer_origin = interface.uc_bottom.layerpos[layer_id] + addition_origin = ( + addition[2, 2] * layer_origin + addition[2, 3] + ) * lower_layer.a[2] + fixed_origin = ( + fixed_bulk[2, 2] * layer_origin + fixed_bulk[2, 3] + ) * lower_layer.a[2] + + self.assertAlmostEqual(addition_origin, fixed_origin) + self.assertNotEqual( + interface._strain_coupling_displacement, + 0.0, + ) + self.assertAlmostEqual( + interface.stacking_loc_absolute, + interface.loc_absolute + + interface._strain_coupling_displacement, + ) + + top_zero, bottom_zero = self.make_epitaxy_cells() + independent = CTRfilm.EpitaxyInterface( + top_zero, + bottom_zero, + profile=SkellamProfile( + width, + tail_probability=1e-4, + ), + ) + independent.basis[:] = [width, 0.0, 0.0, 0.0] + independent.createInterfaceCells() + self.assertAlmostEqual( + interface._strain_coupling_displacement, + interface.height_absolute - independent.height_absolute, + ) + + def test_epitaxy_strain_coupling_interpolates_generated_positions(self): + independent = self.make_epitaxy_interface(strain_coupling=0.0) + halfway = self.make_epitaxy_interface(strain_coupling=0.5) + fully_coupled = self.make_epitaxy_interface(strain_coupling=1.0) + + for layers_zero, layers_half, layers_one in ( + ( + independent.top_layers, + halfway.top_layers, + fully_coupled.top_layers, + ), + ( + independent.bottom_layers, + halfway.bottom_layers, + fully_coupled.bottom_layers, + ), + ): + for layer_zero, layer_half, layer_one in zip( + layers_zero, + layers_half, + layers_one, + ): + domain_count = len(layer_zero.coherentDomainMatrix) + if layers_zero is independent.bottom_layers: + domain_count //= 2 + for matrix_zero, matrix_half, matrix_one in zip( + layer_zero.coherentDomainMatrix[:domain_count], + layer_half.coherentDomainMatrix[:domain_count], + layer_one.coherentDomainMatrix[:domain_count], + ): + self.assertAlmostEqual(matrix_zero[2, 2], 1.0) + np.testing.assert_allclose( + matrix_half[2], + 0.5 * (matrix_zero[2] + matrix_one[2]), + ) + + def test_epitaxy_offset_interpolates_strain_coupling_limits(self): + for strain_coupling in (0.0, 0.5, 1.0): + baseline = self.make_epitaxy_interface( + strain_coupling=strain_coupling, + offset=0.0, + ) + for offset in (-0.25, 0.25): + candidate = self.make_epitaxy_interface( + strain_coupling=strain_coupling, + offset=offset, + ) + physical_shift = offset * candidate.uc_bottom.a[2] + + self.assertAlmostEqual( + candidate._strain_coupling_displacement, + baseline._strain_coupling_displacement, + ) + all_top_occupancy = np.concatenate( + [ + layer.coherentDomainOccupancy + for layer in baseline.top_layers + ] + ) + occupancy_low = np.min(all_top_occupancy) + occupancy_span = np.max(all_top_occupancy) - occupancy_low + for base_layer, candidate_layer in zip( + baseline.top_layers, + candidate.top_layers, + ): + for occupancy, base_matrix, candidate_matrix in zip( + base_layer.coherentDomainOccupancy, + base_layer.coherentDomainMatrix, + candidate_layer.coherentDomainMatrix, + ): + profile_fraction = ( + (occupancy - occupancy_low) / occupancy_span + ) + expected_fraction = ( + 1.0 + - strain_coupling + + strain_coupling * profile_fraction + ) + self.assertAlmostEqual( + (candidate_matrix[2, 3] - base_matrix[2, 3]) + * candidate_layer.a[2], + physical_shift * expected_fraction, + ) + for layer_index, (base_layer, candidate_layer) in enumerate(zip( + baseline.bottom_layers, + candidate.bottom_layers, + )): + split = len(base_layer.coherentDomainMatrix) // 2 + for index, (base_matrix, candidate_matrix) in enumerate( + zip( + base_layer.coherentDomainMatrix[:split], + candidate_layer.coherentDomainMatrix[:split], + ) + ): + top_occupancy = ( + baseline.top_layers[ + layer_index + ].coherentDomainOccupancy[index] + ) + profile_fraction = ( + (top_occupancy - occupancy_low) / occupancy_span + ) + self.assertAlmostEqual( + (candidate_matrix[2, 3] - base_matrix[2, 3]) + * candidate_layer.a[2], + physical_shift + * strain_coupling + * profile_fraction, + ) + np.testing.assert_allclose( + candidate_layer.coherentDomainMatrix[split:], + base_layer.coherentDomainMatrix[split:], + ) + self.assertAlmostEqual( + candidate.stacking_loc_absolute + - baseline.stacking_loc_absolute, + physical_shift, + ) + self.assertAlmostEqual( + candidate.height_absolute - baseline.height_absolute, + physical_shift, + ) + + def test_epitaxy_offset_strains_shared_fully_coupled_layers(self): + top = self.make_layered_unitcell("top") + bottom = self.make_layered_unitcell("bottom") + interface = CTRfilm.EpitaxyInterface( + top, + bottom, + profile=SkellamProfile(0.5), + fixed_ucs=6, + ) + interface.basis[:] = [0.5, 0.0, 1.0, 0.25] + interface.createInterfaceCells() + + for top_layer, bottom_layer in zip( + interface.top_layers, + interface.bottom_layers, + ): + split = len(bottom_layer.coherentDomainMatrix) // 2 + for top_matrix, bottom_matrix in zip( + top_layer.coherentDomainMatrix, + bottom_layer.coherentDomainMatrix[:split], + ): + np.testing.assert_allclose( + top_matrix[2] * top_layer.a[2], + bottom_matrix[2] * bottom_layer.a[2], + rtol=1e-13, + atol=1e-13, + ) + + def test_epitaxy_offset_preserves_integrated_top_density(self): + z = np.linspace(-100.0, 100.0, 4001) + dz = np.diff(z)[0] + + for strain_coupling in (0.0, 0.5, 1.0): + baseline = self.make_epitaxy_interface( + strain_coupling=strain_coupling, + offset=0.0, + ) + baseline_density = sum( + layer.zDensity_G(z, 0.0, 0.0) + for layer in baseline.top_layers + ) + + for offset in (-0.25, 0.25): + shifted = self.make_epitaxy_interface( + strain_coupling=strain_coupling, + offset=offset, + ) + shifted_density = sum( + layer.zDensity_G(z, 0.0, 0.0) + for layer in shifted.top_layers + ) + np.testing.assert_allclose( + np.sum(shifted_density) * dz, + np.sum(baseline_density) * dz, + rtol=1e-6, + atol=1e-6, + ) + + def test_epitaxy_offset_translates_film_and_surface_without_resizing(self): + strain_coupling = 1.0 + offset = 0.25 + baseline = self.make_epitaxy_interface( + strain_coupling=strain_coupling, + offset=0.0, + ) + shifted = self.make_epitaxy_interface( + strain_coupling=strain_coupling, + offset=offset, + ) + physical_shift = offset * shifted.uc_bottom.a[2] + + stacks = [] + for name, interface in (("base", baseline), ("shifted", shifted)): + film = CTRfilm.Film(self.make_layered_unitcell(f"{name}_film")) + film.basis[0] = 30 + surface = CTRfilm.PoissonSurface( + self.make_layered_unitcell(f"{name}_surface"), + profile=PoissonProfile(1.0), + ) + crystal = CTRcalc.SXRDCrystal( + interface.uc_bottom, + interface, + film, + surface, + stacking=np.array([1, 2, 3]), + ) + crystal.apply_stacking() + stacks.append((film, surface)) + + (base_film, base_surface), (shifted_film, shifted_surface) = stacks + self.assertEqual( + shifted_film._layers_to_create, + base_film._layers_to_create, + ) + for base_component, shifted_component in ( + (base_film, shifted_film), + (base_surface, shifted_surface), + ): + self.assertAlmostEqual( + shifted_component.pos_absolute - base_component.pos_absolute, + physical_shift, + ) + self.assertAlmostEqual( + shifted_component.height_absolute - base_component.height_absolute, + physical_shift, + ) + self.assertAlmostEqual( + shifted_component.height_absolute - shifted_component.pos_absolute, + base_component.height_absolute - base_component.pos_absolute, + ) + + def test_epitaxy_parameters_round_trip(self): + interface = self.make_epitaxy_interface( + strain_coupling=0.4, + offset=-0.2, + ) + restored = CTRfilm.EpitaxyInterface.fromStr(interface.toStr(showErrors=False)) + np.testing.assert_allclose(restored.basis, [0.5, 0.0, 0.4, -0.2]) + + def test_epitaxy_loads_v1_5_text_parameters_with_compatible_defaults(self): + interface = self.make_epitaxy_interface() + interface.errors = np.array([0.03, 0.02, 0.01, 0.04]) + + for show_errors in (False, True): + with self.subTest(show_errors=show_errors): + lines = interface.toStr(showErrors=show_errors).splitlines() + header_index = lines.index( + CTRfilm.EpitaxyInterface.parameterOrder + ) + lines[header_index] = ( + CTRfilm.EpitaxyInterface.legacyParameterOrder + ) + if show_errors: + lines[header_index + 1] = ( + "(0.50000 +- 0.03000) (0.00000 +- 0.02000)" + ) + else: + lines[header_index + 1] = "0.5 0" + + restored = CTRfilm.EpitaxyInterface.fromStr("\n".join(lines)) + + np.testing.assert_allclose( + restored.basis, + [0.5, 0.0, 1.0, 0.0], + ) + if show_errors: + np.testing.assert_allclose( + restored.errors[:2], + [0.03, 0.02], + ) + self.assertTrue(np.all(np.isnan(restored.errors[2:]))) + else: + self.assertIsNone(restored.errors) + + def test_epitaxy_loads_v1_5_hdf5_fit_settings(self): + interface = self.make_epitaxy_interface() + interface.basis_0[:] = interface.basis + interface.addFitParameter("W", limits=(0.0, 1.0)) + interface.setFitParameters([0.4]) + legacy_fit_settings = interface.parametersToDict() + legacy_fit_settings["basis_0"] = legacy_fit_settings["basis_0"][:2] + + restored = self.make_epitaxy_interface() + restored.parametersFromDict(legacy_fit_settings) + + np.testing.assert_allclose(restored.basis_0, [0.5, 0.0, 1.0, 0.0]) + np.testing.assert_allclose(restored.basis, [0.4, 0.0, 1.0, 0.0]) + self.assertEqual(restored.fitparnames, interface.fitparnames) + + def test_epitaxy_strain_coupling_and_offset_are_fit_parameters(self): + interface = self.make_epitaxy_interface() + interface.basis_0[:] = interface.basis + interface.addFitParameter("strain_coupling", limits=(0.0, 1.0)) + interface.addFitParameter("offset", limits=(-0.5, 0.5)) + + np.testing.assert_allclose(interface.getInitialParameters(), [1.0, 0.0]) + interface.setFitParameters([0.3, -0.2]) + np.testing.assert_allclose(interface.basis[2:], [0.3, -0.2]) + + def test_epitaxy_rejects_invalid_strain_coupling_and_offset(self): + interface = self.make_epitaxy_interface() + for strain_coupling in (-0.1, 1.1, np.nan): + interface.basis[2] = strain_coupling + with self.assertRaisesRegex(ValueError, "strain_coupling"): + interface.createInterfaceCells() + interface.basis[2] = 1.0 + interface.basis[3] = np.inf + with self.assertRaisesRegex(ValueError, "offset"): + interface.createInterfaceCells() + + def test_epitaxy_split_bulk_domains_feed_all_profile_paths(self): + interface = self.make_epitaxy_interface( + strain_coupling=0.6, + offset=0.1, + ) + interface.setEnergy(10000.0) + + structure_factor = interface.F_uc( + np.array([0.0, 0.2]), + np.array([0.0, 0.1]), + np.array([0.3, 0.7]), + ) + density = interface.zDensity_G( + np.linspace(-20.0, 20.0, 401), + 0.0, + 0.0, + ) + optical_profile = interface.optical_profile() + + self.assertTrue(np.all(np.isfinite(structure_factor))) + self.assertTrue(np.all(np.isfinite(density))) + self.assertEqual(optical_profile.shape[1], 3) + self.assertTrue(np.all(np.isfinite(optical_profile))) + + # Value-level regression check, in addition to the isfinite smoke + # checks above: these are a frozen snapshot of this module's own + # current output (not independently verified against an analytic or + # external reference), so a mismatch signals the split-bulk-domain + # code path changed behavior, not necessarily that it is now wrong. + np.testing.assert_allclose( + structure_factor, + [ + 12.290590935844 + 23.878872280467j, + -1.767926251260 - 0.197899416448j, + ], + rtol=1e-8, + ) + np.testing.assert_allclose( + density[[0, 100, 200, 300, 400]], + [ + 0.0, + -3.006212046217e-03 - 1.665286072977e-05j, + 9.802746914877e-01 + 3.098533417278e-03j, + 1.082893246501e-05, + 0.0, + ], + atol=1e-9, ) - fixture_root = os.path.join( - repository_root, "examples", "CTR", "test" + np.testing.assert_allclose( + np.sum(density), + 40.033115045982 + 0.034943462837j, + rtol=1e-8, ) + mid_row = optical_profile.shape[0] // 2 + np.testing.assert_allclose( + optical_profile[[0, mid_row, -1]], + [ + [-12.0, -7.72508074e-10, -7.18561204e-13], + [-0.209418853, 2.94939896e-06, 2.74343239e-09], + [10.0, 0.0, 0.0], + ], + atol=1e-9, + ) + + +class TestLegacyLayeredCTR(unittest.TestCase): + def test_legacy_xtal_uses_corrected_interface_support(self): + fixture_root = os.path.join(os.path.dirname(__file__), "testdata") xtal_path = os.path.join( fixture_root, "0001_fit_2V036_reference.xtal" ) - reference_path = os.path.join(fixture_root, "CTRs_reference.dat") - for path in (xtal_path, reference_path): - if not os.path.exists(path): - self.skipTest(f"Missing optional CTR fixture: {path}") xtal = CTRcalc.SXRDCrystal.fromFile( xtal_path ) self.assertIsInstance(xtal["RuO2"], CTRfilm.Film) self.assertIsInstance(xtal["TiO2toRuO2"], CTRfilm.EpitaxyInterface) - np.testing.assert_allclose(xtal["TiO2toRuO2"].basis, [0.35, 0.0]) - self.assertTrue(xtal["TiO2toRuO2"]._legacy_support_cursor) - + np.testing.assert_allclose( + xtal["TiO2toRuO2"].basis, + [0.35, 0.0, 1.0, 0.0], + ) + interface = xtal["TiO2toRuO2"] + xtal.apply_stacking() + self.assertAlmostEqual( + interface.stacking_height_absolute, interface.height_absolute + ) + self.assertAlmostEqual( + interface.stacking_loc_absolute, + interface.loc_absolute + + interface._strain_coupling_displacement, + ) + self.assertIsInstance(interface.profile, SkellamProfile) + self.assertNotIn("support_cursor", interface.toStr()) + + # End-to-end structure-factor regression check for the legacy .xtal + # fixture (Film + EpitaxyInterface + SkellamProfile). PRELIMINARY, + # UNTESTED reference values: computed with this module's own + # CTRcalc.SXRDCrystal.F() after the interfacial-strain fix in + # EpitaxyInterface (commit beee738), the F_bulk lattice-sum + # denominator fix, and the deep-bulk strain-field anchoring fix, but + # not independently verified against real data or an analytic result + # -- unlike test_simple_unitcell_bulk_structure_factor_matches_ + # reference below, which does compare against real fit-derived + # intensities for the un-layered unit-cell + bulk case. + # The historical CTRs_reference.dat file was generated by the + # pre-beee738 implementation and is retained as example/provenance + # data, not as a correctness oracle. If this test starts failing, + # investigate the geometry rather than updating these values + # mechanically. xtal["RuO2"].basis[0] = 17.0 xtal["RuO2"].basis_0[0] = 17.0 xtal.atten = 0.01 - reference = np.loadtxt( - reference_path, - skiprows=1, - max_rows=8, - ) l_values = np.linspace(0.0, 7.25, 2000)[:8] calculated = np.abs( xtal.F( @@ -1276,12 +2461,53 @@ def test_legacy_xtal_reconstructs_reference_interface(self): l_values, ) ) - np.testing.assert_allclose( - calculated, - reference[:, 3] * xtal.reference_area, - rtol=2e-5, - atol=2e-5, + expected = np.array( + [ + 17238.5440045948, + 7001.3340402313, + 3852.2553000918, + 2726.9468772925, + 2175.6251991885, + 1855.7582644482, + 1648.1329478054, + 1501.1768061163, + ] ) + np.testing.assert_allclose(calculated, expected, rtol=2e-5, atol=2e-5) + + def test_simple_unitcell_bulk_structure_factor_matches_reference(self): + """Check the simple un-layered unit-cell plus bulk case. + + This does not use CTRs_reference.dat, which was generated from the + full layered crystal by the obsolete pre-beee738 implementation. + Instead it reuses + testdata/0V12_calculated.xpr and .dat, the same fixtures already + validated against real fit-derived reference intensities by + TestCTRcalculationNumPy/Numba/Cpp above -- this is real, + independently verified data rather than a snapshot of current + output. + """ + fp = os.path.split(__file__)[0] + xtal_unitcells = CTRcalc.SXRDCrystal.fromFile( + os.path.join(fp, "testdata/0V12_calculated.xpr") + ) + CTRs = CTRplotutil.CTRCollection.fromANAROD( + os.path.join(fp, "testdata/0V12_calculated.dat"), + RODexport=True, + ) + pt100 = CTRcalc.UnitCell([3.9242, 3.9242, 3.9242], [90.0000, 90.0000, 90.0000]) + xtal_unitcells.setGlobalReferenceUnitCell( + pt100, util.z_rotation(np.deg2rad(45.0)) + ) + # This legacy reference file was generated when amplitudes were + # normalized by unit-cell volume. Canonical F values in electrons are + # therefore larger by the reference-cell volume. + reference_scale = pt100.volume + + calc_CTRs = CTRs.generateCollectionFromXtal(xtal_unitcells) + for calc, reference in zip(calc_CTRs, CTRs): + expected = reference.sfI * reference_scale + np.testing.assert_allclose(calc.sfI, expected, rtol=1e-02) class TestStructureFactorNormalization(unittest.TestCase): @@ -1322,6 +2548,38 @@ def test_crystal_factor_is_invariant_to_lateral_supercell(self): rtol=1e-2, ) + def test_bulk_repeat_follows_reference_unit_cell(self): + reference = CTRcalc.UnitCell( + [3.0, 3.0, 4.0], + [90.0, 90.0, 90.0], + name="reference", + ) + bulk = CTRcalc.UnitCell( + [3.0, 3.0, 8.0], + [90.0, 90.0, 90.0], + name="bulk", + ) + bulk.addAtom("C", [0.0, 0.0, 0.25], 0.1, 0.1, 1.0) + bulk.setEnergy(10000.0) + bulk.setReferenceUnitCell(reference) + h = np.zeros(2) + k = np.zeros(2) + l_values = np.array([0.17, 0.41]) + atten = 0.07 + + hkl_bulk = bulk.refHKLTransform @ np.vstack((h, k, l_values)) + repeat_atten = 2.0 * atten + expected = bulk.F_uc_bulk_direct(*hkl_bulk, repeat_atten) + expected /= 1 - np.exp( + -2j * np.pi * hkl_bulk[2] - repeat_atten + ) + + with mock.patch.object(CTRuc, "CTR_ACCEL_BACKEND", "numpy"): + actual = bulk.F_bulk(h, k, l_values, atten) + + np.testing.assert_allclose(actual, expected) + np.testing.assert_allclose(hkl_bulk[2], 2.0 * l_values) + def test_constructor_propagates_bulk_reference_to_layers(self): bulk = self.make_carbon_cell(3.0, [0.0], "bulk") film_cell = self.make_carbon_cell(6.0, [0.0, 0.5], "film") @@ -1368,6 +2626,33 @@ def test_interface_uses_lower_unitcell_area(self): class TestCTRTextFilesAndFitParameters(unittest.TestCase): + def test_unitcell_atom_table_uses_fixed_width_columns(self): + cell = CTRcalc.UnitCell([1.0, 1.0, 1.0], [90.0, 90.0, 90.0]) + cell.addAtom("O", [0.5, 0.0, 0.80569], 0.4119, 0.4119, 1.0, layer=2) + + lines = cell.toStr(showErrors=False).splitlines() + + self.assertIn( + "Name x/frac y/frac z/frac iDW oDW" + " occup layerIdx", + lines, + ) + self.assertIn( + "00 O 0.50000 0.00000 0.80569 0.4119" + " 0.4119 1.0000 2", + lines, + ) + + cell.errors = np.full_like(cell.basis, np.nan) + cell.errors[0, 3] = 0.00007 + error_lines = cell.toStr(showErrors=True).splitlines() + header = next(line for line in error_lines if line.startswith("Name")) + atom = next(line for line in error_lines if line.startswith("00")) + for column in ("x/frac", "y/frac", "z/frac", "iDW", "oDW", "occup"): + self.assertGreater(header.index(column), 0) + self.assertIn("(0.80569 +- 0.00007)", atom) + self.assertEqual(len(header), len(atom) - 4) + @staticmethod def make_layered_cell(name): cell = CTRcalc.UnitCell( @@ -1394,14 +2679,14 @@ def make_crystal_with_errors(self): film.basis_0[:] = film.basis surface = CTRfilm.PoissonSurface( self.make_layered_cell("surface_cell"), - profile=PoissonProfile(mean_change=1.5, offset=-1.5), + profile=PoissonProfile(mean_change=1.5, alpha=0.5), name="surface", ) surface.basis_0[:] = surface.basis film.errors = np.array([0.2]) - interface.errors = np.array([0.03, 0.04]) - surface.errors = np.array([0.1, 0.15]) + interface.errors = np.array([0.03, 0.04, np.nan, np.nan]) + surface.errors = np.array([0.1, 0.15, 0.2]) film.unitcell.errors = np.full_like(film.unitcell.basis, np.nan) film.unitcell.errors[:, 1:7] = 0.01 @@ -1440,8 +2725,15 @@ def test_xtal_and_xpr_plain_text_round_trip(self): self.assertIsNone(restored_xtal["surface"].errors) np.testing.assert_allclose(restored_xpr.werrors, crystal.werrors) np.testing.assert_allclose(restored_xpr["film"].errors, [0.2]) - np.testing.assert_allclose(restored_xpr["interface"].errors, [0.03, 0.04]) - np.testing.assert_allclose(restored_xpr["surface"].errors, [0.1, 0.15]) + np.testing.assert_allclose( + restored_xpr["interface"].errors[:2], + [0.03, 0.04], + ) + self.assertTrue(np.all(np.isnan(restored_xpr["interface"].errors[2:]))) + np.testing.assert_allclose( + restored_xpr["surface"].errors, + [0.1, 0.15, 0.2], + ) np.testing.assert_allclose( restored_xpr["film"].unitcell.errors[:, 1:7], crystal["film"].unitcell.errors[:, 1:7], @@ -1468,49 +2760,51 @@ def test_ctrfilm_fit_parameters_and_errors(self): interface.addRelParameter(("W", "S"), (1.0, -1.0), limits=(-0.2, 0.2)) interface.setFitParameters([0.1]) interface.setFitErrors([0.02]) - np.testing.assert_allclose(interface.basis, [0.45, -0.1]) - np.testing.assert_allclose(interface.errors, [0.02, 0.02]) + np.testing.assert_allclose(interface.basis, [0.45, -0.1, 1.0, 0.0]) + np.testing.assert_allclose(interface.errors[:2], [0.02, 0.02]) + self.assertTrue(np.all(np.isnan(interface.errors[2:]))) self.assertAlmostEqual(interface.profile.width, 0.45) self.assertAlmostEqual(interface.profile.asymmetry, -0.1) surface = CTRfilm.PoissonSurface( self.make_layered_cell("surface_cell"), - profile=PoissonProfile(mean_change=2.0, offset=-2.0), + profile=PoissonProfile(mean_change=2.0, alpha=0.4), name="surface", ) surface.basis_0[:] = surface.basis surface.addRelParameter( - ("W", "offset"), - (1.0, -1.0), - limits=(-1.0, 1.0), + ("W", "alpha"), + (1.0, 0.1), + limits=(-0.4, 0.4), ) - surface.setFitParameters([0.5]) + surface.setFitParameters([0.25]) surface.setFitErrors([0.1]) - np.testing.assert_allclose(surface.basis, [2.5, -2.5]) - np.testing.assert_allclose(surface.errors, [0.1, 0.1]) - self.assertAlmostEqual(surface.profile.mean_change, 2.5) - self.assertAlmostEqual(surface.profile.offset, -2.5) - self.assertAlmostEqual(surface.profile.expected_height_change, 0.0) + np.testing.assert_allclose(surface.basis, [2.25, 0.425, 0.0]) + np.testing.assert_allclose(surface.errors[:2], [0.1, 0.01]) + self.assertTrue(np.isnan(surface.errors[2])) + self.assertAlmostEqual(surface.profile.mean_change, 2.25) + self.assertAlmostEqual(surface.profile.alpha, 0.425) + self.assertAlmostEqual(surface.profile.expected_height_change, 2.25) def test_linear_fit_parameter_dictionary_round_trip(self): original = CTRfilm.PoissonSurface( self.make_layered_cell("surface_cell"), - profile=PoissonProfile(mean_change=2.0, offset=-2.0), + profile=PoissonProfile(mean_change=2.0, alpha=0.4), name="surface", ) original.basis_0[:] = original.basis original.addRelParameter( - ("W", "offset"), - (1.0, -1.0), - limits=(-1.0, 1.0), - name="mean_preserving_width", + ("W", "alpha"), + (1.0, 0.1), + limits=(-0.4, 0.4), + name="coupled_roughness", ) original.setFitParameters([0.4]) original.setFitErrors([0.05]) restored = CTRfilm.PoissonSurface( self.make_layered_cell("surface_cell"), - profile=PoissonProfile(mean_change=2.0, offset=-2.0), + profile=PoissonProfile(mean_change=2.0, alpha=0.4), name="surface", ) restored.parametersFromDict(original.parametersToDict()) @@ -1520,7 +2814,7 @@ def test_linear_fit_parameter_dictionary_round_trip(self): np.testing.assert_allclose(restored.getFitErrors(), original.getFitErrors()) self.assertEqual(restored.fitparnames, original.fitparnames) self.assertAlmostEqual(restored.profile.mean_change, 2.4) - self.assertAlmostEqual(restored.profile.offset, -2.4) + self.assertAlmostEqual(restored.profile.alpha, 0.44) def test_crystal_fit_vector_updates_ctrfilm_components(self): crystal = self.make_crystal_with_errors() @@ -1535,23 +2829,26 @@ def test_crystal_fit_vector_updates_ctrfilm_components(self): crystal.setParameters([0.4, 7.0, 2.0]) crystal.setFitErrors([0.01, 0.2, 0.1]) - np.testing.assert_allclose(crystal["interface"].basis, [0.4, 0.0]) + np.testing.assert_allclose( + crystal["interface"].basis, + [0.4, 0.0, 1.0, 0.0], + ) np.testing.assert_allclose(crystal["film"].basis, [7.0]) - np.testing.assert_allclose(crystal["surface"].basis, [2.0, -1.5]) + np.testing.assert_allclose(crystal["surface"].basis, [2.0, 0.5, 0.0]) np.testing.assert_allclose(crystal.getFitErrors(), [0.01, 0.2, 0.1]) self.assertAlmostEqual(crystal["interface"].profile.width, 0.4) self.assertAlmostEqual(crystal["surface"].profile.mean_change, 2.0) def test_example_xtal_and_xpr_describe_same_poisson_stack(self): - repository_root = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") - ) - example_root = os.path.join(repository_root, "examples", "CTR") - xtal_path = os.path.join(example_root, "RuO2_TiO2_Poisson_etching.xtal") - xpr_path = os.path.join(example_root, "RuO2_TiO2_Poisson_etching.xpr") - for path in (xtal_path, xpr_path): - if not os.path.exists(path): - self.skipTest(f"Missing optional CTR fixture: {path}") + """Fixtures live in testdata/ (packaged with the test suite) rather + than examples/CTR/, which is not included in installed wheels -- + this test used to silently skip on any CI/CD run that installs the + package instead of using a source checkout. The full, canonical + copies remain at examples/CTR/RuO2_TiO2_Poisson_etching.{xtal,xpr}. + """ + fixture_root = os.path.join(os.path.dirname(__file__), "testdata") + xtal_path = os.path.join(fixture_root, "RuO2_TiO2_Poisson_etching.xtal") + xpr_path = os.path.join(fixture_root, "RuO2_TiO2_Poisson_etching.xpr") xtal = CTRcalc.SXRDCrystal.fromFile(xtal_path) xpr = CTRcalc.SXRDCrystal.fromFile(xpr_path) @@ -1570,9 +2867,12 @@ def test_example_xtal_and_xpr_describe_same_poisson_stack(self): ) np.testing.assert_array_equal(xtal.uc_stacking, [3, 2, 1]) np.testing.assert_array_equal(xpr.uc_stacking, [3, 2, 1]) - np.testing.assert_allclose(xtal["TiO2toRuO2"].basis, [0.35, 0.0]) + np.testing.assert_allclose( + xtal["TiO2toRuO2"].basis, + [0.35, 0.0, 1.0, 0.0], + ) np.testing.assert_allclose(xtal["RuO2"].basis, [17.0]) - np.testing.assert_allclose(xtal["RuO2surface"].basis, [-6.0, 0.0]) + np.testing.assert_allclose(xtal["RuO2surface"].basis, [-6.0, 1.0, 0.0]) for name in ("TiO2toRuO2", "RuO2", "RuO2surface"): np.testing.assert_allclose(xtal[name].basis, xpr[name].basis) @@ -1636,6 +2936,62 @@ def testSetAccelBackendCanSelectNumpy(self): self.assertEqual(CTRuc.CTR_ACCEL_BACKEND, "numpy") +class TestUnitCellEmptyCoherentDomains(unittest.TestCase): + """A unit cell with zero coherent domains is a legitimate state (e.g. a + film layer entirely consumed by an EpitaxyInterface profile, see + test_profile_interface_can_consume_entire_film_width). ``np.asarray([])`` + collapses to shape ``(0,)`` rather than the ``(0, 3, 4)`` the accelerated + backends require, which must not raise. + """ + + def setUp(self): + self.original_backend = CTRuc.CTR_ACCEL_BACKEND + self.addCleanup(CTRuc.set_accel_backend, self.original_backend) + self.unitcell = CTRcalc.UnitCell( + [3.0, 3.0, 6.0], [90.0, 90.0, 90.0], name="empty_domains" + ) + self.unitcell.addAtom("C", [0.0, 0.0, 0.0], 0.1, 0.1, 1.0, layer=1) + self.unitcell.setEnergy(10000.0) + self.unitcell.coherentDomainMatrix = [] + self.unitcell.coherentDomainOccupancy = [] + self.h = np.array([0.0]) + self.k = np.array([0.0]) + self.l = np.array([0.5]) + + def _assert_zero_amplitude(self): + np.testing.assert_allclose( + self.unitcell.F_uc(self.h, self.k, self.l), 0.0 + ) + np.testing.assert_allclose( + self.unitcell.F_uc_bulk(self.h, self.k, self.l), 0.0 + ) + np.testing.assert_allclose( + self.unitcell.F_bulk(self.h, self.k, self.l), 0.0 + ) + + def testEmptyCoherentDomainsNumpyBackend(self): + CTRuc.set_accel_backend("numpy") + self._assert_zero_amplitude() + + def testEmptyCoherentDomainsNumbaBackend(self): + if not CTRuc.ctr_numba_accel_available(): + self.skipTest( + "Cannot perform Numba tests: _CTRcalc_accel library was not " + "imported. Is Numba installed?" + ) + CTRuc.set_accel_backend("numba") + self._assert_zero_amplitude() + + def testEmptyCoherentDomainsCppBackend(self): + if not CTRuc.HAS_CPP_ACCEL: + self.skipTest( + "Cannot perform C++ tests: _CTRcalc_cpp library was not " + "imported. Was the C++ extension built?" + ) + CTRuc.set_accel_backend("cpp") + self._assert_zero_amplitude() + + class TestCTRcalculationNumPy(StructureFactorValidationMixin, unittest.TestCase): def setUp(self): original_backend = CTRuc.CTR_ACCEL_BACKEND @@ -1661,7 +3017,6 @@ def setUp(self): def testStructureFactorEqual(self): self.assert_structure_factors_match_volume_normalized_reference() - class TestCTRcalculationNumba(StructureFactorValidationMixin, unittest.TestCase): def setUp(self): original_backend = CTRuc.CTR_ACCEL_BACKEND @@ -1722,3 +3077,50 @@ def setUp(self): def testStructureFactorEqual(self): self.assert_structure_factors_match_volume_normalized_reference() + + def testZDensityMatchesNumpyWithMultipleDomains(self): + """The C++ density kernel preserves the atomic density convention.""" + cell = CTRcalc.UnitCell([3.0, 4.0, 5.0], [90.0, 90.0, 90.0]) + cell.addAtom("C", [0.2, 0.3, 0.4], 0.12, 0.18, 0.8) + cell.addAtom("O", [0.7, 0.6, 0.1], 0.21, 0.24, 0.5) + cell.setEnergy(10000.0) + shifted_domain = np.vstack((np.identity(3).T, [0.1, -0.2, 0.15])).T + cell.coherentDomainMatrix = [cell.coherentDomainMatrix[0], shifted_domain] + cell.coherentDomainOccupancy = [0.65, 0.35] + z = np.linspace(-2.0, 7.0, 127) + + CTRuc.set_accel_backend("numpy") + expected = cell.zDensity_G(z, 0.37, -0.22) + CTRuc.set_accel_backend("cpp") + actual = cell.zDensity_G(z, 0.37, -0.22) + + np.testing.assert_allclose(actual, expected, rtol=1e-13, atol=1e-13) + + def testFormFactorCacheReusesMatchingGrid(self): + """The C++ cache shares Waasmaier vectors across UnitCell calls.""" + original_budget = CTRuc.form_factor_cache_stats()["budget_bytes"] + self.addCleanup(CTRuc.clear_form_factor_cache) + self.addCleanup(CTRuc.set_form_factor_cache_budget, original_budget) + CTRuc.clear_form_factor_cache() + CTRuc.reset_form_factor_cache_stats() + reset = CTRuc.form_factor_cache_stats() + self.assertEqual(reset["hits"], 0) + self.assertEqual(reset["misses"], 0) + self.assertEqual(reset["evictions"], 0) + h = np.linspace(-1.0, 1.0, 32) + k = np.zeros_like(h) + ell = np.linspace(0.1, 2.0, 32) + + first = self.xtal_unitcells.uc_bulk.F_uc(h, k, ell) + cold = CTRuc.form_factor_cache_stats() + second = self.xtal_unitcells.uc_bulk.F_uc(h, k, ell) + warm = CTRuc.form_factor_cache_stats() + + np.testing.assert_allclose(second, first) + self.assertGreater(cold["misses"], 0) + self.assertGreater(warm["hits"], cold["hits"]) + self.assertGreater(warm["resident_bytes"], 0) + self.assertEqual( + CTRuc.form_factor_cache_expected_bytes(10_000, 5), + 6 * 10_000 * np.dtype(np.float64).itemsize, + ) diff --git a/orgui/datautils/xrayutils/test/test_CTRoptical_profile.py b/orgui/datautils/xrayutils/test/test_CTRoptical_profile.py new file mode 100644 index 0000000..ee86cd3 --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_CTRoptical_profile.py @@ -0,0 +1,577 @@ +import unittest + +import numpy as np +import xraydb + +from .. import CTRcalc, CTRfilm, CTRoptics, unitcells + + +AVOGADRO = 6.02214076e23 + + +def density_from_cell(formula_mass, formula_units, volume): + """Return density in g / cm**3 from an Angstrom**3 cell volume.""" + return formula_mass * formula_units / (volume * 1e-24 * AVOGADRO) + + +class TestUnitCellOpticalProfile(unittest.TestCase): + energy_eV = 10000.0 + + def test_requires_energy(self): + cell = CTRcalc.UnitCell([3.0, 3.0, 4.0], [90.0, 90.0, 90.0]) + cell.addAtom("C", [0.0, 0.0, 0.0], 0.1, 0.1, 1.0) + + with self.assertRaisesRegex(ValueError, "Set the UnitCell energy"): + cell.optical_profile() + + def test_pt100_matches_xraydb(self): + cell = unitcells.unitcell("Pt100") + cell.setEnergy(self.energy_eV) + profile = cell.optical_profile() + np.testing.assert_array_equal(profile, CTRoptics.optical_profile(cell)) + density = density_from_cell( + xraydb.atomic_mass("Pt"), 4, cell.volume + ) + delta, beta, _ = xraydb.xray_delta_beta("Pt", density, self.energy_eV) + + self.assertEqual(profile.shape, (1, 3)) + self.assertEqual(profile.dtype, np.float64) + self.assertTrue(profile.flags.c_contiguous) + self.assertEqual(profile[0, 0], 0.0) + np.testing.assert_allclose(profile[:, 1], delta, rtol=1e-7) + np.testing.assert_allclose(profile[:, 2], beta, rtol=1e-7) + + def test_ionic_profile_uses_ionic_forward_scattering_factor(self): + cell = CTRcalc.UnitCell([10.0, 10.0, 10.0], [90.0, 90.0, 90.0]) + cell.addAtom("O2-", [0.0, 0.0, 0.0], 0.1, 0.1, 1.0) + cell.setEnergy(self.energy_eV) + + profile = cell.optical_profile() + + wavelength = CTRoptics.HC_KEV_ANGSTROM / (self.energy_eV * 1e-3) + scale = 2.8179403262e-5 * wavelength**2 / (2.0 * np.pi) + ionic_f0 = np.sum(cell.f[0, :5]) + cell.f[0, 10] + expected = scale * ( + ionic_f0 + cell.f[0, 11] + 1j * cell.f[0, 12] + ) / cell.volume + np.testing.assert_allclose( + profile[0, 1:], + [expected.real, expected.imag], + rtol=1e-14, + ) + self.assertGreater(ionic_f0, xraydb.atomic_number("O")) + + def test_tio2_100_has_two_homogeneous_layers(self): + cell = unitcells.crystal("TiO2(100)").uc_bulk + cell.setEnergy(self.energy_eV) + profile = cell.optical_profile() + formula_mass = xraydb.atomic_mass("Ti") + 2.0 * xraydb.atomic_mass("O") + density = density_from_cell(formula_mass, 4, cell.volume) + delta, beta, _ = xraydb.xray_delta_beta("TiO2", density, self.energy_eV) + + self.assertEqual(profile.shape, (2, 3)) + np.testing.assert_allclose(profile[:, 0], [-6.5807, -3.29035]) + np.testing.assert_allclose(profile[:, 1], delta, rtol=1e-7) + np.testing.assert_allclose(profile[:, 2], beta, rtol=1e-7) + + def test_domain_translation_and_occupancy_weight_optical_contribution(self): + cell = unitcells.unitcell("Pt100") + cell.setEnergy(self.energy_eV) + translated = np.eye(3, 4) + translated[2, 3] = 0.25 + cell.coherentDomainMatrix = [np.eye(3, 4), translated] + cell.coherentDomainOccupancy = [0.75, 0.25] + + profile = cell.optical_profile() + + np.testing.assert_allclose(profile[:, 0], [0.0, 0.25 * cell.a[2]]) + np.testing.assert_allclose(profile[1, 1], profile[0, 1] / 3.0) + np.testing.assert_allclose(profile[1, 2], profile[0, 2] / 3.0) + + def test_domain_normal_strain_preserves_areal_optical_content(self): + cell = unitcells.unitcell("Pt100") + cell.setEnergy(self.energy_eV) + unstrained = cell.optical_profile() + stretched = np.eye(3, 4) + stretched[2, 2] = 2.0 + cell.coherentDomainMatrix = [stretched] + cell.coherentDomainOccupancy = [1.0] + + profile = cell.optical_profile() + + np.testing.assert_allclose(profile[:, 1:], unstrained[:, 1:] / 2.0) + + def test_film_and_crystal_combine_layer_profiles(self): + bulk = unitcells.crystal("TiO2(100)").uc_bulk + bulk.setEnergy(self.energy_eV) + film = CTRfilm.Film(bulk) + film.basis[0] = 2.0 + + film_profile = film.optical_profile() + crystal_profile = CTRcalc.SXRDCrystal(bulk, film).optical_profile() + + self.assertEqual(film_profile.shape, (2, 3)) + np.testing.assert_allclose(film_profile[:, 0], [0.0, 3.29035]) + self.assertEqual(crystal_profile.shape, (63, 3)) + np.testing.assert_allclose( + crystal_profile[-5:, 0], + [-6.5807, -3.29035, 0.0, 3.29035, 6.5807], + ) + np.testing.assert_allclose( + crystal_profile[:-1, 1:], + np.tile(film_profile[0, 1:], (62, 1)), + ) + np.testing.assert_array_equal(crystal_profile[-1, 1:], [0.0, 0.0]) + + def test_combine_profiles_sums_coincident_positions(self): + combined = CTRoptics.combine_profiles( + np.array([[1.0, 2.0, 3.0]]), + np.array([[1.0, 4.0, 5.0], [2.0, 6.0, 7.0]]), + ) + + np.testing.assert_allclose(combined, [[1.0, 6.0, 8.0], [2.0, 6.0, 7.0]]) + self.assertTrue(combined.flags.c_contiguous) + + def test_combine_profiles_merges_nearby_layer_origins(self): + combined = CTRoptics.combine_profiles( + np.array([[3.2728, 2.0, 3.0]]), + np.array([[3.2731576542, 4.0, 5.0], [4.0, 6.0, 7.0]]), + ) + + np.testing.assert_allclose(combined, [[3.2728, 6.0, 8.0], [4.0, 6.0, 7.0]]) + + def test_combine_profiles_bounds_full_group_span(self): + combined = CTRoptics.combine_profiles( + np.array( + [ + [0.0, 1.0, 0.0], + [0.5, 2.0, 0.0], + [1.0, 4.0, 0.0], + ] + ), + z_tolerance=0.6, + ) + + np.testing.assert_allclose( + combined, + [[0.0, 3.0, 0.0], [1.0, 4.0, 0.0]], + ) + + def test_structural_layers_are_added_to_continuous_samples(self): + combined = CTRoptics.add_structural_to_sampled_profile( + np.array([[1.1, 2.0, 3.0], [4.0, 5.0, 6.0]]), + np.array([[1.0, 10.0, 20.0], [1.6, 10.0, 20.0], [2.2, 10.0, 20.0]]), + ) + + np.testing.assert_allclose( + combined, + [[1.0, 12.0, 23.0], [1.6, 10.0, 20.0], [2.2, 10.0, 20.0], [4.0, 5.0, 6.0]], + ) + + def test_bulk_profile_repeats_towards_negative_z(self): + bulk = unitcells.unitcell("Pt100") + bulk.setEnergy(self.energy_eV) + + profile = bulk.optical_profile_asbulk(noUC=3) + + self.assertEqual(profile.shape, (3, 3)) + np.testing.assert_allclose(profile[:, 0], [0.0, -bulk.a[2], -2 * bulk.a[2]]) + np.testing.assert_allclose(profile[:, 1:], np.tile(profile[0, 1:], (3, 1))) + + def test_simplify_profile_conserves_finite_optical_thickness(self): + profile = np.array( + [ + [0.0, 2.0e-6, 2.0e-8], + [1.0, 1.0e-6, 1.0e-8], + [3.0, 1.1e-6, 1.1e-8], + [6.0, 1.2e-6, 1.2e-8], + [10.0, 5.0e-6, 5.0e-8], + [12.0, 0.0, 0.0], + ] + ) + + stratified = CTRoptics.stratify_profile( + profile, delta_tolerance=0.25e-6, beta_tolerance=0.25e-8 + ) + simplified = stratified.values + + np.testing.assert_allclose( + simplified[:, 0], [0.0, 4.25, 9.5, 12.0] + ) + np.testing.assert_allclose(stratified.boundaries, [0.5, 8.0, 11.0]) + original_boundaries = CTRoptics.profile_boundaries(profile) + original_integral = np.sum( + profile[1:-1, 1:] + * np.diff(original_boundaries)[:, None], + axis=0, + ) + simplified_integral = np.sum( + simplified[1:-1, 1:] + * np.diff(stratified.boundaries)[:, None], + axis=0, + ) + np.testing.assert_allclose(simplified_integral, original_integral) + np.testing.assert_array_equal(simplified[[0, -1]], profile[[0, -1]]) + + def test_simplify_profile_respects_beta_and_group_range(self): + profile = np.array( + [ + [0.0, 2.0e-6, 2.0e-8], + [1.0, 1.00e-6, 1.0e-8], + [2.0, 1.09e-6, 1.0e-8], + [3.0, 1.18e-6, 4.0e-8], + [4.0, 0.0, 0.0], + ] + ) + + simplified = CTRoptics.simplify_profile( + profile, delta_tolerance=0.15e-6, beta_tolerance=0.5e-8 + ) + + np.testing.assert_allclose(simplified[:, 0], [0.0, 1.5, 3.0, 4.0]) + + def test_profile_boundaries_are_centered_between_samples(self): + profile = np.array( + [ + [-4.0, 2.0e-6, 0.0], + [2.0, 1.0e-6, 0.0], + [10.0, 0.0, 0.0], + ] + ) + + stratified = CTRoptics.stratify_profile(profile) + + np.testing.assert_array_equal(stratified.values, profile) + np.testing.assert_allclose(stratified.boundaries, [-1.0, 6.0]) + + def test_water_profile_matches_bulk_water_and_stacks_on_film(self): + bulk = unitcells.crystal("TiO2(100)").uc_bulk + bulk.setEnergy(self.energy_eV) + film = CTRfilm.Film(bulk) + film.basis[0] = 2.0 + water = CTRcalc.WaterModel(bulk.a, bulk.alpha, "step") + water.setEnergy(self.energy_eV) + crystal = CTRcalc.SXRDCrystal( + bulk, film, water, stacking=np.array([1, 2]) + ) + + crystal.apply_stacking() + self.assertAlmostEqual(water.pos_absolute, film.stacking_height_absolute) + initial_water_position = water.pos_absolute + dz = bulk.a[2] / 2.0 + profile = water.optical_profile(noUC=10, z_step=dz) + self.assertTrue(np.allclose(np.diff(profile[:, 0]), dz)) + density = water.pw * 18.01528 * 1.66053906660 + expected = xraydb.xray_delta_beta("H2O", density, self.energy_eV) + np.testing.assert_allclose(profile[-1, 1:], expected[:2], rtol=5e-5) + + crystal_profile = crystal.optical_profile() + np.testing.assert_allclose(crystal_profile[-1, 1:], expected[:2], rtol=5e-5) + water_region = crystal_profile[crystal_profile[:, 0] >= profile[0, 0]] + np.testing.assert_allclose(np.diff(water_region[:, 0]), dz) + + film.basis[0] = 4.0 + crystal.apply_stacking() + self.assertAlmostEqual(water.pos_absolute, film.stacking_height_absolute) + self.assertGreater(water.pos_absolute, initial_water_position) + + +class TestLayeredWavefield(unittest.TestCase): + energy_eV = 10000.0 + + @staticmethod + def _q(n, energy_eV, alpha): + wavelength = CTRoptics.HC_KEV_ANGSTROM / (energy_eV * 1e-3) + k0 = 2.0 * np.pi / wavelength + if n == 1.0: + return k0 * np.sin(np.deg2rad(alpha)) + q = k0 * np.sqrt( + n**2 - np.cos(np.deg2rad(alpha)) ** 2 + ) + if q.imag > 0.0: + q = -q + return q + + def test_single_interface_matches_fresnel_and_conserves_flux(self): + profile = np.array([[-10.0, 1.0e-5, 0.0], [0.0, 0.0, 0.0]]) + alpha = 1.0 + + for polarization in ("s", "p"): + field = CTRoptics.solve_wavefield( + profile, self.energy_eV, alpha, polarization + ) + n = np.array([1.0 + 0.0j, 1.0 - 1.0e-5 + 0.0j]) + q = np.array([self._q(value, self.energy_eV, alpha) for value in n]) + admittance = q if polarization == "s" else n**2 / q + expected_r = (admittance[0] - admittance[1]) / ( + admittance[0] + admittance[1] + ) + expected_t = 2.0 * admittance[0] / ( + admittance[0] + admittance[1] + ) + + np.testing.assert_allclose(field.r_S, expected_r, rtol=1e-13) + np.testing.assert_allclose(field.t_S, expected_t, rtol=1e-13) + flux = abs(field.r_S) ** 2 + ( + admittance[1].real / admittance[0].real + ) * abs(field.t_S) ** 2 + np.testing.assert_allclose(flux, 1.0, rtol=1e-12) + np.testing.assert_allclose(field.A_minus[-1], 0.0, atol=1e-14) + + def test_non_vacuum_incident_medium_uses_its_tangential_wavevector(self): + incident_delta = 4.6e-7 + substrate_delta = 2.05e-6 + profile = np.array( + [ + [-10.0, substrate_delta, 0.0], + [0.0, incident_delta, 0.0], + ] + ) + alpha = 0.02 + + field = CTRoptics.solve_wavefield( + profile, self.energy_eV, alpha, "s" + ) + wavelength = CTRoptics.HC_KEV_ANGSTROM / (self.energy_eV * 1e-3) + k0 = 2.0 * np.pi / wavelength + n_incident = 1.0 - incident_delta + expected_q_incident = ( + k0 * n_incident * np.sin(np.deg2rad(alpha)) + ) + + np.testing.assert_allclose(-field.kz[0], expected_q_incident, rtol=1e-13) + np.testing.assert_allclose(abs(field.r_S) ** 2, 1.0, rtol=1e-12) + + def test_amplitudes_satisfy_interface_continuity(self): + profile = np.array( + [ + [-12.0, 1.2e-5, 0.0], + [0.0, 6.0e-6, 0.0], + [20.0, 0.0, 0.0], + ] + ) + field = CTRoptics.solve_wavefield(profile, self.energy_eV, 1.2, "p") + q = -field.kz + admittance = field.n**2 / q + + for interface, z_value in enumerate(field.z_interfaces): + depth = field.z_reference[interface] - z_value + down = field.A_plus[interface] * np.exp( + -1j * q[interface] * depth + ) + up = field.A_minus[interface] * np.exp( + 1j * q[interface] * depth + ) + lower_down = field.A_plus[interface + 1] + lower_up = field.A_minus[interface + 1] + np.testing.assert_allclose( + down + up, lower_down + lower_up, rtol=1e-12, atol=1e-12 + ) + np.testing.assert_allclose( + admittance[interface] * (down - up), + admittance[interface + 1] * (lower_down - lower_up), + rtol=1e-12, + atol=1e-12, + ) + + def test_absorbing_substrate_field_decays_with_depth(self): + profile = np.array([[-40.0, 1.0e-5, 2.0e-6], [0.0, 0.0, 0.0]]) + field = CTRoptics.solve_wavefield(profile, self.energy_eV, 1.0, "s") + + self.assertLess(abs(field.psi[0]), abs(field.t_S)) + q_substrate = -field.kz[-1] + depth = field.z_interfaces[-1] - field.z[0] + expected = abs(field.t_S) * np.exp(q_substrate.imag * depth) + np.testing.assert_allclose(abs(field.psi[0]), expected, rtol=1e-12) + + def test_single_interface_critical_angle(self): + delta = 1.0e-5 + profile = np.array([[-10.0, delta, 0.0], [0.0, 0.0, 0.0]]) + alpha_c = np.rad2deg(np.sqrt(2.0 * delta)) + + below = CTRoptics.solve_wavefield( + profile, self.energy_eV, 0.5 * alpha_c, "s" + ) + above = CTRoptics.solve_wavefield( + profile, self.energy_eV, 2.0 * alpha_c, "s" + ) + + np.testing.assert_allclose(abs(below.r_S) ** 2, 1.0, rtol=1e-12) + self.assertLess(abs(above.r_S) ** 2, 1.0) + self.assertLess((-below.kz[-1]).imag, 0.0) + + def test_p_polarization_is_finite_at_exact_substrate_critical_angle(self): + delta = 1.0e-5 + profile = np.array([[-10.0, delta, 0.0], [0.0, 0.0, 0.0]]) + alpha_c = np.rad2deg(np.arccos(1.0 - delta)) + + field = CTRoptics.solve_wavefield( + profile, self.energy_eV, alpha_c, "p" + ) + + np.testing.assert_allclose(-field.kz[-1], 0.0, atol=0.0) + np.testing.assert_allclose(field.r_S, -1.0, rtol=1e-14) + np.testing.assert_allclose(field.t_S, 0.0, atol=0.0) + self.assertTrue(np.all(np.isfinite(field.psi))) + self.assertTrue(np.all(np.isfinite(field.A_plus))) + self.assertTrue(np.all(np.isfinite(field.A_minus))) + + def test_p_polarization_is_continuous_at_internal_critical_angle(self): + delta = 1.0e-5 + profile = np.array( + [ + [-40.0, 2.0 * delta, 0.0], + [-10.0, delta, 0.0], + [20.0, 0.0, 0.0], + ] + ) + alpha_c = np.rad2deg(np.arccos(1.0 - delta)) + + exact = CTRoptics.solve_wavefield( + profile, self.energy_eV, alpha_c, "p" + ) + below = CTRoptics.solve_wavefield( + profile, self.energy_eV, alpha_c * (1.0 - 1.0e-8), "p" + ) + above = CTRoptics.solve_wavefield( + profile, self.energy_eV, alpha_c * (1.0 + 1.0e-8), "p" + ) + + np.testing.assert_allclose(-exact.kz[1], 0.0, atol=0.0) + self.assertTrue(np.all(np.isfinite(exact.psi))) + self.assertTrue(np.isfinite(exact.r_S)) + np.testing.assert_allclose(below.r_S, exact.r_S, rtol=1e-7) + np.testing.assert_allclose(above.r_S, exact.r_S, rtol=1e-7) + np.testing.assert_allclose(below.psi, exact.psi, rtol=1e-7) + np.testing.assert_allclose(above.psi, exact.psi, rtol=1e-7) + + def test_vectorized_reflection_matches_scalar_wavefields(self): + delta = 1.0e-5 + profile = np.array( + [ + [-40.0, 2.0 * delta, 0.0], + [-10.0, delta, 0.0], + [20.0, 0.0, 0.0], + ] + ) + alpha_c = np.rad2deg(np.arccos(1.0 - delta)) + angles = np.array([0.1, alpha_c, 0.5, 1.0]) + + for polarization in ("s", "p"): + actual = CTRoptics._specular_reflection( + profile, self.energy_eV, angles, polarization + ) + expected = np.array( + [ + CTRoptics.solve_wavefield( + profile, self.energy_eV, angle, polarization + ).r_S + for angle in angles + ] + ) + + np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-12) + + def test_vectorized_wavefield_matches_scalar_wavefields(self): + delta = 1.0e-5 + profile = np.array( + [ + [-40.0, 2.0 * delta, 0.0], + [-10.0, delta, 0.0], + [20.0, 0.0, 0.0], + ] + ) + alpha_c = np.rad2deg(np.arccos(1.0 - delta)) + angles = np.array([[0.1, alpha_c], [0.5, 1.0]]) + + vector = CTRoptics.solve_wavefield( + profile, self.energy_eV, angles, "p" + ) + + self.assertEqual(vector.psi.shape, (len(profile), *angles.shape)) + self.assertEqual(vector.kz.shape, (len(profile), *angles.shape)) + self.assertEqual(vector.A_plus.shape, (len(profile), *angles.shape)) + self.assertEqual(vector.A_minus.shape, (len(profile), *angles.shape)) + self.assertEqual(vector.r_S.shape, angles.shape) + self.assertEqual(vector.t_S.shape, angles.shape) + for index in np.ndindex(angles.shape): + scalar = CTRoptics.solve_wavefield( + profile, self.energy_eV, angles[index], "p" + ) + np.testing.assert_allclose( + vector.psi[(slice(None), *index)], + scalar.psi, + rtol=1e-12, + atol=1e-12, + ) + np.testing.assert_allclose( + vector.A_plus[(slice(None), *index)], + scalar.A_plus, + rtol=1e-12, + atol=1e-12, + ) + np.testing.assert_allclose( + vector.A_minus[(slice(None), *index)], + scalar.A_minus, + rtol=1e-12, + atol=1e-12, + ) + np.testing.assert_allclose(vector.r_S[index], scalar.r_S) + np.testing.assert_allclose(vector.t_S[index], scalar.t_S) + + def test_one_film_matches_analytic_slab_expression(self): + thickness = 35.0 + profile = np.array( + [ + [-0.5 * thickness, 1.4e-5, 0.0], + [0.5 * thickness, 5.0e-6, 0.0], + [1.5 * thickness, 0.0, 0.0], + ] + ) + alpha = 1.5 + field = CTRoptics.solve_wavefield(profile, self.energy_eV, alpha, "s") + n = np.array([1.0, 1.0 - 5.0e-6, 1.0 - 1.4e-5]) + q = np.array([self._q(value, self.energy_eV, alpha) for value in n]) + r01 = (q[0] - q[1]) / (q[0] + q[1]) + r12 = (q[1] - q[2]) / (q[1] + q[2]) + phase = np.exp(-1j * q[1] * thickness) + expected = (r01 + r12 * phase**2) / ( + 1.0 + r01 * r12 * phase**2 + ) + + np.testing.assert_allclose(field.r_S, expected, rtol=1e-12) + + def test_crystal_reflectivity_polarizations(self): + bulk = unitcells.unitcell("Pt100") + bulk.setEnergy(self.energy_eV) + crystal = CTRcalc.SXRDCrystal(bulk) + angles = np.array([0.5, 1.0, 2.0]) + + s = crystal.specular_reflectivity(angles, "s") + p = crystal.specular_reflectivity(angles, "p") + unpolarized = crystal.specular_reflectivity(angles, "unpolarized") + + np.testing.assert_allclose(unpolarized, 0.5 * (s + p)) + self.assertIsInstance(crystal.specular_reflectivity(1.0), float) + self.assertEqual(crystal.wavefield(1.0).polarization, "s") + grid = angles.reshape(1, -1) + np.testing.assert_allclose( + crystal.specular_reflectivity(grid, "p"), + p.reshape(1, -1), + ) + + def test_crystal_simplification_removes_redundant_bulk_layers(self): + bulk = unitcells.unitcell("Pt100") + bulk.setEnergy(self.energy_eV) + crystal = CTRcalc.SXRDCrystal(bulk) + full = crystal.optical_profile() + simplified = crystal.simplified_optical_profile() + angles = np.array([0.5, 1.0, 2.0]) + + self.assertEqual(len(full), 31) + self.assertEqual(len(simplified), 3) + exact = crystal.specular_reflectivity(angles, delta_tolerance=None) + reduced = crystal.specular_reflectivity( + angles, delta_tolerance=1e-9 + ) + np.testing.assert_allclose(reduced, exact, rtol=1e-13, atol=1e-15) diff --git a/orgui/datautils/xrayutils/test/test_CTRresolution.py b/orgui/datautils/xrayutils/test/test_CTRresolution.py new file mode 100644 index 0000000..b74c9d1 --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_CTRresolution.py @@ -0,0 +1,274 @@ +"""Regression tests for optional CTR resolution modeling.""" + +import unittest + +import numpy as np + +from .. import CTRplotutil, CTRresolution + + +def _angles(gamma): + gamma = np.asarray(gamma, dtype=np.float64) + zeros = np.zeros_like(gamma) + return np.rec.fromarrays( + [zeros, zeros, gamma, zeros, zeros, zeros], + names="alpha,delta,gamma,omega,chi,phi", + ) + + +class QuadraticCrystal: + """Crystal-like test double with intensity equal to L squared.""" + + @staticmethod + def F(h, k, l): # noqa: N802,E741 + return np.asarray(l, dtype=np.complex128) + + +class TestResolutionFunctions(unittest.TestCase): + def test_constant_and_gamma_dependent_widths(self): + h = np.array([0.0, 1.0, 2.0]) + l = np.array([0.1, 0.2, 0.3]) # noqa: E741 + gamma = np.array([0.0, np.pi / 6.0, np.pi / 2.0]) + + constant = CTRresolution.BoxResolution(0.2) + varying = CTRresolution.GaussianResolution(0.2, 0.4) + + np.testing.assert_allclose(constant.width(h, h, l), 0.2) + np.testing.assert_allclose( + varying.width(h, h, l, _angles(gamma)), + [0.2, 0.4, 0.6], + ) + + def test_box_full_width_and_gaussian_fwhm(self): + box = CTRresolution.BoxResolution(0.4) + gaussian = CTRresolution.GaussianResolution(0.4) + + np.testing.assert_array_equal( + box.weights(np.array([-0.21, -0.2, 0.0, 0.2, 0.21]), 0.4), + [0.0, 1.0, 1.0, 1.0, 0.0], + ) + np.testing.assert_allclose( + gaussian.weights(np.array([-0.2, 0.0, 0.2]), 0.4), + [0.5, 1.0, 0.5], + ) + + def test_invalid_width_parameters_are_rejected(self): + for value in (-0.1, np.inf, np.nan): + with self.subTest(value=value): + with self.assertRaises(ValueError): + CTRresolution.BoxResolution(value) + with self.assertRaises(ValueError): + CTRresolution.GaussianResolution(0.1, -0.2) + + def test_gamma_dependent_width_requires_angle_records(self): + resolution = CTRresolution.BoxResolution(0.1, 0.2) + with self.assertRaisesRegex(ValueError, "calcAnglesZmode"): + resolution.width([0.0], [0.0], [1.0]) + + +class TestFastConvolution(unittest.TestCase): + def test_constant_intensity_is_preserved_on_unsorted_irregular_grid(self): + l = np.array([3.0, 0.0, 1.0, 1.8]) # noqa: E741 + ctr = CTRplotutil.CTR((1.0, 0.0), l, np.full(l.shape, 4.0)) + ctrs = CTRplotutil.CTRCollection([ctr]) + + result = CTRresolution.fast_convolve( + ctrs, CTRresolution.GaussianResolution(1.2) + ) + + np.testing.assert_array_equal(result[0].l, l) + np.testing.assert_allclose(result[0].sfI, 4.0) + + def test_box_uses_trapezoidal_weights_and_boundary_normalization(self): + l = np.array([0.0, 1.0, 3.0]) # noqa: E741 + ctr = CTRplotutil.CTR((0.0, 0.0), l, np.array([1.0, 2.0, 4.0])) + result = CTRresolution.fast_convolve( + CTRplotutil.CTRCollection([ctr]), + CTRresolution.BoxResolution(4.0), + )[0] + + expected_intensity = np.array( + [ + (0.5 * 1.0 + 1.5 * 4.0) / 2.0, + (0.5 * 1.0 + 1.5 * 4.0 + 1.0 * 16.0) / 3.0, + (1.5 * 4.0 + 1.0 * 16.0) / 2.5, + ] + ) + np.testing.assert_allclose(result.sfI, np.sqrt(expected_intensity)) + + def test_zero_width_is_identity_and_output_metadata_is_clean(self): + l = np.array([0.0, 0.4, 1.0]) # noqa: E741 + ctr = CTRplotutil.CTR( + (1.0, 2.0), + l, + np.array([2.0, 3.0, 5.0]), + err=np.ones(3), + phi=np.array([10.0, 20.0, 30.0]), + name="source rod", + ) + ctr.angles = _angles([0.0, 0.1, 0.2]) + ctr.bgI = np.ones(3) + ctrs = CTRplotutil.CTRCollection([ctr], name="source collection") + ctrs.setPlotSettings("plot argument", color="red") + + result = CTRresolution.fast_convolve( + ctrs, CTRresolution.GaussianResolution(0.0) + ) + + self.assertIsNot(result, ctrs) + self.assertIsNot(result[0], ctr) + self.assertEqual(result.name, ctrs.name) + self.assertEqual(result.plotsett, ctrs.plotsett) + self.assertEqual(result.plotkeyargs, ctrs.plotkeyargs) + self.assertEqual(result[0].name, ctr.name) + np.testing.assert_array_equal(result[0].sfI, ctr.sfI) + np.testing.assert_array_equal(result[0].angles, ctr.angles) + self.assertIsNone(result[0].err) + self.assertIsNone(result[0].phi) + self.assertFalse(hasattr(result[0], "bgI")) + np.testing.assert_array_equal(ctr.err, np.ones(3)) + np.testing.assert_array_equal(ctr.phi, [10.0, 20.0, 30.0]) + + def test_gamma_dependent_fast_convolution_requires_angles(self): + ctr = CTRplotutil.CTR((0.0, 0.0), [0.0, 1.0], [1.0, 2.0]) + with self.assertRaisesRegex(ValueError, "calcAnglesZmode"): + CTRresolution.fast_convolve( + CTRplotutil.CTRCollection([ctr]), + CTRresolution.BoxResolution(0.1, 0.2), + ) + + def test_invalid_ctr_data_are_rejected(self): + cases = [ + CTRplotutil.CTR((0.0, 0.0), [0.0, 0.0], [1.0, 2.0]), + CTRplotutil.CTR((0.0, 0.0), [0.0, np.nan], [1.0, 2.0]), + CTRplotutil.CTR((0.0, 0.0), [0.0, 1.0], [1.0, -2.0]), + ] + for ctr in cases: + with self.subTest(ctr=ctr): + with self.assertRaises(ValueError): + CTRresolution.fast_convolve( + CTRplotutil.CTRCollection([ctr]), + CTRresolution.BoxResolution(0.2), + ) + + +class TestStructureFactorSampling(unittest.TestCase): + def test_box_quadrature_matches_quadratic_intensity_average(self): + center = 2.0 + width = 0.6 + ctr = CTRplotutil.CTR((0.0, 0.0), [center], [0.0]) + result = CTRresolution.sample_structure_factor( + CTRplotutil.CTRCollection([ctr]), + QuadraticCrystal(), + CTRresolution.BoxResolution(width), + quadrature_order=5, + )[0] + + expected_intensity = center**2 + width**2 / 12.0 + np.testing.assert_allclose(result.sfI, np.sqrt(expected_intensity)) + + def test_gaussian_quadrature_matches_quadratic_intensity_average(self): + center = 2.0 + fwhm = 0.6 + ctr = CTRplotutil.CTR((0.0, 0.0), [center], [0.0]) + result = CTRresolution.sample_structure_factor( + CTRplotutil.CTRCollection([ctr]), + QuadraticCrystal(), + CTRresolution.GaussianResolution(fwhm), + quadrature_order=5, + )[0] + + sigma = fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0))) + expected_intensity = center**2 + sigma**2 + np.testing.assert_allclose(result.sfI, np.sqrt(expected_intensity)) + + def test_zero_width_samples_the_central_structure_factor(self): + ctr = CTRplotutil.CTR((1.0, 2.0), [0.5, 1.5], [99.0, 99.0]) + result = CTRresolution.sample_structure_factor( + CTRplotutil.CTRCollection([ctr]), + QuadraticCrystal(), + CTRresolution.BoxResolution(0.0), + )[0] + np.testing.assert_allclose(result.sfI, [0.5, 1.5]) + + def test_dense_irregular_fast_result_converges_to_slow_result(self): + left = np.linspace(0.2, 1.0, 401) ** 1.3 + right = 1.0 + np.linspace(0.0, 0.8, 401)[1:] ** 1.7 + l = np.concatenate((left, right)) # noqa: E741 + ctr = CTRplotutil.CTR((0.0, 0.0), l, l) + ctrs = CTRplotutil.CTRCollection([ctr]) + resolution = CTRresolution.GaussianResolution(0.3) + + fast = CTRresolution.fast_convolve(ctrs, resolution)[0] + center_index = np.flatnonzero(l == 1.0)[0] + template = CTRplotutil.CTR((0.0, 0.0), [1.0], [0.0]) + slow = CTRresolution.sample_structure_factor( + CTRplotutil.CTRCollection([template]), + QuadraticCrystal(), + resolution, + )[0] + + np.testing.assert_allclose( + fast.sfI[center_index], slow.sfI[0], rtol=2e-4 + ) + + def test_invalid_quadrature_order_is_rejected(self): + ctrs = CTRplotutil.CTRCollection( + [CTRplotutil.CTR((0.0, 0.0), [1.0], [0.0])] + ) + for order in (0, 2, 2.5, True): + with self.subTest(order=order): + with self.assertRaises(ValueError): + CTRresolution.sample_structure_factor( + ctrs, + QuadraticCrystal(), + CTRresolution.BoxResolution(0.1), + quadrature_order=order, + ) + + +class RecordingAngles: + def __init__(self): + self.calls = [] + + def anglesZmode(self, hkl, fixedangle, **kwargs): + self.calls.append((np.copy(hkl), fixedangle, kwargs)) + count = hkl.shape[1] + values = np.zeros((count, 6), dtype=np.float64) + values[:, 2] = np.arange(count) + 0.25 + return values + + +class TestCollectionAngleAPI(unittest.TestCase): + def test_collection_forwards_all_z_mode_constraints(self): + ctrs = CTRplotutil.CTRCollection( + [ + CTRplotutil.CTR((0.0, 0.0), [0.1, 0.2], [1.0, 1.0]), + CTRplotutil.CTR((1.0, 0.0), [0.3], [1.0]), + ] + ) + calculator = RecordingAngles() + + result = ctrs.calcAnglesZmode( + calculator, + fixedangle=0.12, + fixed="out", + chi=0.03, + phi=-0.04, + mirrorx=True, + ) + + self.assertEqual(len(result), 2) + self.assertEqual(len(calculator.calls), 2) + for call in calculator.calls: + self.assertEqual(call[1], 0.12) + self.assertEqual( + call[2], + {"fixed": "out", "chi": 0.03, "phi": -0.04, "mirrorx": True}, + ) + np.testing.assert_allclose(ctrs[0].angles["gamma"], [0.25, 1.25]) + np.testing.assert_allclose(ctrs[1].angles["gamma"], [0.25]) + + +if __name__ == "__main__": + unittest.main() diff --git a/orgui/datautils/xrayutils/test/test_CTRsymmetry.py b/orgui/datautils/xrayutils/test/test_CTRsymmetry.py index de863b1..d71a2dc 100644 --- a/orgui/datautils/xrayutils/test/test_CTRsymmetry.py +++ b/orgui/datautils/xrayutils/test/test_CTRsymmetry.py @@ -21,6 +21,7 @@ # THE SOFTWARE. # # ###########################################################################*/ +import copy import importlib.util import subprocess import sys @@ -38,6 +39,154 @@ class TestSymmetryUtilities(unittest.TestCase): + @staticmethod + def make_metadata_unitcell(): + site = CTRsymmetry.WyckoffSiteSpec( + site_id="O_2a", + element="O", + wyckoff_label="2a", + coordinates=(), + representative_parent_fractional=(0.2, 0.3, 0.4), + occ=0.9, + iDW=0.5, + oDW=0.6, + ) + atoms = [] + for index, (position, x_factor) in enumerate( + (((0.2, 0.3, 0.4), 1.0), ((0.8, 0.7, 0.6), -1.0)) + ): + atoms.append( + CTRsymmetry.GeneratedWyckoffAtom( + atom_index=index, + element="O", + site_id="O_2a", + wyckoff_label="2a", + parent_fractional=np.asarray(position), + surface_fractional=np.asarray(position), + layer=0, + site_couplings=( + CTRsymmetry.WyckoffSiteCoupling( + atom_index=index, + coordinate="x", + axis="x", + factor=x_factor, + site_id="O_2a", + ), + ), + ) + ) + model = CTRsymmetry.SurfaceSymmetryModel( + CTRsymmetry.SurfaceCellSpec( + (4.0, 4.0, 4.0), + (90.0, 90.0, 90.0), + np.identity(3), + translation_range=0, + ), + (site,), + atoms, + ) + return model.build_unitcell("test") + + def test_wyckoff_returns_one_site_and_reports_site_parameters(self): + unitcell = self.make_metadata_unitcell() + + self.assertEqual(unitcell.wyckoff("O_2a"), unitcell.wyckoff_sites()[0]) + self.assertEqual(unitcell.wyckoff("O_2a")["occ"], 0.9) + self.assertEqual(unitcell.wyckoff("O_2a")["iDW"], 0.5) + self.assertEqual(unitcell.wyckoff("O_2a")["oDW"], 0.6) + with self.assertRaisesRegex(ValueError, "Unknown Wyckoff site"): + unitcell.wyckoff("missing") + + def test_set_wyckoff_atom_parameter_updates_all_site_atoms(self): + unitcell = self.make_metadata_unitcell() + + unitcell.set_wyckoff_atom_parameter("O_2a", "occ", 0.75) + unitcell.set_wyckoff_atom_parameter("O_2a", "z", [0.1, 0.9]) + + np.testing.assert_allclose(unitcell.basis[:, 6], 0.75) + np.testing.assert_allclose(unitcell.basis_0[:, 6], 0.75) + np.testing.assert_allclose(unitcell.basis[:, 3], [0.1, 0.9]) + np.testing.assert_allclose(unitcell.basis_0[:, 3], [0.1, 0.9]) + self.assertEqual(unitcell.wyckoff("O_2a")["occ"], 0.75) + np.testing.assert_allclose( + [atom.surface_fractional[2] for atom in unitcell.symmetry_metadata.atoms], + [0.1, 0.9], + ) + + def test_set_wyckoff_atom_parameter_allows_uninitialized_fit_parameters(self): + unitcell = self.make_metadata_unitcell() + unitcell.addFitParameter( + ([0], ["z"]), + name="manual_top_oxygen_z", + ) + original_z = unitcell.basis[:, 3].copy() + + unitcell.set_wyckoff_atom_parameter("O_2a", "iDW", 0.35) + + np.testing.assert_allclose(unitcell.basis[:, 4], 0.35) + np.testing.assert_allclose(unitcell.basis_0[:, 4], 0.35) + np.testing.assert_allclose(unitcell.basis[:, 3], original_z) + self.assertIsNone(unitcell.parameters["absolute"][0].value) + + def test_set_wyckoff_site_parameter_propagates_parent_coordinate(self): + unitcell = self.make_metadata_unitcell() + + unitcell.set_wyckoff_site_parameter("O_2a", "x", 0.25) + + np.testing.assert_allclose(unitcell.basis[:, 1], [0.25, 0.75]) + np.testing.assert_allclose(unitcell.basis_0[:, 1], [0.25, 0.75]) + self.assertEqual( + unitcell.wyckoff("O_2a")["representative_parent_fractional"], + (0.25, 0.3, 0.4), + ) + np.testing.assert_allclose( + [atom.parent_fractional[0] for atom in unitcell.symmetry_metadata.atoms], + [0.25, 0.75], + ) + + def test_set_wyckoff_site_parameter_sets_site_wide_physical_values(self): + unitcell = self.make_metadata_unitcell() + + unitcell.set_wyckoff_site_parameter("O_2a", "iDW", 0.7) + unitcell.set_wyckoff_site_parameter("O_2a", "oDW", 0.8) + unitcell.set_wyckoff_site_parameter("O_2a", "occ", 0.65) + + np.testing.assert_allclose(unitcell.basis[:, 4], 0.7) + np.testing.assert_allclose(unitcell.basis[:, 5], 0.8) + np.testing.assert_allclose(unitcell.basis[:, 6], 0.65) + self.assertEqual(unitcell.wyckoff("O_2a")["iDW"], 0.7) + self.assertEqual(unitcell.wyckoff("O_2a")["oDW"], 0.8) + self.assertEqual(unitcell.wyckoff("O_2a")["occ"], 0.65) + + def test_legacy_expanded_couplings_remain_readable(self): + lines = [ + "parent_a: 4 4 4", + "parent_alpha: 90 90 90", + "surface_transform:", + "1 0 0", + "0 1 0", + "0 0 1", + "wyckoff_sites:", + "site_id element wyckoff_label variables representative_x " + "representative_y representative_z occ iDW oDW", + "O_1a O 1a u=0.2 0.2 0 0 1 0.5 0.5", + "wyckoff_atoms:", + "atom_index element site_id wyckoff_label parent_x parent_y " + "parent_z surface_x surface_y surface_z layer", + "0 O O_1a 1a 0.2 0 0 0.2 0 0 0", + "wyckoff_couplings:", + "atom_index coordinate site_id variable constant factor", + "0 x O_1a u 0 1", + "wyckoff_site_couplings:", + "atom_index coordinate site_id axis factor", + "0 x O_1a x 1", + ] + + model = CTRsymmetry.symmetry_metadata_from_lines(lines) + + self.assertEqual(len(model.wyckoff_couplings("O_1a")), 1) + self.assertEqual(len(model.wyckoff_site_couplings("O_1a")), 1) + def test_duplicate_wyckoff_site_ids_are_disambiguated(self): sites = CTRsymmetry._with_unique_site_ids( ( @@ -130,6 +279,18 @@ def make_pyxtal_generated_unitcell( model = CTRsymmetry.model_from_seed(seed, surface_spec, tol=1e-3) return model.build_unitcell(element) + def assert_variables_allclose(self, actual, expected): + """Compare Wyckoff ``variables`` dicts, tolerating float rounding. + + pyxtal/spglib can return values that are a few ULP off an exact + input (e.g. 0.11999999999999997 instead of 0.12), so this avoids + exact dict equality on floats. + """ + self.assertEqual(set(actual), set(expected)) + np.testing.assert_allclose( + [actual[key] for key in expected], [expected[key] for key in expected] + ) + @staticmethod def coupling_factor_vectors(unitcell, site_id): grouped = {} @@ -215,7 +376,7 @@ def test_wyckoff_query_exposes_oxygen_u_couplings(self): sites = unitcell.wyckoff_sites() self.assertEqual([site["site_id"] for site in sites], ["Ru_2a", "O_4f"]) self.assertEqual(sites[0]["spacegroup_number"], 136) - self.assertEqual(sites[1]["variables"], {"u": 0.30569}) + self.assert_variables_allclose(sites[1]["variables"], {"u": 0.30569}) self.assertEqual(sites[1]["status"], "metadata_only") couplings = unitcell.wyckoff_couplings("O_4f") @@ -242,15 +403,15 @@ def test_wyckoff_coordinate_parameter_preserves_rutile_u_symmetry(self): ) self.assertEqual(parameter.settings["wyckoff"]["kind"], "coordinate") - self.assertEqual(parameter.settings["wyckoff"]["value_kind"], "delta") - np.testing.assert_allclose(unitcell.getInitialParameters(), [0.0]) + self.assertEqual(parameter.settings["wyckoff"]["value_kind"], "absolute") + np.testing.assert_allclose(unitcell.getInitialParameters(), [0.30569]) np.testing.assert_allclose( unitcell.getStartParamAndLimits()[1:], - ([-0.10569], [0.09431]), + ([0.2], [0.4]), ) self.assertEqual(unitcell.wyckoff_sites()[1]["status"], "symmetry_preserving") - unitcell.setFitParameters([0.01]) + unitcell.setFitParameters([0.31569]) for coupling in couplings: column = unitcell.parameterLookup[coupling.coordinate] @@ -260,6 +421,37 @@ def test_wyckoff_coordinate_parameter_preserves_rutile_u_symmetry(self): expected, ) + def test_wyckoff_legacy_delta_parameter_dict_matches_absolute_result(self): + """A pre-fix, ``value_kind="delta"`` saved Wyckoff parameter dict + must reproduce the same basis as the current absolute-value + convention, since old saved fits stored the delta from the site's + reference value rather than the absolute coordinate.""" + unitcell = self.make_rutile_110_unitcell() + unitcell.addWyckoffParameter("O_4f", "u", absolute_limits=(0.2, 0.4)) + unitcell.setFitParameters([0.31569]) + expected_basis = unitcell.basis.copy() + + saved = unitcell.parametersToDict() + (key, param_dict), = saved["relative"].items() + wyckoff_settings = param_dict["settings"]["wyckoff"] + self.assertEqual(wyckoff_settings["value_kind"], "absolute") + reference_value = wyckoff_settings["reference_value"] + + # Simulate a parameter dict saved before value_kind="absolute" was + # introduced: no reference_value, value_kind="delta", and the stored + # value is the delta from the reference rather than the absolute + # coordinate. + legacy_dict = copy.deepcopy(saved) + legacy_settings = legacy_dict["relative"][key]["settings"]["wyckoff"] + legacy_settings["value_kind"] = "delta" + del legacy_settings["reference_value"] + legacy_dict["relative"][key]["value"] = param_dict["value"] - reference_value + + restored = self.make_rutile_110_unitcell() + restored.parametersFromDict(legacy_dict) + + np.testing.assert_allclose(restored.basis, expected_basis) + def test_wyckoff_site_parameter_displaces_fixed_rutile_site(self): unitcell = self.make_rutile_110_unitcell() couplings = [ @@ -321,9 +513,13 @@ def test_wyckoff_coordinate_parameters_fit_all_site_variables(self): [par.settings["wyckoff"]["variable"] for par in parameters], ["u", "v", "w"], ) + np.testing.assert_allclose( + unitcell.getInitialParameters(), + [0.12, 0.23, 0.34], + ) self.assertEqual(unitcell.wyckoff_sites()[0]["status"], "symmetry_preserving") - unitcell.setFitParameters([0.01, -0.02, 0.03]) + unitcell.setFitParameters([0.13, 0.21, 0.37]) deltas = {"u": 0.01, "v": -0.02, "w": 0.03} for coordinate_name in ("x", "y", "z"): @@ -352,7 +548,7 @@ def test_trigonal_general_site_exposes_u_minus_v_couplings(self): Lattice.hexagonal(4.0, 6.0), ) - self.assertEqual( + self.assert_variables_allclose( unitcell.wyckoff_sites()[0]["variables"], {"u": 0.12, "v": 0.27, "w": 0.34}, ) @@ -447,11 +643,39 @@ def test_symmetry_metadata_round_trips_without_pyxtal_construction(self): unitcell = self.make_rutile_110_unitcell() text = unitcell.toStr() + self.assertIn("wyckoff_coupling_matrices:", text) + self.assertIn("wyckoff_site_coupling_matrices:", text) + self.assertNotIn("\nwyckoff_couplings:\n", text) + self.assertNotIn("\nwyckoff_site_couplings:\n", text) + restored = UnitCell.fromStr(text) - self.assertEqual(restored.wyckoff_sites()[1]["variables"], {"u": 0.30569}) + self.assert_variables_allclose( + restored.wyckoff_sites()[1]["variables"], {"u": 0.30569} + ) self.assertEqual(len(restored.wyckoff_couplings("O_4f")), 8) self.assertGreater(len(restored.wyckoff_site_couplings("Ru_2a")), 0) + original_couplings = sorted( + ( + coupling.atom_index, + coupling.coordinate, + coupling.variable, + round(coupling.constant, 10), + round(coupling.factor, 10), + ) + for coupling in unitcell.wyckoff_couplings() + ) + restored_couplings = sorted( + ( + coupling.atom_index, + coupling.coordinate, + coupling.variable, + round(coupling.constant, 10), + round(coupling.factor, 10), + ) + for coupling in restored.wyckoff_couplings() + ) + self.assertEqual(restored_couplings, original_couplings) restored.addWyckoffParameter("O_4f", "u", absolute_limits=(0.2, 0.4)) self.assertEqual(restored.wyckoff_sites()[1]["status"], "symmetry_preserving") diff --git a/orgui/datautils/xrayutils/test/test_scattering_factor_cache.py b/orgui/datautils/xrayutils/test/test_scattering_factor_cache.py new file mode 100644 index 0000000..0634f40 --- /dev/null +++ b/orgui/datautils/xrayutils/test/test_scattering_factor_cache.py @@ -0,0 +1,76 @@ +import unittest +from unittest import mock + +import numpy as np +import xraydb + +from .. import CTRuc, CTRutil + + +class TestScatteringFactorCaching(unittest.TestCase): + def tearDown(self): + CTRutil._readWaasmaier_cached.cache_clear() + CTRutil._readDispersion_cached.cache_clear() + + def test_cached_values_match_xraydb_and_cannot_be_mutated(self): + for species in ("Fe", "Re", "O", "Ti", "O2-", "Fe2+"): + coefficients = CTRutil.readWaasmaier(species) + baseline = CTRutil._readWaasmaier_cached( + CTRutil._normalize_species(species) + ) + np.testing.assert_allclose(coefficients, baseline) + coefficients[0] += 1.0 + np.testing.assert_allclose( + CTRutil.readWaasmaier(species), baseline + ) + for energy in (10000.0, 20000.0): + expected = ( + xraydb.f1_chantler(CTRutil.atomic_number(species), energy), + xraydb.f2_chantler(CTRutil.atomic_number(species), energy), + ) + np.testing.assert_allclose( + CTRutil.readDispersion(species, energy), expected + ) + + def test_repeated_energy_and_species_groups_do_not_repeat_lookups(self): + cell = CTRuc.UnitCell([4.0, 4.0, 4.0], [90.0, 90.0, 90.0]) + for index, species in enumerate(("Fe", "Re", "O") * 32): + cell.addAtom(species, [index / 96.0, 0.0, 0.0], 0.0, 0.0, 1.0) + + with ( + mock.patch.object( + CTRuc, "readWaasmaier", wraps=CTRuc.readWaasmaier + ) as waasmaier, + mock.patch.object( + CTRuc, "readDispersion", wraps=CTRuc.readDispersion + ) as dispersion, + ): + cell.setEnergy(10000.0) + first = np.array(cell.f, copy=True) + cell.setEnergy(10000.0) + self.assertEqual(waasmaier.call_count, 3) + self.assertEqual(dispersion.call_count, 3) + cell.setEnergy(20000.0) + self.assertEqual(waasmaier.call_count, 6) + self.assertEqual(dispersion.call_count, 6) + self.assertFalse(np.allclose(first[:, 11:], cell.f[:, 11:])) + + def test_layer_splitting_defers_or_copies_form_factors(self): + cell = CTRuc.UnitCell([4.0, 4.0, 4.0], [90.0, 90.0, 90.0]) + cell.addAtom("Fe", [0.0, 0.0, 0.0], 0.0, 0.0, 1.0, layer=0) + cell.addAtom("O", [0.0, 0.0, 0.5], 0.0, 0.0, 1.0, layer=1) + layers = cell.split_in_layers() + self.assertFalse(hasattr(cell, "f")) + self.assertTrue(all(not hasattr(layer, "f") for layer in layers.values())) + + cell.setEnergy(20000.0) + layers = cell.split_in_layers() + first_layer = next(iter(layers.values())) + before = cell.f[0, 0] + first_layer.f[0, 0] += 1.0 + self.assertEqual(cell.f[0, 0], before) + self.assertEqual(first_layer._E, 20000.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/orgui/datautils/xrayutils/test/testdata/0001_fit_2V036_reference.xtal b/orgui/datautils/xrayutils/test/testdata/0001_fit_2V036_reference.xtal new file mode 100644 index 0000000..3ecca0c --- /dev/null +++ b/orgui/datautils/xrayutils/test/testdata/0001_fit_2V036_reference.xtal @@ -0,0 +1,96 @@ +E = 20.00000 keV + +# Film RuO2 +0002 occupancy = 1.00000 +- nan + +Width/layers +(18.00000 +- nan) + +UnitCell RuO2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# EpitaxyInterface TiO2toRuO2 +0001 occupancy = 1.00000 +- nan +type skellam +Width/cells Skew/cells StrainCoupling Offset/bulk_frac +(0.35000 +- nan) (0.00000 +- 0.00000) (1.00000 +- nan) (0.00000 +- nan) + +TopUnitCell RuO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +BottomUnitCell TiO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ti (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ti (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30458 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69542 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ti (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ti (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19542 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80458 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# UnitCell bulk +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (-0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (-0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ti (0.50000 +- nan) (0.00000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ti (0.00000 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30458 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69542 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (-0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (-0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ti (0.00000 +- nan) (0.00000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ti (0.50000 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19542 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80458 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = -1.0, 2.0 = -0.5 +layer_behaviour: ignore diff --git a/orgui/datautils/xrayutils/test/testdata/CTRs_reference.dat b/orgui/datautils/xrayutils/test/testdata/CTRs_reference.dat new file mode 100644 index 0000000..29aa069 --- /dev/null +++ b/orgui/datautils/xrayutils/test/testdata/CTRs_reference.dat @@ -0,0 +1,9 @@ +# H K L F_HKL phi +0.00000 0.00000 0.00000 905.72572 nan -3.00000 +0.00000 0.00000 0.00363 367.33955 nan -3.00000 +0.00000 0.00000 0.00725 201.34023 nan -3.00000 +0.00000 0.00000 0.01088 141.76241 nan -3.00000 +0.00000 0.00000 0.01451 112.43899 nan -3.00000 +0.00000 0.00000 0.01813 95.37011 nan -3.00000 +0.00000 0.00000 0.02176 84.28214 nan -3.00000 +0.00000 0.00000 0.02539 76.45271 nan -3.00000 diff --git a/orgui/datautils/xrayutils/test/testdata/RuO2_TiO2_Poisson_etching.xpr b/orgui/datautils/xrayutils/test/testdata/RuO2_TiO2_Poisson_etching.xpr new file mode 100644 index 0000000..3d249d3 --- /dev/null +++ b/orgui/datautils/xrayutils/test/testdata/RuO2_TiO2_Poisson_etching.xpr @@ -0,0 +1,170 @@ +E = 20.00000 keV +# PoissonSurface RuO2surface +0003 occupancy = 1.00000 + +W/layers alpha offset/layers +-6.00000 1.00000 0.00000 + +TerminationUnitCell 1 RuO2_termination_1 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.15285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.09715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.40285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.34715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +12 O (0.50000 +- nan) (0.00000 +- nan) (0.65285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +13 O (0.00000 +- nan) (0.00000 +- nan) (0.59715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +14 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +15 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +16 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +17 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +18 O (0.00000 +- nan) (0.00000 +- nan) (0.90285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +19 O (0.50000 +- nan) (0.00000 +- nan) (0.84715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +20 Ru (0.00000 +- nan) (0.00000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +21 Ru (0.50000 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +22 O (0.19431 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +23 O (0.80569 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.75 +layer_behaviour: select +layer_cycle: 1.0 + + +TerminationUnitCell 2 RuO2_termination_2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.00000 +- nan) (0.00000 +- nan) (0.15285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.50000 +- nan) (0.00000 +- nan) (0.09716 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.50000 +- nan) (0.00000 +- nan) (0.40285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +07 O (0.00000 +- nan) (0.00000 +- nan) (0.34715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +08 Ru (0.50000 +- nan) (0.00000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +09 Ru (0.00000 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +10 O (0.30569 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +11 O (0.69431 +- nan) (0.50000 +- nan) (0.25000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +12 O (0.00000 +- nan) (0.00000 +- nan) (0.65285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +13 O (0.50000 +- nan) (0.00000 +- nan) (0.59715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +14 Ru (0.00000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +15 Ru (0.50000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +16 O (0.19431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +17 O (0.80569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +18 O (0.50000 +- nan) (0.00000 +- nan) (0.90285 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +19 O (0.00000 +- nan) (0.00000 +- nan) (0.84715 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +20 Ru (0.50000 +- nan) (0.00000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +21 Ru (0.00000 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +22 O (0.30569 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +23 O (0.69431 +- nan) (0.50000 +- nan) (0.75000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +layerpos: 2.0 = 0.75 +layer_behaviour: select +layer_cycle: 2.0 + + + +# Film RuO2 +0002 occupancy = 1.00000 + +Width/layers +(17.00000 +- nan) + +UnitCell RuO2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# EpitaxyInterface TiO2toRuO2 +0001 occupancy = 1.00000 +type skellam +Width/cells Skew/cells StrainCoupling Offset/bulk_frac +0.35000 0.00000 1.00000 0.00000 + +TopUnitCell RuO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ru (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ru (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30569 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69431 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30569 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19431 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ru (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ru (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19431 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80569 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.5033 +- nan) (0.5033 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +BottomUnitCell TiO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ti (0.50000 +- nan) (0.00000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ti (0.00000 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30458 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69542 +- nan) (0.50000 +- nan) (0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ti (0.00000 +- nan) (0.00000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ti (0.50000 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19542 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80458 +- nan) (0.50000 +- nan) (0.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# UnitCell bulk +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O (0.50000 +- nan) (0.00000 +- nan) (-0.19542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +01 O (0.00000 +- nan) (0.00000 +- nan) (-0.30458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +02 Ti (0.50000 +- nan) (0.00000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +03 Ti (0.00000 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +04 O (0.30458 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +05 O (0.69542 +- nan) (0.50000 +- nan) (-0.50000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (2 +- nan) +06 O (0.00000 +- nan) (0.00000 +- nan) (-0.69542 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +07 O (0.50000 +- nan) (0.00000 +- nan) (-0.80458 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +08 Ti (0.00000 +- nan) (0.00000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +09 Ti (0.50000 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +10 O (0.19542 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +11 O (0.80458 +- nan) (0.50000 +- nan) (-1.00000 +- nan) (0.3719 +- nan) (0.3719 +- nan) (1.0000 +- nan) (1 +- nan) +layerpos: 1.0 = -1.0, 2.0 = -0.5 +layer_behaviour: ignore diff --git a/orgui/datautils/xrayutils/test/testdata/RuO2_TiO2_Poisson_etching.xtal b/orgui/datautils/xrayutils/test/testdata/RuO2_TiO2_Poisson_etching.xtal new file mode 100644 index 0000000..5e787ea --- /dev/null +++ b/orgui/datautils/xrayutils/test/testdata/RuO2_TiO2_Poisson_etching.xtal @@ -0,0 +1,170 @@ +E = 20.00000 keV +# PoissonSurface RuO2surface +0003 occupancy = 1.00000 + +W/layers alpha offset/layers +-6.00000 1.00000 0.00000 + +TerminationUnitCell 1 RuO2_termination_1 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.15285 0.5033 0.5033 1.0000 1 +01 O 0.00000 0.00000 0.09715 0.5033 0.5033 1.0000 1 +02 Ru 0.50000 0.00000 0.00000 0.5033 0.5033 1.0000 1 +03 Ru 0.00000 0.50000 0.00000 0.5033 0.5033 1.0000 1 +04 O 0.30569 0.50000 0.00000 0.5033 0.5033 1.0000 1 +05 O 0.69431 0.50000 0.00000 0.5033 0.5033 1.0000 1 +06 O 0.00000 0.00000 0.40285 0.5033 0.5033 1.0000 1 +07 O 0.50000 0.00000 0.34715 0.5033 0.5033 1.0000 1 +08 Ru 0.00000 0.00000 0.25000 0.5033 0.5033 1.0000 1 +09 Ru 0.50000 0.50000 0.25000 0.5033 0.5033 1.0000 1 +10 O 0.19431 0.50000 0.25000 0.5033 0.5033 1.0000 1 +11 O 0.80569 0.50000 0.25000 0.5033 0.5033 1.0000 1 +12 O 0.50000 0.00000 0.65285 0.5033 0.5033 1.0000 1 +13 O 0.00000 0.00000 0.59715 0.5033 0.5033 1.0000 1 +14 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 1 +15 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 1 +16 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 1 +17 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 1 +18 O 0.00000 0.00000 0.90285 0.5033 0.5033 1.0000 1 +19 O 0.50000 0.00000 0.84715 0.5033 0.5033 1.0000 1 +20 Ru 0.00000 0.00000 0.75000 0.5033 0.5033 1.0000 1 +21 Ru 0.50000 0.50000 0.75000 0.5033 0.5033 1.0000 1 +22 O 0.19431 0.50000 0.75000 0.5033 0.5033 1.0000 1 +23 O 0.80569 0.50000 0.75000 0.5033 0.5033 1.0000 1 +layerpos: 1.0 = 0.75 +layer_behaviour: select +layer_cycle: 1.0 + + +TerminationUnitCell 2 RuO2_termination_2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 13.0912 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.00000 0.00000 0.15285 0.5033 0.5033 1.0000 2 +01 O 0.50000 0.00000 0.09716 0.5033 0.5033 1.0000 2 +02 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 2 +03 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 2 +04 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 2 +05 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 2 +06 O 0.50000 0.00000 0.40285 0.5033 0.5033 1.0000 2 +07 O 0.00000 0.00000 0.34715 0.5033 0.5033 1.0000 2 +08 Ru 0.50000 0.00000 0.25000 0.5033 0.5033 1.0000 2 +09 Ru 0.00000 0.50000 0.25000 0.5033 0.5033 1.0000 2 +10 O 0.30569 0.50000 0.25000 0.5033 0.5033 1.0000 2 +11 O 0.69431 0.50000 0.25000 0.5033 0.5033 1.0000 2 +12 O 0.00000 0.00000 0.65285 0.5033 0.5033 1.0000 2 +13 O 0.50000 0.00000 0.59715 0.5033 0.5033 1.0000 2 +14 Ru 0.00000 0.00000 0.50000 0.5033 0.5033 1.0000 2 +15 Ru 0.50000 0.50000 0.50000 0.5033 0.5033 1.0000 2 +16 O 0.19431 0.50000 0.50000 0.5033 0.5033 1.0000 2 +17 O 0.80569 0.50000 0.50000 0.5033 0.5033 1.0000 2 +18 O 0.50000 0.00000 0.90285 0.5033 0.5033 1.0000 2 +19 O 0.00000 0.00000 0.84715 0.5033 0.5033 1.0000 2 +20 Ru 0.50000 0.00000 0.75000 0.5033 0.5033 1.0000 2 +21 Ru 0.00000 0.50000 0.75000 0.5033 0.5033 1.0000 2 +22 O 0.30569 0.50000 0.75000 0.5033 0.5033 1.0000 2 +23 O 0.69431 0.50000 0.75000 0.5033 0.5033 1.0000 2 +layerpos: 2.0 = 0.75 +layer_behaviour: select +layer_cycle: 2.0 + + + +# Film RuO2 +0002 occupancy = 1.00000 + +Width/layers +17.00000 + +UnitCell RuO2 +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 +01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 +02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 +03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 +04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 +05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 +06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 +07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 +08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 +09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 +10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 +11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# EpitaxyInterface TiO2toRuO2 +0001 occupancy = 1.00000 +type skellam +Width/cells Skew/cells StrainCoupling Offset/bulk_frac +0.35000 0.00000 1.00000 0.00000 + +TopUnitCell RuO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5456 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.80569 0.5033 0.5033 1.0000 2 +01 O 0.00000 0.00000 0.69431 0.5033 0.5033 1.0000 2 +02 Ru 0.50000 0.00000 0.50000 0.5033 0.5033 1.0000 2 +03 Ru 0.00000 0.50000 0.50000 0.5033 0.5033 1.0000 2 +04 O 0.30569 0.50000 0.50000 0.5033 0.5033 1.0000 2 +05 O 0.69431 0.50000 0.50000 0.5033 0.5033 1.0000 2 +06 O 0.00000 0.00000 0.30569 0.5033 0.5033 1.0000 1 +07 O 0.50000 0.00000 0.19431 0.5033 0.5033 1.0000 1 +08 Ru 0.00000 0.00000 0.00000 0.5033 0.5033 1.0000 1 +09 Ru 0.50000 0.50000 0.00000 0.5033 0.5033 1.0000 1 +10 O 0.19431 0.50000 0.00000 0.5033 0.5033 1.0000 1 +11 O 0.80569 0.50000 0.00000 0.5033 0.5033 1.0000 1 +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +BottomUnitCell TiO2interf +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 0.80458 0.3719 0.3719 1.0000 2 +01 O 0.00000 0.00000 0.69542 0.3719 0.3719 1.0000 2 +02 Ti 0.50000 0.00000 0.50000 0.3719 0.3719 1.0000 2 +03 Ti 0.00000 0.50000 0.50000 0.3719 0.3719 1.0000 2 +04 O 0.30458 0.50000 0.50000 0.3719 0.3719 1.0000 2 +05 O 0.69542 0.50000 0.50000 0.3719 0.3719 1.0000 2 +06 O 0.00000 0.00000 0.30458 0.3719 0.3719 1.0000 1 +07 O 0.50000 0.00000 0.19542 0.3719 0.3719 1.0000 1 +08 Ti 0.00000 0.00000 0.00000 0.3719 0.3719 1.0000 1 +09 Ti 0.50000 0.50000 0.00000 0.3719 0.3719 1.0000 1 +10 O 0.19542 0.50000 0.00000 0.3719 0.3719 1.0000 1 +11 O 0.80458 0.50000 0.00000 0.3719 0.3719 1.0000 1 +layerpos: 1.0 = 0.0, 2.0 = 0.5 +layer_behaviour: ignore + + +# UnitCell bulk +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 -0.19542 0.3719 0.3719 1.0000 2 +01 O 0.00000 0.00000 -0.30458 0.3719 0.3719 1.0000 2 +02 Ti 0.50000 0.00000 -0.50000 0.3719 0.3719 1.0000 2 +03 Ti 0.00000 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +04 O 0.30458 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +05 O 0.69542 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +06 O 0.00000 0.00000 -0.69542 0.3719 0.3719 1.0000 1 +07 O 0.50000 0.00000 -0.80458 0.3719 0.3719 1.0000 1 +08 Ti 0.00000 0.00000 -1.00000 0.3719 0.3719 1.0000 1 +09 Ti 0.50000 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +10 O 0.19542 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +11 O 0.80458 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +layerpos: 1.0 = -1.0, 2.0 = -0.5 +layer_behaviour: ignore diff --git a/orgui/datautils/xrayutils/unitcells/TiO2(100).xtal b/orgui/datautils/xrayutils/unitcells/TiO2(100).xtal new file mode 100644 index 0000000..0b93b76 --- /dev/null +++ b/orgui/datautils/xrayutils/unitcells/TiO2(100).xtal @@ -0,0 +1,21 @@ +E = 20.00000 keV + +# UnitCell bulk +return +Coherent 1.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 1.00000 0.00000 0.00000 0.00000 +6.5807 2.9692 6.5807 90.0000 90.0000 90.0000 +Name x/frac y/frac z/frac iDW oDW occup layerIdx +00 O 0.50000 0.00000 -0.19542 0.3719 0.3719 1.0000 2 +01 O 0.00000 0.00000 -0.30458 0.3719 0.3719 1.0000 2 +02 Ti 0.50000 0.00000 -0.50000 0.3719 0.3719 1.0000 2 +03 Ti 0.00000 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +04 O 0.30458 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +05 O 0.69542 0.50000 -0.50000 0.3719 0.3719 1.0000 2 +06 O 0.00000 0.00000 -0.69542 0.3719 0.3719 1.0000 1 +07 O 0.50000 0.00000 -0.80458 0.3719 0.3719 1.0000 1 +08 Ti 0.00000 0.00000 -1.00000 0.3719 0.3719 1.0000 1 +09 Ti 0.50000 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +10 O 0.19542 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +11 O 0.80458 0.50000 -1.00000 0.3719 0.3719 1.0000 1 +layerpos: 1.0 = -1.0, 2.0 = -0.5 +layer_behaviour: ignore \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 19ad146..ff2379b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = 'orGUI' dynamic = ['version',] license = "MIT" +license-files = ["LICENSE", "THIRD_PARTY_LICENSES/*"] requires-python = '>=3.10' readme = 'README.rst' description = 'orGUI: Orientation and Integration with 2D detectors' @@ -52,8 +53,8 @@ console = ['qtconsole'] speedup = ['numba'] extendedfilesupport = ['ase', 'hdf5plugin'] symmetry = ['pyxtal'] -full = ['qtconsole', 'ase', 'numba' , 'PyOpenGL', 'hdf5plugin', 'pyxtal'] -full-devel = ['qtconsole', 'ase', 'numba' , 'PyOpenGL', 'hdf5plugin', 'pyxtal', 'ruff', 'pyupgrade'] +full = ['qtconsole', 'ase', 'numba' , 'PyOpenGL', 'hdf5plugin', 'pyxtal', 'py3Dmol'] +full-devel = ['qtconsole', 'ase', 'numba' , 'PyOpenGL', 'hdf5plugin', 'pyxtal', 'py3Dmol', 'ruff', 'pyupgrade'] [build-system] requires = ["meson-python", "pybind11", "setuptools-scm>=8", "numpy"] diff --git a/requirements.txt b/requirements.txt index 5b65809..cdd56bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ qtconsole ase hdf5plugin PyOpenGL +py3Dmol diff --git a/subprojects/packagefiles/xxhash/meson.build b/subprojects/packagefiles/xxhash/meson.build new file mode 100644 index 0000000..1ff127a --- /dev/null +++ b/subprojects/packagefiles/xxhash/meson.build @@ -0,0 +1,19 @@ +project( + 'xxHash', + 'c', + version: '0.8.2', + license: 'BSD-2-Clause', +) + +xxhash = static_library( + 'xxhash', + 'xxhash.c', + install: false, +) + +xxhash_dep = declare_dependency( + link_with: xxhash, + include_directories: include_directories('.'), +) + +meson.override_dependency('libxxhash', xxhash_dep) diff --git a/subprojects/xxhash.wrap b/subprojects/xxhash.wrap new file mode 100644 index 0000000..0986175 --- /dev/null +++ b/subprojects/xxhash.wrap @@ -0,0 +1,9 @@ +[wrap-file] +directory = xxHash-0.8.2 +source_url = https://github.com/Cyan4973/xxHash/archive/refs/tags/v0.8.2.tar.gz +source_filename = xxhash-0.8.2.tar.gz +source_hash = baee0c6afd4f03165de7a4e67988d16f0f2b257b51d0e3cb91909302a26a79c4 +patch_directory = xxhash + +[provide] +dependency_names = libxxhash