diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef4a413..939caf02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,12 @@ new `rcN` heading when that rc is published to TestPyPI. - Save inside the review persists to the folder: Save writes the phase JPEG plus the aberration state into `saves/` (and downloads the JPEG), so saved states reappear with Load / download / delete on any relaunch - double-click or CLI. The bundled range server accepts writes only under `saves/`. - SSB reconstruction in the browser is 5.6x faster at full bright field: slider drags use a Fourier-domain BF sum with a single inverse FFT (the same `angle(mean(object))` estimator as the Python reference, corr 0.997), reaching ~50 FPS on a real 512x512x192x192 dataset at all 13137 BF pixels on an Apple-silicon laptop. Release commits keep the exact per-BF path for the loss readout. - Resident G(q,k) stores the Hermitian half-plane by default (bit-exact, 2x less GPU memory, faster) with an opt-in snorm16 quantized mode (4x), a GPU-memory clamp on the BF count so big scans cannot crash small GPUs, and acceptance of rfft half-plane calibrations from the CUDA backend. +- Fix ShowEDS spectrum line markers so major lines (Fe Ka, Cu Ka, Au La) keep markers across the full energy range. +- Harden ShowDiffraction analysis: calibration assignment and exactness, ring/ellipse/texture fit guards, zone-axis variants, stack frame provenance, per-axis canvas overlays, and hostile-input handling. +- Fix ShowDiffraction systematic absences for spinel, bixbyite, cuprite, rutile-type, and I41/amd phases: new structure-verified rules remove symmetry-forbidden lines (for example Fe3O4 200 and anatase 002) from predicted reflection lists. +- Improve ShowDiffraction phase matching: optimal line assignment replaces the greedy pass, missing-strong counts read n/a when a phase carries no intensities, full-circle radial wedges no longer collapse to zero width, and the ellipse fit's sector weighting is de-biased on noisy patterns. +- ShowDiffraction mobile/touch support: fluid layout at phone widths, pointer-event canvas interactions (tap, drag, two-finger pinch/pan, double-tap reset), and a dual-thumb contrast histogram slider that syncs traits once per gesture. +- ShowDiffraction phase library: pymatgen removed entirely (no more `phaseid` extra or CIF/Structure loaders); built-in lattice constants now carry per-entry source citations (NIST SRM / COD / primary literature). - Depend on `quantem.gpu[movie]>=0.0.1rc5` for the CUDA/MPS/CPU movie backend, keep migrated HDF5 and movie shims patch-compatible during the transition, and accept 3-axis `Show3DSlices(pixel_size=...)` tuples in release notebooks. - Keep display filtering consistent throughout review: Show3D reapplies denoise when a user scrubs to another frame, standalone Show2D/Show3D HTML paints denoised and frequency-filtered pixels on first load, and unlinked Show2D galleries show and edit each selected panel's own denoise mode, sigma, and bin without changing neighboring panels. - Add display-side denoise for sparse maps (EDS, low dose) to Show2D and Show3D: a Denoise menu (`none` / `gaussian` / `anscombe`, the count-respecting Anscombe smoother) with `denoise_sigma` and `denoise_bin` knobs, a `show_denoise` gate that keeps the controls row hidden until needed, and an always-on banner whenever a reduction is active. It is purely a view transform: the stored array, the stats row, and every export of raw data keep the original counts, and `none` is the lossless default. Show2D adds per-panel lists for raw-vs-denoised A/B galleries and runs the filter through a browser-side WebGPU pipeline, so exported HTML denoises without a kernel. Replaces the earlier `display_filter` / `display_sigma` / `spatial_bin` kwargs, which stay accepted as aliases for one release. diff --git a/docs/api/showdiffraction.md b/docs/api/showdiffraction.md index ec7183ed..7d17640d 100644 --- a/docs/api/showdiffraction.md +++ b/docs/api/showdiffraction.md @@ -2,10 +2,32 @@ Interactive d-spacing analysis for a single 2D diffraction pattern or a 3D stack (tilt/time series). Find the beam center, pick Bragg spots and rings, -read calibrated d-spacings, and calibrate k-space from a known reflection. For -full 4D-STEM datasets, use [Show4DSTEM](show4dstem). See the +read calibrated d-spacings, and calibrate k-space from a known reflection. See the [ShowDiffraction tutorial](../tutorials/showdiffraction) for a worked example. +The primary phase workflow is candidate verification: you usually know which +phases to expect, so build them (`library_phase`, `Phase.from_cubic`, +`Phase.from_dspacings`, or custom phases in the Phase menu) and rank only +those with `identify_phase(database)` or the *candidates only* switch. +`search_phases()` against the built-in library is the fallback for when you +have no candidates in mind; narrow it with an element filter. + +Reference data sources: every lattice parameter in the built-in phase library +(`PHASE_LIBRARY` in `showdiffraction.py`) is a room-temperature value taken from a +license-clean source cited next to the entry — NIST SRM certificates and NBS +circulars/monographs (US public domain), the Crystallography Open Database +(CC0), or the primary literature. No values come from proprietary compilations +such as the ICDD PDF or Pearson's Handbook. + +The lattice-based phase model enumerates d-spacings and geometric systematic +absences. It does not calculate structure-factor intensities, thermal effects, +or dynamical diffraction; use `Phase.from_dspacings` when matching against a +measured or literature line table with intensities. + +Calibrated radial axes are reported as `g = 1/d` in `1/Å`; the legacy +`radial_profile(units="q")` spelling is an alias for `units="g"`, not a +`2πg` scattering-vector axis. + ## Viewer UI `ShowDiffraction` supports the shared `ui_mode`, `show_title`, @@ -31,19 +53,44 @@ no console error, no NaN frame). | Colormap dropdown | `dp_colormap` | Pattern recolors to the chosen map | | Scale mode dropdown | `dp_scale_mode` | Intensity mapped linear / log / sqrt | | Invert toggle | `dp_invert` | Colormap reversed | -| Contrast min / max sliders | `dp_vmin_pct`, `dp_vmax_pct` | Display clamp changes; histogram markers move | +| Contrast histogram (dual-thumb slider) | `dp_vmin_pct`, `dp_vmax_pct` | Drag either thumb (mouse or touch) for a live preview; traits update once on release | | Center mode dropdown | `center_mode` | `auto` re-detects the BF disk; `manual` enables click-to-set | | Click to set center (manual) | `center_row`, `center_col` | Crosshair moves; spot d-spacings recompute | -| Detect spots | `_detect_spots_request`, `spots` | Auto-finds Bragg peaks; d-spacing table fills | +| Detect spots | `_detect_spots_request`, `spots` | Auto-finds every isolated peak with contrast at least 10% of the strongest (`min_relative`); no count cap | | Add / remove spot (click) | `_spot_add_request`, `_spot_remove_request`, `spots` | Marker placed/removed; d-spacing updates | -| Snap toggle | `snap_enabled`, `snap_radius` | New spots snap to the nearest local maximum | -| Refine toggle | `spot_refine` | Sub-pixel Gaussian fit on/off | -| Detect rings | `_detect_rings_request`, `rings` | Auto-finds Debye–Scherrer rings | +| Move spot (Move + drag) | `_spot_move_request`, `spots` | Re-picks the spot at the drop position; stale hkl clears | +| Spot pick dropdown | `spot_refine`, `snap_enabled`, `snap_radius` | Clicked spots are Gaussian-fitted, snapped to the local maximum, or kept exactly as clicked | +| Detect rings | `_detect_rings_request`, `rings` | Auto-finds all Debye–Scherrer rings above the profile prominence threshold; no count cap | | Add / remove ring | `_ring_add_request`, `_ring_remove_request`, `rings` | Ring overlay; ring d-spacing updates | | Calibrate from spot / ring | `_calibrate_from_spot_request`, `_calibrate_from_ring_request`, `k_pixel_size` | Sets k-space pixel size from a known d | +| Auto button | `_auto_request`, `analysis_status` | Runs center, rings, calibration, fit, and indexing in one pass; status reports failed steps only | +| Phase menu | `phase_name`, `custom_phases` | Selects a library or custom phase for calibration and indexing; custom entries take a full lattice (a, b, c, α, β, γ) and absence rule | +| Identify candidates only | `identify_custom_only` | Identify ranks only custom phases, skipping the library | +| Calibrate from phase | `_calibrate_phase_request`, `calibration_rms_px` | Fits k-space sampling from ring-to-reflection assignment | +| Index rings / spots | `_index_rings_request`, `_index_spots_request`, `zone_axis` | Fills hkl labels; spot indexing also solves the zone axis | +| Exclude menu | `mask_regions` | Edits wedge/disk regions excluded from analysis | +| Draw excluded disk / wedge (drag) | `mask_regions` | Drag on the pattern to add an excluded region with live preview | +| Mask view toggle | `show_mask` | Shows or hides excluded-region overlays | +| Fit rings | `_fit_rings_request` | Refines ring radius and width; fwhm column appears | +| Fit ellipse / use correction | `_fit_ellipse_request`, `ellipse_corrected` | Measures distortion; the switch circularizes radii | +| Profile panel | `show_profile`, `profile_log`, `profile_subtract_background`, `_profile_data` | Radial profile with ring markers; click adds a ring | +| Azimuthal panel | `show_azimuthal`, `_azimuthal_data` | Intensity vs azimuth around the outermost ring | +| hkl toggle | `show_hkl` | Shows or hides hkl labels on spots and rings | +| Stats toggle | `show_stats` | Shows or hides the pattern-statistics readout | +| Undo / clear spots and rings | `_spot_undo_request`, `_spot_clear_request`, `_ring_undo_request`, `_ring_clear_request` | Removes the last or all markers (Ctrl+Z also undoes) | +| Center view | view transform | Recenters and zooms the view to the diffraction center | +| Spot / ring CSV and JSON | measurement tables | Downloads the visible measurement table rows | +| Refine method dropdown | `refine_method` | Picks the center-refinement algorithm (auto / symmetry / phase_corr) | +| Refine center | `_refine_center_request`, `center_method` | Refines the center; records the method used | +| Merge frames (3D) | `_merge_request` | Align + merge frames; appends the combined pattern | +| Element filter box | `identify_elements` | Restricts the phase search to these elements (e.g. `Fe,O`) | +| Identify phases | `_identify_request`, `_identify_results` | Ranked candidate phases with per-line match tables | +| Quality panel | `_quality` | Analysis-quality snapshot: center, calibration, ellipse, ring fits, mask coverage | +| Ring click in profile | `selected_ring_id` | Highlights the picked ring marker (0 clears) | | Frame slider (3D) | `frame_idx` | Scrubs to a different pattern in the stack | | Pan (drag) / zoom (wheel) | view transform | Pattern translates / zooms about the cursor | -| Export → HTML | `export_request`, `export_payload` | Writes a standalone HTML viewer | +| Touch: two-finger pinch / drag, double-tap | view transform | Pinch zooms about the fingers, two-finger drag pans, double-tap resets the view | +| Export → PNG / HTML | `export_request`, `export_payload` | Saves the current view as a PNG image or a standalone HTML viewer | ```{seealso} The shared HTML-export contract is documented in [html-export](html-export). diff --git a/docs/tutorials/showdiffraction.ipynb b/docs/tutorials/showdiffraction.ipynb index 1a62a60a..91b640ce 100644 --- a/docs/tutorials/showdiffraction.ipynb +++ b/docs/tutorials/showdiffraction.ipynb @@ -7,7 +7,9 @@ "source": [ "# ShowDiffraction\n", "\n", - "[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/electronmicroscopy/quantem.widget/blob/main/docs/tutorials/showdiffraction.ipynb)" + "[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/electronmicroscopy/quantem.widget/blob/main/docs/tutorials/showdiffraction.ipynb)\n", + "\n", + "`ShowDiffraction` is an interactive d-spacing analyzer for 2D diffraction patterns and 3D pattern stacks. It accepts a NumPy array, a PyTorch tensor, or a quantem `Dataset`. Auto runs center refinement, ring detection, phase calibration, fitting, and indexing, and Phase selects a library or custom phase." ] }, { @@ -16,24 +18,25 @@ "id": "2c5e1b7e", "metadata": { "execution": { - "iopub.execute_input": "2026-07-03T20:56:15.252061Z", - "iopub.status.busy": "2026-07-03T20:56:15.252005Z", - "iopub.status.idle": "2026-07-03T20:56:17.667582Z", - "shell.execute_reply": "2026-07-03T20:56:17.667210Z" + "iopub.execute_input": "2026-07-10T21:00:32.536002Z", + "iopub.status.busy": "2026-07-10T21:00:32.535831Z", + "iopub.status.idle": "2026-07-10T21:00:41.746558Z", + "shell.execute_reply": "2026-07-10T21:00:41.744779Z" + }, + "mystnb": { + "code_prompt_hide": "Hide synthetic data generation code", + "code_prompt_show": "Show synthetic data generation code" }, "tags": [ "hide-input" - ], - "mystnb": { - "code_prompt_show": "Show synthetic data generation code", - "code_prompt_hide": "Hide synthetic data generation code" - } + ] }, "outputs": [], "source": [ "import numpy as np\n", "\n", "from quantem.widget import ShowDiffraction\n", + "from quantem.widget import Phase\n", "\n", "rng = np.random.default_rng(0)\n", "size = 256\n", @@ -52,7 +55,7 @@ " spot_col = center + (h * sin_a + k * cos_a) * spacing_px\n", " amplitude = 6.0 if h == 0 and k == 0 else 1.0 / (1 + 0.4 * (h * h + k * k))\n", " pattern += amplitude * np.exp(-((rows - spot_row) ** 2 + (cols - spot_col) ** 2) / 8.0)\n", - " return pattern\n", + " return rng.poisson(1000.0 * (pattern + 0.05)).astype(np.float32) # shot noise\n", "\n", "\n", "single_crystal = bragg_lattice()\n", @@ -62,7 +65,24 @@ "polycrystalline = 3.0 * np.exp(-(radius ** 2) / 32.0)\n", "for ring_radius in ring_radii_px:\n", " polycrystalline += 2.0 * np.exp(-((radius - ring_radius) ** 2) / 9.68)\n", - "polycrystalline = (polycrystalline + 0.01 * rng.random((size, size))).astype(np.float32)" + "polycrystalline = rng.poisson(100.0 * (polycrystalline + 0.05)).astype(np.float32)\n", + "\n", + "size_m = 512\n", + "center_m = (size_m - 1) / 2\n", + "m_rows, m_cols = np.mgrid[0:size_m, 0:size_m]\n", + "m_radius = np.hypot(m_rows - center_m, m_cols - center_m)\n", + "magnetite_pattern = 4.0 * np.exp(-(m_radius ** 2) / 80.0)\n", + "k_synth = 0.004\n", + "for d_ref, strength, width in [\n", + " (2.967, 1.7, 3.0),\n", + " (2.532, 1.3, 3.5),\n", + " (2.099, 1.0, 4.0),\n", + " (1.715, 0.8, 4.5),\n", + " (1.485, 0.55, 5.0),\n", + "]:\n", + " ring_radius = 1.0 / (d_ref * k_synth)\n", + " magnetite_pattern += strength * np.exp(-((m_radius - ring_radius) ** 2) / (2 * width ** 2))\n", + "magnetite_pattern = rng.poisson(100.0 * (magnetite_pattern + 0.05)).astype(np.float32)" ] }, { @@ -70,7 +90,9 @@ "id": "5b97f23b", "metadata": {}, "source": [ - "## Single-crystal SAED" + "## Single-crystal spots\n", + "\n", + "Use Spots or click reflections. Add a custom cubic phase, then Index Spots to fill hkl and zone axis." ] }, { @@ -79,17 +101,17 @@ "id": "4d42c109", "metadata": { "execution": { - "iopub.execute_input": "2026-07-03T20:56:17.669159Z", - "iopub.status.busy": "2026-07-03T20:56:17.668965Z", - "iopub.status.idle": "2026-07-03T20:56:17.712620Z", - "shell.execute_reply": "2026-07-03T20:56:17.712433Z" + "iopub.execute_input": "2026-07-10T21:00:41.753072Z", + "iopub.status.busy": "2026-07-10T21:00:41.752363Z", + "iopub.status.idle": "2026-07-10T21:00:41.832831Z", + "shell.execute_reply": "2026-07-10T21:00:41.831987Z" } }, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2913e04e7c6d4df4998fe46ca9c615fe", + "model_id": "3f99cfde6a7c41808dfc0bfaad42873d", "version_major": 2, "version_minor": 1 }, @@ -102,14 +124,32 @@ "output_type": "execute_result" } ], - "source": "saed = ShowDiffraction(\n single_crystal,\n center=(center, center),\n bf_radius=14,\n k_pixel_size=0.018,\n title=\"Single-crystal SAED\",\n \n verbose=False,\n panel_width_px=530,\n)\nsaed.detect_spots(max_spots=12)\nsaed" + "source": [ + "saed = ShowDiffraction(\n", + " single_crystal,\n", + " center=(center, center),\n", + " bf_radius=14,\n", + " k_pixel_size=0.018,\n", + " title=\"Single-crystal SAED\",\n", + " offline=True,\n", + " verbose=False,\n", + " panel_width_px=530,\n", + ")\n", + "saed.detect_spots(max_spots=12)\n", + "saed.custom_phases = [{\"name\": \"Cubic\", \"a\": 1.984, \"absences\": \"none\"}]\n", + "saed.phase_name = \"Cubic\"\n", + "saed.index_spots(Phase.from_cubic(\"Cubic\", 1.984, absences=\"none\"))\n", + "saed" + ] }, { "cell_type": "markdown", "id": "256f3719", "metadata": {}, "source": [ - "## Polycrystalline rings" + "## Polycrystalline rings\n", + "\n", + "Pick a phase, then press Auto. Profile, Azim, Mask View, and Quality in the side menu show the radial profile, azimuthal intensity, excluded-region overlay, and quality checks. Fit refines ring radius and width, Fit Ellipse measures distortion, and Identify ranks phase candidates; use element filters when chemistry is known." ] }, { @@ -118,22 +158,22 @@ "id": "87cd6240", "metadata": { "execution": { - "iopub.execute_input": "2026-07-03T20:56:17.713778Z", - "iopub.status.busy": "2026-07-03T20:56:17.713714Z", - "iopub.status.idle": "2026-07-03T20:56:17.836354Z", - "shell.execute_reply": "2026-07-03T20:56:17.836140Z" + "iopub.execute_input": "2026-07-10T21:00:41.838284Z", + "iopub.status.busy": "2026-07-10T21:00:41.838064Z", + "iopub.status.idle": "2026-07-10T21:00:42.276484Z", + "shell.execute_reply": "2026-07-10T21:00:42.275755Z" } }, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c046428864314175a14f701e2bf1c880", + "model_id": "6c7b8458618943938f97d3fd7ed2d830", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "ShowDiffraction(shape=(1, 256, 256), sampling=(1.0 Å, 0.012631480808671821 1/Å), frame=0/1, title='Polycrystalline')" + "ShowDiffraction(shape=(1, 512, 512), sampling=(1.0 Å, 0.004000635020534425 1/Å), frame=0/1, title='Magnetite-like rings')" ] }, "execution_count": 3, @@ -141,139 +181,199 @@ "output_type": "execute_result" } ], - "source": "powder = ShowDiffraction(polycrystalline, title=\"Polycrystalline\", verbose=False, panel_width_px=530)\npowder.detect_rings(max_rings=4)\npowder.calibrate_from_ring(powder.rings[0][\"radius_px\"], d_known=2.355)\npowder" - }, - { - "cell_type": "markdown", - "id": "d1ce8551", - "metadata": {}, "source": [ - "## Tilt series" + "magnetite = ShowDiffraction(\n", + " magnetite_pattern,\n", + " title=\"Magnetite-like rings\",\n", + " offline=True,\n", + " verbose=False,\n", + " panel_width_px=530,\n", + ")\n", + "magnetite.phase_name = \"Fe3O4\"\n", + "magnetite.run_auto(max_rings=5)\n", + "magnetite.dp_colormap = \"viridis\"\n", + "magnetite\n" ] }, { "cell_type": "code", - "execution_count": 4, - "id": "b6292d68", + "execution_count": 5, + "id": "real-identify-code", "metadata": { "execution": { - "iopub.execute_input": "2026-07-03T20:56:17.837374Z", - "iopub.status.busy": "2026-07-03T20:56:17.837310Z", - "iopub.status.idle": "2026-07-03T20:56:17.843980Z", - "shell.execute_reply": "2026-07-03T20:56:17.843788Z" + "iopub.execute_input": "2026-07-10T21:00:42.990802Z", + "iopub.status.busy": "2026-07-10T21:00:42.990589Z", + "iopub.status.idle": "2026-07-10T21:00:43.248872Z", + "shell.execute_reply": "2026-07-10T21:00:43.247450Z" } }, "outputs": [ { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "95f2e2c1063540eb9b05508280051369", - "version_major": 2, - "version_minor": 1 - }, - "text/plain": [ - "ShowDiffraction(shape=(4, 256, 256), sampling=(1.0 Å, 0.018 1/Å), frame=0/4, title='Tilt series')" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "Fe3O4: 5/5 lines, mean Δd 0.04%, missing strong None\n", + "γ-Fe2O3: 5/5 lines, mean Δd 0.57%, missing strong None\n", + "α-Fe2O3 (hematite): 4/5 lines, mean Δd 0.70%, missing strong None\n", + "α-Fe: 0/5 lines, mean Δd n/a, missing strong None\n", + "Fe3O4\n" + ] } ], - "source": "tilt = ShowDiffraction(\n tilt_series,\n center=(center, center),\n bf_radius=14,\n k_pixel_size=0.018,\n title=\"Tilt series\",\n \n verbose=False,\n panel_width_px=530,\n)\ntilt" + "source": [ + "from quantem.widget import library_phase\n", + "\n", + "expected = [library_phase(n) for n in (\"Fe3O4\", \"γ-Fe2O3\", \"α-Fe2O3 (hematite)\", \"α-Fe\")]\n", + "verified = magnetite.identify_phase(expected)\n", + "\n", + "for candidate in verified:\n", + " mean_err = candidate[\"mean_err\"]\n", + " error_text = \"n/a\" if mean_err is None else f\"{100 * mean_err:.2f}%\"\n", + " print(\n", + " f\"{candidate['name']}: {candidate['matched']}/{candidate['n_obs']} lines, \"\n", + " f\"mean Δd {error_text}, \"\n", + " f\"missing strong {candidate['n_missing_strong']}\"\n", + " )\n", + "\n", + "magnetite.identify_elements = \"Fe, O\"\n", + "candidates = magnetite.search_phases()\n", + "print(candidates[0][\"name\"])" + ] }, { "cell_type": "markdown", - "id": "afb10839", + "id": "8afdef01", "metadata": {}, "source": [ - "## Magnetite-like rings\n", - "\n", - "Generate a magnetite-style ring pattern in the notebook so the tutorial stays self-contained." + "## ShowEDS" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, + "id": "0dfac7d4", "metadata": { + "mystnb": { + "code_prompt_hide": "Hide synthetic data generation code", + "code_prompt_show": "Show synthetic data generation code" + }, "tags": [ "hide-input" - ], - "mystnb": { - "code_prompt_show": "Show synthetic data generation code", - "code_prompt_hide": "Hide synthetic data generation code" - } + ] }, "outputs": [], "source": [ - "# Synthetic magnetite-like SAED pattern: central beam + Debye-Scherrer rings.\n", - "size_m = 512\n", - "center_m = (size_m - 1) / 2\n", - "m_rows, m_cols = np.mgrid[0:size_m, 0:size_m]\n", - "m_radius = np.hypot(m_rows - center_m, m_cols - center_m)\n", - "magnetite_pattern = 4.0 * np.exp(-(m_radius ** 2) / 80.0)\n", - "for ring_radius, strength, width in [\n", - " (70.0, 1.7, 6.0),\n", - " (113.0, 1.3, 7.0),\n", - " (140.0, 1.0, 8.0),\n", - " (184.0, 0.8, 9.0),\n", - " (226.0, 0.55, 10.0),\n", - "]:\n", - " magnetite_pattern += strength * np.exp(-((m_radius - ring_radius) ** 2) / (2 * width ** 2))\n", - "magnetite_pattern += 0.035 * rng.random((size_m, size_m))\n", - "magnetite_pattern = magnetite_pattern.astype(np.float32)" - ], - "id": "be3e6c93" + "from quantem.widget import ShowEDS\n", + "from quantem.widget.datasets import show2d_gold\n", + "\n", + "rng2 = np.random.default_rng(3)\n", + "energy = np.linspace(0.1, 12.0, 600, dtype=np.float32)\n", + "\n", + "\n", + "def eds_peaks(line_list):\n", + " # detector-broadened lines, 130 eV FWHM at Mn Ka\n", + " s = np.zeros_like(energy)\n", + " for e0, amp in line_list:\n", + " fwhm = np.sqrt(max(2500.0 * (e0 - 5.899) + 130.0**2, 60.0**2))\n", + " s += amp * np.exp(-((energy - e0) ** 2) / (2 * (fwhm / 2355.0) ** 2))\n", + " return s\n", + "\n", + "\n", + "particle_lines = eds_peaks([(6.404, 3.0), (7.058, 0.42), (0.705, 1.4), (0.525, 5.0)])\n", + "support_lines = eds_peaks([(0.277, 2.2), (0.525, 0.3)])\n", + "grid_lines = eds_peaks([(8.041, 0.9), (8.905, 0.13), (0.930, 0.3)])\n", + "brems = 0.5 * (1 - np.exp(-((energy / 0.45) ** 2))) * np.exp(-energy / 5.0)\n", + "\n", + "# real HAADF, mean-binned to a normalized mass-thickness map\n", + "haadf = show2d_gold(size=\"small\", verbose=False).array\n", + "mass = haadf.reshape(64, 8, 64, 8).mean(axis=(1, 3))\n", + "mass = (mass - mass.min()) / (mass.max() - mass.min())\n", + "\n", + "eds_cube = rng2.poisson(\n", + " 120.0 * (mass[..., None] * (particle_lines + 0.6 * brems)\n", + " + (support_lines + grid_lines + 0.4 * brems)[None, None, :])\n", + ").astype(np.uint16)" + ] }, { "cell_type": "code", - "execution_count": 5, - "id": "403a2c07", - "metadata": { - "execution": { - "iopub.execute_input": "2026-07-03T20:56:17.846490Z", - "iopub.status.busy": "2026-07-03T20:56:17.846410Z", - "iopub.status.idle": "2026-07-03T20:56:17.963342Z", - "shell.execute_reply": "2026-07-03T20:56:17.963137Z" - } - }, + "execution_count": 7, + "id": "b13d7af1", + "metadata": {}, "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Candidates: Cu, Fe, Pd, Cs, Tb, Tm, C, O\n", + "Top phase: Fe3O4\n" + ] + }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "1d0ec13e28d244888453622ce0d122e8", + "model_id": "a2722d2a97874153ab358205d560d1a7", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "ShowDiffraction(shape=(1, 512, 512), sampling=(1.0 Å, 0.005672910439341243 1/Å), frame=0/1, title='Magnetite-like rings')" + "" ] }, - "execution_count": 5, "metadata": {}, - "output_type": "execute_result" + "output_type": "display_data" + }, + { + "data": { + "image/jpeg": "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", + "text/html": [ + "\"ShowEDS" + ], + "text/plain": [ + "ShowEDS static render (for saved-notebook viewing)" + ] + }, + "metadata": { + "quantem.widget": { + "static_fallback": true + } + }, + "output_type": "display_data" } ], - "source": "magnetite = ShowDiffraction(magnetite_pattern, title=\"Magnetite-like rings\", verbose=False, panel_width_px=530)\nmagnetite.auto_detect_center()\nmagnetite.detect_rings(max_rings=5, exclude_radius=40)\ninner_ring = min(magnetite.rings, key=lambda ring: ring[\"radius_px\"])\nmagnetite.calibrate_from_ring(inner_ring[\"radius_px\"], d_known=2.532)\nmagnetite.dp_colormap = \"viridis\"\nmagnetite" + "source": [ + "eds = ShowEDS(eds_cube, energy, title=\"Fe3O4 nanoparticles EDS (synthetic)\", verbose=False)\n", + "detected = [c[\"element\"] for c in eds.detect_elements(max_candidates=8)]\n", + "magnetite.identify_elements = \", \".join(detected)\n", + "print(\"Candidates:\", \", \".join(detected))\n", + "print(\"Top phase:\", magnetite.search_phases()[0][\"name\"])\n", + "eds" + ] }, { "cell_type": "markdown", "id": "sddsave01", "metadata": {}, "source": [ - "### Save the analysis" + "### Save" ] }, + { + "cell_type": "markdown", + "id": "94ec48fa", + "metadata": {}, + "source": [] + }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, "id": "sddsave02", "metadata": { "execution": { - "iopub.execute_input": "2026-07-03T20:56:17.964259Z", - "iopub.status.busy": "2026-07-03T20:56:17.964184Z", - "iopub.status.idle": "2026-07-03T20:56:17.971818Z", - "shell.execute_reply": "2026-07-03T20:56:17.971577Z" + "iopub.execute_input": "2026-07-10T21:00:43.252265Z", + "iopub.status.busy": "2026-07-10T21:00:43.251813Z", + "iopub.status.idle": "2026-07-10T21:00:43.312117Z", + "shell.execute_reply": "2026-07-10T21:00:43.311084Z" } }, "outputs": [ @@ -284,60 +384,22 @@ "Magnetite-like rings\n", "════════════════════════════════\n", "Frames: 1 (showing #0)\n", - "Detector: 512×512 (0.0057 1/Å/px)\n", - "Calib: from_ring (d=2.532 Å @ r=69.6 px)\n", - "Center: (255.5, 255.6) BF r=11.9 px\n", + "Detector: 512×512 (0.0040 1/Å/px)\n", + "Calibration: phase (rms 0.06 px)\n", + "Center: (255.5, 255.5) BF r=13.1 px\n", "Spots: 0\n", "Rings: 5\n", + "Phase: Fe3O4 (spinel): 5/5 matched, 0.0% mean error\n", "Display: viridis | log\n" ] }, { "data": { "text/plain": [ - "[{'id': 1,\n", - " 'kind': 'ring',\n", - " 'raw_row': None,\n", - " 'raw_col': None,\n", - " 'row': None,\n", - " 'col': None,\n", - " 'row_err': None,\n", - " 'col_err': None,\n", - " 'r_pixels': 69.61941528320312,\n", - " 'r_pixels_err': None,\n", - " 'g_inv_angstrom': 0.3949447077409163,\n", - " 'g_inv_angstrom_err': None,\n", - " 'd_angstrom': 2.532,\n", - " 'd_angstrom_err': None,\n", - " 'angle_deg': None,\n", - " 'angle_deg_err': None,\n", - " 'intensity': 1.7121703624725342,\n", - " 'fit_quality': None,\n", - " 'hkl': '',\n", - " 'note': ''},\n", - " {'id': 2,\n", - " 'kind': 'ring',\n", - " 'raw_row': None,\n", - " 'raw_col': None,\n", - " 'row': None,\n", - " 'col': None,\n", - " 'row_err': None,\n", - " 'col_err': None,\n", - " 'r_pixels': 112.69329833984375,\n", - " 'r_pixels_err': None,\n", - " 'g_inv_angstrom': 0.6392989885958967,\n", - " 'g_inv_angstrom_err': None,\n", - " 'd_angstrom': 1.5642133302859074,\n", - " 'd_angstrom_err': None,\n", - " 'angle_deg': None,\n", - " 'angle_deg_err': None,\n", - " 'intensity': 1.317672848701477,\n", - " 'fit_quality': None,\n", - " 'hkl': '',\n", - " 'note': ''}]" + "'showdiffraction_saed.html'" ] }, - "execution_count": 6, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -346,37 +408,11 @@ "magnetite.summary()\n", "magnetite.save(\"magnetite_state.json\")\n", "\n", - "ShowDiffraction.measurements_from_state(\"magnetite_state.json\")[:2]" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "80f147a3", - "metadata": { - "execution": { - "iopub.execute_input": "2026-07-03T20:56:17.972696Z", - "iopub.status.busy": "2026-07-03T20:56:17.972636Z", - "iopub.status.idle": "2026-07-03T20:56:17.999295Z", - "shell.execute_reply": "2026-07-03T20:56:17.999061Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "'showdiffraction_saed.html'" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Optional\n", + "ShowDiffraction.measurements_from_state(\"magnetite_state.json\")[:2]\n", + "\n", + "# HTML export\n", "export_path = saed.export_html(\"showdiffraction_saed.html\", title=\"Single-crystal SAED\")\n", - "export_path.name" + "export_path.name\n" ] } ], @@ -396,7 +432,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.11" + "version": "3.14.6" } }, "nbformat": 4, diff --git a/js/showdiffraction/index.tsx b/js/showdiffraction/index.tsx index 3cacac81..823519b7 100644 --- a/js/showdiffraction/index.tsx +++ b/js/showdiffraction/index.tsx @@ -1,10 +1,4 @@ -/** - * ShowDiffraction — interactive d-spacing analysis for a single 2D/3D diffraction pattern. - * - * Lean single-panel viewer: one diffraction-pattern (DP) canvas with colormap, - * scale mode, contrast, center/BF disk, spots, rings, calibration and a frame - * slider to scrub a 3D stack. - */ +/** ShowDiffraction frontend. */ import * as React from "react"; import { createRender, useModel, useModelState } from "@anywidget/react"; @@ -24,15 +18,25 @@ import { extractBytes, extractFloat32, formatNumber, downloadBlob, preserveResto import { computeHistogramFromBytes, findDataRange, sliderRange, applyLogScaleInPlace } from "../stats"; import { COLORMAPS, COLORMAP_NAMES, applyColormap } from "../colormaps"; import { MetadataSection } from "../widgetInfo"; +import { + dataAngleToScreen, + dataColToScreenX, + dataRowToScreenY, + frameStats, + screenToData, + staleFrameNote, + viewTransform, +} from "./overlayGeometry"; +import { buildMeasurementRecords, measurementCsv, measurementMetadata } from "./measurements"; -// ============================================================================ // Style tokens -// ============================================================================ const MIN_ZOOM = 0.5; const MAX_ZOOM = 10; const DPR = window.devicePixelRatio || 1; const CANVAS_MIN = 384; +const PROFILE_H = 140; +const PROFILE_PAD = { left: 54, right: 14, top: 16, bottom: 34 }; const SPACING = { XS: 4, SM: 8, MD: 12, LG: 16 } as const; const typography = { label: { fontSize: 11 }, @@ -67,11 +71,15 @@ const compactButton = { const upwardMenuProps = { anchorOrigin: { vertical: "top" as const, horizontal: "left" as const }, transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, + sx: { zIndex: 9999 }, +}; +const downwardMenuProps = { + anchorOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, + transformOrigin: { vertical: "top" as const, horizontal: "left" as const }, + sx: { zIndex: 9999 }, }; -// ============================================================================ -// Info tooltip + keyboard shortcuts -// ============================================================================ +// Info tooltip function InfoTooltip({ text, theme = "dark" }: { text: React.ReactNode; theme?: "light" | "dark" }) { const isDark = theme === "dark"; @@ -129,84 +137,267 @@ function KeyboardShortcuts({ items }: { items: [string, string][] }) { ); } -// ============================================================================ -// Contrast histogram with draggable min/max handles -// ============================================================================ +function calibrationSourceLabel(source: string): string { + const labels: Record = { + from_phase: "phase", + from_ring: "ring", + from_spot: "spot", + manual: "manual", + metadata: "metadata", + }; + return labels[source] ?? source; +} + +// Mobile viewport +function useMobileViewport(): boolean { + const getIsMobile = React.useCallback(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia("(pointer: coarse)").matches || window.matchMedia("(max-width: 768px)").matches; + }, []); + const [isMobile, setIsMobile] = React.useState(getIsMobile); + + React.useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + const coarsePointer = window.matchMedia("(pointer: coarse)"); + const narrowViewport = window.matchMedia("(max-width: 768px)"); + const update = () => setIsMobile(getIsMobile()); + const addQueryListener = (query: MediaQueryList) => { + if (typeof query.addEventListener === "function") query.addEventListener("change", update); + else query.addListener(update); + }; + const removeQueryListener = (query: MediaQueryList) => { + if (typeof query.removeEventListener === "function") query.removeEventListener("change", update); + else query.removeListener(update); + }; + update(); + addQueryListener(coarsePointer); + addQueryListener(narrowViewport); + window.addEventListener("resize", update); + return () => { + removeQueryListener(coarsePointer); + removeQueryListener(narrowViewport); + window.removeEventListener("resize", update); + }; + }, [getIsMobile]); + + return isMobile; +} + +// Contrast histogram interface HistogramProps { data: Float32Array | null; vminPct: number; vmaxPct: number; onRangeChange: (min: number, max: number) => void; + onRangePreview?: (min: number, max: number) => void; + onRangeCommit?: (min: number, max: number) => void; width?: number; height?: number; theme?: "light" | "dark"; + dataMin?: number; + dataMax?: number; } -function Histogram({ data, vminPct, vmaxPct, onRangeChange, width = 110, height = 50, theme = "dark" }: HistogramProps) { +function Histogram({ data, vminPct, vmaxPct, onRangeChange, onRangePreview, onRangeCommit, width = 110, height = 50, theme = "dark", dataMin = 0, dataMax = 1 }: HistogramProps) { const canvasRef = React.useRef(null); - const bins = React.useMemo(() => data ? computeHistogramFromBytes(data) : null, [data]); - const draggingRef = React.useRef<"left" | "right" | null>(null); + const sliderRef = React.useRef(null); + const minLabelRef = React.useRef(null); + const maxLabelRef = React.useRef(null); + const onRangeChangeRef = React.useRef(onRangeChange); + const onRangePreviewRef = React.useRef(onRangePreview); + const onRangeCommitRef = React.useRef(onRangeCommit); + const pendingRangeRef = React.useRef<[number, number] | null>(null); + const rangeRafRef = React.useRef(null); + const [liveRange, setLiveRange] = React.useState<[number, number]>([vminPct, vmaxPct]); + React.useEffect(() => { setLiveRange([vminPct, vmaxPct]); }, [vminPct, vmaxPct]); + const [liveVminPct, liveVmaxPct] = liveRange; + const bins = React.useMemo(() => data ? computeHistogramFromBytes(data) : new Array(256).fill(0), [data]); const isDark = theme === "dark"; + const colors = isDark + ? { bg: "#1a1a2e", barActive: "#888", barInactive: "#444", border: "#333" } + : { bg: "#f0f0f0", barActive: "#666", barInactive: "#bbb", border: "#ccc" }; - React.useEffect(() => { + const formatValue = React.useCallback((pct: number) => { + const val = dataMin + (pct / 100) * (dataMax - dataMin); + return val >= 1000 ? val.toExponential(1) : val.toFixed(1); + }, [dataMax, dataMin]); + + const drawHistogram = React.useCallback((loPct: number, hiPct: number) => { const canvas = canvasRef.current; - if (!canvas || !bins) return; + if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = isDark ? "#1a1a2e" : "#f0f0f0"; + canvas.width = width * DPR; + canvas.height = height * DPR; + ctx.scale(DPR, DPR); + ctx.fillStyle = colors.bg; ctx.fillRect(0, 0, width, height); - const maxBin = Math.max(...Array.from(bins)); - if (maxBin > 0) { - ctx.fillStyle = isDark ? "#555" : "#999"; - for (let i = 0; i < bins.length; i++) { - const x = (i / bins.length) * width; - const bw = width / bins.length; - const bh = (bins[i] / maxBin) * height; - ctx.fillRect(x, height - bh, bw, bh); - } + const displayBins = 64; + const binRatio = Math.floor(bins.length / displayBins); + const reducedBins: number[] = []; + for (let i = 0; i < displayBins; i++) { + let sum = 0; + for (let j = 0; j < binRatio; j++) sum += bins[i * binRatio + j] || 0; + reducedBins.push(sum / binRatio); } - const lx = (vminPct / 100) * width; - const rx = (vmaxPct / 100) * width; - ctx.fillStyle = isDark ? "rgba(0,0,0,0.5)" : "rgba(0,0,0,0.2)"; - ctx.fillRect(0, 0, lx, height); - ctx.fillRect(rx, 0, width - rx, height); - ctx.fillStyle = isDark ? "#4fc3f7" : "#1976d2"; - ctx.fillRect(lx - 1, 0, 3, height); - ctx.fillRect(rx - 1, 0, 3, height); - }, [bins, vminPct, vmaxPct, width, height, isDark]); - - const handleMouse = (e: React.MouseEvent, isDown: boolean) => { - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const pct = Math.max(0, Math.min(100, (x / width) * 100)); - if (isDown) { - const dl = Math.abs(pct - vminPct); - const dr = Math.abs(pct - vmaxPct); - draggingRef.current = dl < dr ? "left" : "right"; + const maxVal = Math.max(...reducedBins, 0.001); + const barWidth = width / displayBins; + const vminBin = Math.floor((loPct / 100) * displayBins); + const vmaxBin = Math.floor((hiPct / 100) * displayBins); + for (let i = 0; i < displayBins; i++) { + const barHeight = (reducedBins[i] / maxVal) * (height - 2); + ctx.fillStyle = (i >= vminBin && i <= vmaxBin) ? colors.barActive : colors.barInactive; + ctx.fillRect(i * barWidth + 0.5, height - barHeight, Math.max(1, barWidth - 1), barHeight); + } + }, [bins, colors, height, width]); + + const applyRangePreview = React.useCallback((next: [number, number]) => { + const [lo, hi] = next; + const slider = sliderRef.current?.querySelector(".MuiSlider-root") as HTMLElement | null; + const thumbs = slider?.querySelectorAll(".MuiSlider-thumb"); + const track = slider?.querySelector(".MuiSlider-track") as HTMLElement | null; + if (thumbs && thumbs.length >= 2) { + (thumbs[0] as HTMLElement).style.left = `${lo}%`; + (thumbs[1] as HTMLElement).style.left = `${hi}%`; } - if (draggingRef.current === "left") onRangeChange(Math.min(pct, vmaxPct - 1), vmaxPct); - else if (draggingRef.current === "right") onRangeChange(vminPct, Math.max(pct, vminPct + 1)); + if (track) { + track.style.left = `${lo}%`; + track.style.width = `${Math.max(0, hi - lo)}%`; + } + if (minLabelRef.current) minLabelRef.current.textContent = formatValue(lo); + if (maxLabelRef.current) maxLabelRef.current.textContent = formatValue(hi); + drawHistogram(lo, hi); + }, [drawHistogram, formatValue]); + + React.useEffect(() => { + drawHistogram(liveVminPct, liveVmaxPct); + }, [drawHistogram, liveVmaxPct, liveVminPct]); + + React.useEffect(() => { + onRangeChangeRef.current = onRangeChange; + onRangePreviewRef.current = onRangePreview; + onRangeCommitRef.current = onRangeCommit; + }, [onRangeChange, onRangeCommit, onRangePreview]); + const emitRangePreview = React.useCallback((min: number, max: number) => { + (onRangePreviewRef.current || onRangeChangeRef.current)(min, max); + }, []); + const emitRangeCommit = React.useCallback((min: number, max: number) => { + (onRangeCommitRef.current || onRangeChangeRef.current)(min, max); + }, []); + const applySliderValue = (v: number | number[], emit: (min: number, max: number) => void) => { + const [newMin, newMax] = v as number[]; + const next: [number, number] = [Math.min(newMin, newMax - 1), Math.max(newMax, newMin + 1)]; + setLiveRange(next); + emit(next[0], next[1]); }; + const flushRangePreview = React.useCallback(() => { + if (rangeRafRef.current != null) { + window.cancelAnimationFrame(rangeRafRef.current); + rangeRafRef.current = null; + } + const pending = pendingRangeRef.current; + pendingRangeRef.current = null; + if (pending) { + setLiveRange(pending); + applyRangePreview(pending); + emitRangeCommit(pending[0], pending[1]); + } + }, [applyRangePreview, emitRangeCommit]); + React.useEffect(() => () => { + if (rangeRafRef.current != null) window.cancelAnimationFrame(rangeRafRef.current); + }, []); + const beginRangeDrag = React.useCallback((event: React.PointerEvent, dragWidth: number, lo0: number, hi0: number) => { + const startX = event.clientX; + const span = Math.max(1, hi0 - lo0); + const previousCursor = document.body.style.cursor; + document.body.style.cursor = "grabbing"; + const onMove = (moveEvent: PointerEvent) => { + moveEvent.preventDefault(); + const deltaPct = ((moveEvent.clientX - startX) / Math.max(1, dragWidth)) * 100; + const lo = Math.max(0, Math.min(100 - span, lo0 + deltaPct)); + const next: [number, number] = [lo, lo + span]; + pendingRangeRef.current = next; + if (rangeRafRef.current == null) { + rangeRafRef.current = window.requestAnimationFrame(() => { + rangeRafRef.current = null; + const pending = pendingRangeRef.current; + if (pending) { + setLiveRange(pending); + applyRangePreview(pending); + emitRangePreview(pending[0], pending[1]); + } + }); + } + }; + const onUp = () => { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + document.removeEventListener("pointercancel", onUp); + document.body.style.cursor = previousCursor; + flushRangePreview(); + }; + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onUp); + }, [applyRangePreview, emitRangePreview, flushRangePreview]); + + const sliderInset = 4; + const sliderWidth = Math.max(1, width - sliderInset * 2); return ( - handleMouse(e, true)} - onMouseMove={(e) => { if (draggingRef.current) handleMouse(e, false); }} - onMouseUp={() => { draggingRef.current = null; }} - onMouseLeave={() => { draggingRef.current = null; }} - /> + + + + { + if ((e.target as HTMLElement).closest(".MuiSlider-thumb")) return; + const rect = sliderRef.current?.getBoundingClientRect(); + if (!rect) return; + const lo = Math.max(0, Math.min(100, Math.min(liveVminPct, liveVmaxPct))); + const hi = Math.max(0, Math.min(100, Math.max(liveVminPct, liveVmaxPct))); + const pct = ((e.clientX - rect.left) / Math.max(1, rect.width)) * 100; + if (pct < lo || pct > hi) return; + const thumbGuardPct = Math.max(4, (10 / Math.max(1, rect.width)) * 100); + if (Math.abs(pct - lo) <= thumbGuardPct || Math.abs(pct - hi) <= thumbGuardPct) return; + beginRangeDrag(e, rect.width, lo, hi); + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }} + sx={{ position: "absolute", left: sliderInset, top: height - 1, width: sliderWidth, height: 8, display: "flex", alignItems: "flex-start", cursor: "grab", zIndex: 2, overflow: "visible", touchAction: "none" }} + > + applySliderValue(v, emitRangePreview)} + onChangeCommitted={(_, v) => applySliderValue(v, emitRangeCommit)} + min={0} max={100} size="small" valueLabelDisplay="auto" + valueLabelFormat={formatValue} + sx={{ + width: sliderWidth, + py: 0, + position: "relative", + zIndex: 3, + overflow: "visible", + "& .MuiSlider-rail": { height: 2, zIndex: 1 }, + "& .MuiSlider-track": { height: 2, cursor: "grab", zIndex: 2 }, + "& .MuiSlider-thumb": { width: 8, height: 8, zIndex: 4 }, + "& .MuiSlider-valueLabel": { fontSize: 10, padding: "2px 4px", zIndex: 5 }, + }} + /> + + + {formatValue(liveVminPct)}{formatValue(liveVmaxPct)} + ); } -// ============================================================================ -// Format a stat value for the readout -// ============================================================================ +// Stat values function formatStat(v: number): string { if (v === 0) return "0"; const a = Math.abs(v); @@ -215,14 +406,16 @@ function formatStat(v: number): string { return v.toPrecision(3); } -// ============================================================================ -// Spot and ring types -// ============================================================================ +// Model types interface SpotDict { id: number; row: number; col: number; + raw_row?: number | null; + raw_col?: number | null; + row_err?: number | null; + col_err?: number | null; d_spacing: number | null; d_spacing_err?: number | null; g_magnitude: number | null; @@ -234,30 +427,88 @@ interface SpotDict { fit_quality?: number | null; intensity: number; hkl?: string; + hkl_candidates?: string[]; + d_ref?: number | null; + d_error?: number | null; note?: string; } +interface PhaseEntry { + name: string; + a: number; + b?: number; + c?: number; + alpha?: number; + beta?: number; + gamma?: number; + absences: string; +} + +interface MaskRegion { + kind: string; + start_deg?: number; + end_deg?: number; + row?: number; + col?: number; + radius?: number; +} + interface RingDict { id: number; radius_px: number; g_magnitude: number | null; d_spacing: number | null; intensity: number; + hkl?: string; + hkl_candidates?: string[]; + d_ref?: number | null; + d_error?: number | null; + fwhm_px?: number | null; + fwhm_inv_angstrom?: number | null; + intensity_integrated?: number | null; + fit_quality?: number | null; + note?: string; +} + +interface IdentifyLine { + obs_d: number | null; + ref_d: number | null; + hkl: string; + err: number | null; + i_rel: number | null; +} + +interface IdentifyResult { + phase_id: string; + name: string; + matched: number; + n_obs: number; + mean_err: number | null; + n_missing_strong: number | null; + lines: IdentifyLine[]; +} + +interface QualityDict { + center?: { method: string }; + calibration?: { source: string; k_pixel_size: number; rms_px: number }; + ellipse?: { ratio: number; angle_deg: number; corrected: boolean }; + rings?: { id: number; fit_quality: number }[]; + n_unexplained_rings?: number; + mask_coverage_pct?: number; + ring_snr?: { cv: number; coverage: number; snr: number }; } -// Spot colors, shared by table rows and canvas overlay. +// Spot colors const PICK_COLORS = [ "#ff4d4f", "#40a9ff", "#73d13d", "#ffa940", "#9254de", "#13c2c2", "#f759ab", "#bae637", ]; const spotColorAt = (index: number) => PICK_COLORS[((index % PICK_COLORS.length) + PICK_COLORS.length) % PICK_COLORS.length]; -// ============================================================================ // Main component -// ============================================================================ function ShowDiffraction() { - // Force a light background for offline/export HTML renders. + // Offline theme const [offline] = useModelState("offline"); const { themeInfo, colors: themeColors } = useTheme(offline); const rootRef = React.useRef(null); @@ -274,10 +525,20 @@ function ShowDiffraction() { }; const themedMenuProps = { ...upwardMenuProps, - PaperProps: { sx: { bgcolor: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` } }, + PaperProps: { sx: { bgcolor: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}`, maxHeight: 200, overflowY: "auto" } }, + }; + const topToolbarMenuProps = { + ...downwardMenuProps, + PaperProps: themedMenuProps.PaperProps, }; - // Bordered control group. + // Control group const controlBox = { ...controlRow, border: `1px solid ${themeColors.border}`, borderRadius: "4px", bgcolor: themeColors.controlBg }; + // Number input + const numInput = (width: number) => ({ width, fontSize: 10, padding: "2px 4px", background: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` }); + // Status colors + const statusColors = themeInfo.theme === "dark" + ? { good: "#81c784", warn: "#ffb74d", bad: "#e57373" } + : { good: "#2e7d32", warn: "#e65100", bad: "#d32f2f" }; // Model state const [title] = useModelState("title"); @@ -303,6 +564,7 @@ function ShowDiffraction() { const [, setDetectRequest] = useModelState("_detect_spots_request"); const [, setDetectRingsRequest] = useModelState("_detect_rings_request"); const [, setSpotRemoveRequest] = useModelState("_spot_remove_request"); + const [, setSpotMoveRequest] = useModelState("_spot_move_request"); const [, setRingRemoveRequest] = useModelState("_ring_remove_request"); const [dpColormap, setDpColormap] = useModelState("dp_colormap"); const [dpScaleMode, setDpScaleMode] = useModelState("dp_scale_mode"); @@ -310,13 +572,13 @@ function ShowDiffraction() { const [dpVminPct, setDpVminPct] = useModelState("dp_vmin_pct"); const [dpVmaxPct, setDpVmaxPct] = useModelState("dp_vmax_pct"); const [dpStats] = useModelState("dp_stats"); - const [showStats] = useModelState("show_stats"); - const [showControls] = useModelState("show_controls"); + const [showStats, setShowStats] = useModelState("show_stats"); + const [showControls, setShowControls] = useModelState("show_controls"); const [controlsCollapsed, setControlsCollapsed] = useModelState("controls_collapsed"); const controlsVisible = showControls && !controlsCollapsed; const [panelWidthPx] = useModelState("panel_width_px"); - // Standalone HTML export bridge. + // HTML export const [, setExportRequest] = useModelState("export_request"); const [exportStatus] = useModelState("export_status"); const [exportEnabled] = useModelState("export_enabled"); @@ -326,9 +588,12 @@ function ShowDiffraction() { const exportCounterRef = React.useRef(0); const pendingExportRef = React.useRef(""); - // Center, rings, calibration + // Geometry const [centerMode, setCenterMode] = useModelState("center_mode"); const [rings] = useModelState("rings"); + const [showHkl, setShowHkl] = useModelState("show_hkl"); + const [zoneAxis] = useModelState("zone_axis"); + const [phaseMatch] = useModelState("phase_match"); const [calibrationSource] = useModelState("calibration_source"); const [calibrationRefD] = useModelState("calibration_ref_d"); const [calibrationRefRadius] = useModelState("calibration_ref_radius"); @@ -336,40 +601,63 @@ function ShowDiffraction() { const [, setRingClearRequest] = useModelState("_ring_clear_request"); const [, setCalibrateFromRingRequest] = useModelState("_calibrate_from_ring_request"); const [, setCalibrateFromSpotRequest] = useModelState("_calibrate_from_spot_request"); - - // Export spots and rings as CSV or JSON. - const exportMeasurements = React.useCallback((format: "csv" | "json") => { - const cols = [ - "id", "kind", "row", "col", "r_pixels", "r_pixels_err", - "g_inv_angstrom", "g_inv_angstrom_err", "d_angstrom", "d_angstrom_err", - "angle_deg", "angle_deg_err", "intensity", "fit_quality", "hkl", "note", - ]; - const rows: (string | number | null)[][] = []; - for (const s of spots || []) { - rows.push([s.id, "spot", s.row, s.col, s.r_pixels, s.r_pixels_err ?? null, - s.g_magnitude, s.g_magnitude_err ?? null, s.d_spacing, s.d_spacing_err ?? null, - s.angle_deg ?? null, s.angle_deg_err ?? null, s.intensity, s.fit_quality ?? null, - s.hkl ?? "", s.note ?? ""]); - } - for (const r of rings || []) { - rows.push([r.id, "ring", null, null, r.radius_px, null, r.g_magnitude, null, - r.d_spacing, null, null, null, r.intensity, null, "", ""]); - } + const [ellipseRatio] = useModelState("ellipse_ratio"); + const [ellipseAngle] = useModelState("ellipse_angle"); + const [ellipseCorrected, setEllipseCorrected] = useModelState("ellipse_corrected"); + const [analysisStatus] = useModelState("analysis_status"); + const [showProfile, setShowProfile] = useModelState("show_profile"); + const [profileLog, setProfileLog] = useModelState("profile_log"); + const [profileSubtract, setProfileSubtract] = useModelState("profile_subtract_background"); + const [profileData] = useModelState("_profile_data"); + const [, setRingAddRequest] = useModelState("_ring_add_request"); + const [, setRefineCenterRequest] = useModelState("_refine_center_request"); + const [, setFitRingsRequest] = useModelState("_fit_rings_request"); + const [, setFitEllipseRequest] = useModelState("_fit_ellipse_request"); + const [, setCalibratePhaseRequest] = useModelState("_calibrate_phase_request"); + const [, setIndexRingsRequest] = useModelState("_index_rings_request"); + const [, setIndexSpotsRequest] = useModelState("_index_spots_request"); + const [, setIdentifyRequest] = useModelState("_identify_request"); + const [, setAutoRequest] = useModelState("_auto_request"); + const [refineMethod, setRefineMethod] = useModelState("refine_method"); + const [centerMethod] = useModelState("center_method"); + const [, setMergeRequest] = useModelState("_merge_request"); + const [, setQualityRequest] = useModelState("_quality_request"); + const [identifyElements, setIdentifyElements] = useModelState("identify_elements"); + const [identifyCustomOnly, setIdentifyCustomOnly] = useModelState("identify_custom_only"); + const [identifyResults] = useModelState("_identify_results"); + const [quality] = useModelState("_quality"); + const [selectedRingId, setSelectedRingId] = useModelState("selected_ring_id"); + const [phaseName, setPhaseName] = useModelState("phase_name"); + const [customPhases, setCustomPhases] = useModelState("custom_phases"); + const [phaseLibrary] = useModelState("_phase_library"); + const [maskRegions, setMaskRegions] = useModelState("mask_regions"); + const [showMask, setShowMask] = useModelState("show_mask"); + const [showAzimuthal, setShowAzimuthal] = useModelState("show_azimuthal"); + const [azimuthalData] = useModelState("_azimuthal_data"); + + // CSV/JSON export; column schema kept in sync with the Python exporter + const exportMeasurements = React.useCallback((format: "csv" | "json", kind: "spots" | "rings" | "all" = "all") => { + const records = buildMeasurementRecords( + kind === "rings" ? [] : spots || [], + kind === "spots" ? [] : rings || [], + ); + const basename = kind === "all" ? "measurements" : kind; if (format === "json") { - const records = rows.map((r) => Object.fromEntries(cols.map((c, i) => [c, r[i]]))); - const blob = new Blob([JSON.stringify({ measurements: records }, null, 2)], { type: "application/json" }); - downloadBlob(blob, "measurements.json"); + const metadata = measurementMetadata({ + centerRow, centerCol, centerMethod, kPixelSize, kCalibrated, + calibrationSource, calibrationRefD, calibrationRefRadius, + maskRegions: maskRegions || [], backgroundSubtracted: profileSubtract, + }); + const blob = new Blob([JSON.stringify({ metadata, measurements: records }, null, 2)], { type: "application/json" }); + downloadBlob(blob, `${basename}.json`); } else { - const esc = (v: string | number | null) => { - const s = v == null ? "" : String(v); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; - }; - const csv = [cols.join(","), ...rows.map((r) => r.map(esc).join(","))].join("\n"); - downloadBlob(new Blob([csv], { type: "text/csv" }), "measurements.csv"); + downloadBlob(new Blob([measurementCsv(records)], { type: "text/csv" }), `${basename}.csv`); } - }, [spots, rings]); + }, [spots, rings, centerRow, centerCol, centerMethod, kPixelSize, kCalibrated, + calibrationSource, calibrationRefD, calibrationRefRadius, maskRegions, profileSubtract]); // Local UI state + const isMobile = useMobileViewport(); const initialCanvasSize = React.useMemo(() => { const requested = Number(panelWidthPx); return Number.isFinite(requested) && requested > 0 ? Math.max(CANVAS_MIN, Math.round(requested)) : CANVAS_MIN; @@ -384,6 +672,28 @@ function ShowDiffraction() { const [dpHistData, setDpHistData] = React.useState(null); const [cursorInfo, setCursorInfo] = React.useState<{ row: number; col: number; value: number } | null>(null); const [dpExportAnchor, setDpExportAnchor] = React.useState(null); + const [phaseMenuAnchor, setPhaseMenuAnchor] = React.useState(null); + const [maskMenuAnchor, setMaskMenuAnchor] = React.useState(null); + const [refineMenuAnchor, setRefineMenuAnchor] = React.useState(null); + const [showQc, setShowQc] = React.useState(false); + const toggleQuality = React.useCallback(() => { + const next = !showQc; + setShowQc(next); + if (next) setQualityRequest(true); + }, [showQc, setQualityRequest]); + const [identifyCollapsed, setIdentifyCollapsed] = React.useState(false); + const [expandedPhaseId, setExpandedPhaseId] = React.useState(null); + const [customName, setCustomName] = React.useState(""); + const [customA, setCustomA] = React.useState(""); + const [customB, setCustomB] = React.useState(""); + const [customC, setCustomC] = React.useState(""); + const [customAlpha, setCustomAlpha] = React.useState(""); + const [customBeta, setCustomBeta] = React.useState(""); + const [customGamma, setCustomGamma] = React.useState(""); + const [customAbsences, setCustomAbsences] = React.useState("fcc"); + const [wedgeStart, setWedgeStart] = React.useState(""); + const [wedgeEnd, setWedgeEnd] = React.useState(""); + const [diskRadius, setDiskRadius] = React.useState(""); const [dKnown, setDKnown] = React.useState(""); React.useEffect(() => { @@ -392,28 +702,181 @@ function ShowDiffraction() { } }, [initialCanvasSize]); - // Local frame index for smooth scrubbing; commit on release. + // Canvas drag modes + const [moveSpots, setMoveSpots] = React.useState(false); + const [drawMode, setDrawMode] = React.useState<"disk" | "wedge" | null>(null); + type DragPreview = + | { kind: "spot"; id: number; row: number; col: number } + | { kind: "disk"; row: number; col: number; radius: number } + | { kind: "wedge"; start_deg: number; end_deg: number }; + const [dragPreview, setDragPreview] = React.useState(null); + const dragTargetRef = React.useRef< + | { kind: "spot"; id: number } + | { kind: "disk"; row: number; col: number } + | { kind: "wedge"; start_deg: number } + | null + >(null); + const dragPosRef = React.useRef({ row: 0, col: 0 }); + const dragRafRef = React.useRef(0); + + // Smooth scrubbing const [localFrame, setLocalFrame] = React.useState(frameIdx); React.useEffect(() => { setLocalFrame(frameIdx); }, [frameIdx]); - // Zoom to the diffraction center. + // Identify table + React.useEffect(() => { setIdentifyCollapsed(false); }, [identifyResults]); + + // Center zoom (pixel-center convention) const zoomToCenter = React.useCallback(() => { const Z = 2.5; setDpZoom(Z); - setDpPanX(canvasSize * Z * (0.5 - centerCol / Math.max(detCols, 1))); - setDpPanY(canvasSize * Z * (0.5 - centerRow / Math.max(detRows, 1))); + setDpPanX(canvasSize * Z * (0.5 - (centerCol + 0.5) / Math.max(detCols, 1))); + setDpPanY(canvasSize * Z * (0.5 - (centerRow + 0.5) / Math.max(detRows, 1))); }, [canvasSize, centerRow, centerCol, detRows, detCols]); const dpCanvasRef = React.useRef(null); const dpUiRef = React.useRef(null); const dpScaleRef = React.useRef(null); + const profileCanvasRef = React.useRef(null); + const azimuthalCanvasRef = React.useRef(null); const dpOffscreenRef = React.useRef(null); const [dpVersion, setDpVersion] = React.useState(0); const dpVminRef = React.useRef(0); const dpVmaxRef = React.useRef(1); - // Drag the corner handle to resize the canvas. - const handleCanvasResizeStart = (e: React.MouseEvent) => { + const decodeHalves = (data: DataView, on: boolean) => { + if (!on) return null; + const arr = extractFloat32(data); + if (!arr || arr.length < 4 || arr.length % 2 !== 0) return null; + const n = arr.length / 2; + return { x: arr.subarray(0, n), y: arr.subarray(n) }; + }; + + // Curve painter + const drawCurvePanel = React.useCallback(( + canvas: HTMLCanvasElement | null, + data: { x: Float32Array; y: Float32Array } | null, + xLabel: string, yLabel: string, zeroLine: boolean, + opts?: { logY?: boolean; rings?: RingDict[]; bfRadius?: number; selectedRingId?: number }, + ) => { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + canvas.width = canvasSize * DPR; + canvas.height = PROFILE_H * DPR; + ctx.scale(DPR, DPR); + const isDark = themeInfo.theme === "dark"; + ctx.fillStyle = isDark ? "#050505" : "#ffffff"; + ctx.fillRect(0, 0, canvasSize, PROFILE_H); + if (!data) return; + const plotW = canvasSize - PROFILE_PAD.left - PROFILE_PAD.right; + const plotH = PROFILE_H - PROFILE_PAD.top - PROFILE_PAD.bottom; + const n = data.x.length; + const xMin = data.x[0], xMax = data.x[n - 1]; + let vMin = Infinity, vMax = -Infinity; + for (let i = 0; i < n; i++) { + if (data.y[i] < vMin) vMin = data.y[i]; + if (data.y[i] > vMax) vMax = data.y[i]; + } + const xOf = (x: number) => PROFILE_PAD.left + ((x - xMin) / Math.max(1e-9, xMax - xMin)) * plotW; + const yOf = (v: number) => { + const t = opts?.logY + ? Math.log1p(Math.max(0, v - vMin)) / Math.max(1e-9, Math.log1p(vMax - vMin)) + : (v - vMin) / Math.max(1e-9, vMax - vMin); + return PROFILE_PAD.top + (1 - t) * plotH; + }; + ctx.strokeStyle = isDark ? "#333333" : "#d8d8d8"; + ctx.lineWidth = 1; + for (let g = 0; g <= 4; g++) { + const y = PROFILE_PAD.top + (g / 4) * plotH; + ctx.beginPath(); ctx.moveTo(PROFILE_PAD.left, y); ctx.lineTo(canvasSize - PROFILE_PAD.right, y); ctx.stroke(); + } + if (zeroLine && vMin < 0 && vMax > 0) { + ctx.strokeStyle = isDark ? "#666666" : "#999999"; + ctx.beginPath(); ctx.moveTo(PROFILE_PAD.left, yOf(0)); ctx.lineTo(canvasSize - PROFILE_PAD.right, yOf(0)); ctx.stroke(); + } + const ringColor = isDark ? "#ffb74d" : "#e65100"; + for (const ring of opts?.rings || []) { + const x = xOf(ring.radius_px); + const selected = opts?.selectedRingId != null && opts.selectedRingId !== 0 && ring.id === opts.selectedRingId; + ctx.strokeStyle = selected ? themeColors.accent : ringColor; + ctx.lineWidth = selected ? 2.5 : 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(x, PROFILE_PAD.top); ctx.lineTo(x, PROFILE_PAD.top + plotH); ctx.stroke(); + ctx.setLineDash([]); + ctx.lineWidth = 1; + ctx.fillStyle = selected ? themeColors.accent : ringColor; + ctx.font = "9px -apple-system, sans-serif"; + ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText(`${ring.id}`, x + 2, PROFILE_PAD.top); + if (kCalibrated && kPixelSize > 0 && ring.radius_px > 0) { + ctx.fillText(`${(1 / (ring.radius_px * kPixelSize)).toFixed(2)}Å`, x + 2, PROFILE_PAD.top + 10); + } + } + if (opts?.bfRadius && opts.bfRadius > 0 && opts.bfRadius <= xMax) { + ctx.strokeStyle = isDark ? "#333333" : "#d8d8d8"; + ctx.beginPath(); + ctx.moveTo(xOf(opts.bfRadius), PROFILE_PAD.top + plotH - 6); + ctx.lineTo(xOf(opts.bfRadius), PROFILE_PAD.top + plotH); + ctx.stroke(); + } + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 1.4; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const px = xOf(data.x[i]), py = yOf(data.y[i]); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + ctx.stroke(); + ctx.fillStyle = isDark ? "#dddddd" : "#222222"; + ctx.font = "10px -apple-system, sans-serif"; + ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText(yLabel, 4, 4); + ctx.textBaseline = "bottom"; + ctx.fillText(xLabel, PROFILE_PAD.left, PROFILE_H - 4); + ctx.textAlign = "center"; + for (const frac of [0.25, 0.5, 0.75, 1.0]) { + const xv = xMin + (xMax - xMin) * frac; + ctx.fillText(xv >= 100 ? `${Math.round(xv)}` : xv.toFixed(1), xOf(xv), PROFILE_H - 16); + } + ctx.setTransform(1, 0, 0, 1, 0, 0); + }, [canvasSize, themeInfo.theme, themeColors.accent, kCalibrated, kPixelSize]); + + const azimuthalArrays = React.useMemo(() => decodeHalves(azimuthalData, showAzimuthal), [azimuthalData, showAzimuthal]); + + React.useLayoutEffect(() => { + if (showAzimuthal) drawCurvePanel(azimuthalCanvasRef.current, azimuthalArrays, "θ (°)", "I", false); + }, [azimuthalArrays, showAzimuthal, drawCurvePanel]); + + const profileArrays = React.useMemo(() => decodeHalves(profileData, showProfile), [profileData, showProfile]); + + const handleProfileClick = React.useCallback((e: React.PointerEvent) => { + const canvas = profileCanvasRef.current; + if (!canvas || !profileArrays) return; + const rect = canvas.getBoundingClientRect(); + const scale = rect.width > 0 ? canvasSize / rect.width : 1; + const plotW = canvasSize - PROFILE_PAD.left - PROFILE_PAD.right; + const rMax = profileArrays.x[profileArrays.x.length - 1]; + const radius = (((e.clientX - rect.left) * scale - PROFILE_PAD.left) / Math.max(1, plotW)) * rMax; + if (!(radius > 0 && radius <= rMax)) return; + // Ring selection + const hit = (rings || []).find((r) => Math.abs(r.radius_px - radius) <= 3); + if (hit) { + setSelectedRingId(selectedRingId === hit.id ? 0 : hit.id); + return; + } + setRingAddRequest([radius]); + }, [profileArrays, canvasSize, rings, selectedRingId, setSelectedRingId, setRingAddRequest]); + + React.useLayoutEffect(() => { + if (showProfile) { + drawCurvePanel(profileCanvasRef.current, profileArrays, "r (px)", profileLog ? "log I" : "I", + false, { logY: profileLog, rings, bfRadius, selectedRingId }); + } + }, [profileArrays, profileLog, rings, bfRadius, showProfile, selectedRingId, drawCurvePanel]); + + // Canvas resize + const handleCanvasResizeStart = (e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); hasResizedCanvasRef.current = true; @@ -425,7 +888,7 @@ function ShowDiffraction() { if (!isResizingCanvas) return; let rafId = 0; let latestSize = resizeCanvasStart ? resizeCanvasStart.size : canvasSize; - const handleMouseMove = (e: MouseEvent) => { + const handleMouseMove = (e: PointerEvent) => { if (!resizeCanvasStart) return; const delta = Math.max(e.clientX - resizeCanvasStart.x, e.clientY - resizeCanvasStart.y); latestSize = Math.max(CANVAS_MIN, resizeCanvasStart.size + delta); @@ -442,16 +905,18 @@ function ShowDiffraction() { setIsResizingCanvas(false); setResizeCanvasStart(null); }; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("pointermove", handleMouseMove); + document.addEventListener("pointerup", handleMouseUp); + document.addEventListener("pointercancel", handleMouseUp); return () => { cancelAnimationFrame(rafId); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("pointermove", handleMouseMove); + document.removeEventListener("pointerup", handleMouseUp); + document.removeEventListener("pointercancel", handleMouseUp); }; }, [isResizingCanvas, resizeCanvasStart]); - // Colormap LUT, reversed when Invert is on. + // Colormap LUT const dpLut = React.useMemo(() => { const base = COLORMAPS[dpColormap] || COLORMAPS.inferno; if (!dpInvert) return base; @@ -464,7 +929,7 @@ function ShowDiffraction() { return inv; }, [dpColormap, dpInvert]); - // Offline bakes the whole stack for client-side scrubbing; live streams one frame. + // Frame bytes const activeFrame = React.useMemo(() => { const frameLen = detRows * detCols; if (offline && offlineFrames && frameLen > 0 @@ -478,10 +943,19 @@ function ShowDiffraction() { return extractFloat32(frameBytes, frameLen); }, [offline, offlineFrames, frameBytes, frameIdx, nFrames, detRows, detCols]); - // Render the frame: scale then colormap - React.useEffect(() => { + // offline stacks: stats recompute in JS; panes flag the baked frame + const bakedFrameRef = React.useRef(frameIdx); + const offlineStats = React.useMemo( + () => (offline && nFrames > 1 && activeFrame ? frameStats(activeFrame) : null), + [offline, nFrames, activeFrame], + ); + const displayStats = offlineStats ?? dpStats; + const paneStaleNote = staleFrameNote(offline, nFrames, frameIdx, bakedFrameRef.current); + + // Frame scaling + const scaledFrame = React.useMemo(() => { const raw = activeFrame; - if (!raw || raw.length === 0) return; + if (!raw || raw.length === 0) return null; let scaled: Float32Array; if (dpScaleMode === "log") { scaled = new Float32Array(raw.length); @@ -495,7 +969,13 @@ function ShowDiffraction() { scaled = raw; } const { min: dataMin, max: dataMax } = findDataRange(scaled); - const { vmin, vmax } = sliderRange(dataMin, dataMax, dpVminPct, dpVmaxPct); + return { scaled, dataMin, dataMax }; + }, [activeFrame, dpScaleMode]); + + // Contrast painter + const paintContrast = React.useCallback((loPct: number, hiPct: number) => { + if (!scaledFrame) return; + const { vmin, vmax } = sliderRange(scaledFrame.dataMin, scaledFrame.dataMax, loPct, hiPct); dpVminRef.current = vmin; dpVmaxRef.current = vmax; let offscreen = dpOffscreenRef.current; @@ -505,13 +985,46 @@ function ShowDiffraction() { const ctx = offscreen.getContext("2d"); if (!ctx) return; const imgData = ctx.createImageData(detCols, detRows); - applyColormap(scaled, imgData.data, dpLut, vmin, vmax); + applyColormap(scaledFrame.scaled, imgData.data, dpLut, vmin, vmax); ctx.putImageData(imgData, 0, 0); - setDpHistData(scaled); setDpVersion(v => v + 1); - }, [activeFrame, dpLut, dpScaleMode, dpVminPct, dpVmaxPct, detRows, detCols]); + }, [scaledFrame, dpLut, detRows, detCols]); + + // Frame render + React.useEffect(() => { + if (!scaledFrame) return; + paintContrast(dpVminPct, dpVmaxPct); + setDpHistData(scaledFrame.scaled); + }, [scaledFrame, paintContrast, dpVminPct, dpVmaxPct]); + + // Contrast preview: rAF-throttled paint, traits written once on commit + const pendingContrastRef = React.useRef<[number, number] | null>(null); + const contrastRafRef = React.useRef(0); + const previewContrast = React.useCallback((lo: number, hi: number) => { + pendingContrastRef.current = [lo, hi]; + if (!contrastRafRef.current) { + contrastRafRef.current = window.requestAnimationFrame(() => { + contrastRafRef.current = 0; + const pending = pendingContrastRef.current; + if (pending) paintContrast(pending[0], pending[1]); + }); + } + }, [paintContrast]); + const commitContrast = React.useCallback((lo: number, hi: number) => { + if (contrastRafRef.current) { + window.cancelAnimationFrame(contrastRafRef.current); + contrastRafRef.current = 0; + } + pendingContrastRef.current = null; + paintContrast(lo, hi); + setDpVminPct(lo); + setDpVmaxPct(hi); + }, [paintContrast, setDpVminPct, setDpVmaxPct]); + React.useEffect(() => () => { + if (contrastRafRef.current) window.cancelAnimationFrame(contrastRafRef.current); + }, []); - // Draw the rendered frame with zoom and pan + // Frame canvas React.useLayoutEffect(() => { const canvas = dpCanvasRef.current; const offscreen = dpOffscreenRef.current; @@ -527,7 +1040,7 @@ function ShowDiffraction() { ctx.drawImage(offscreen, offX, offY, canvasSize * dpZoom, canvasSize * dpZoom); }, [dpVersion, dpZoom, dpPanX, dpPanY, canvasSize, detRows, detCols]); - // Overlay: center, spots, rings, colorbar + // Overlays React.useLayoutEffect(() => { const canvas = dpUiRef.current; if (!canvas) return; @@ -539,14 +1052,13 @@ function ShowDiffraction() { ctx.scale(DPR, DPR); ctx.clearRect(0, 0, cssW, cssW); - const scX = (cssW / detCols) * dpZoom; - const scY = (cssW / detRows) * dpZoom; - const offX = (cssW - cssW * dpZoom) / 2 + dpPanX; - const offY = (cssW - cssW * dpZoom) / 2 + dpPanY; + // per-axis transform for non-square detectors + const view = viewTransform(cssW, dpZoom, dpPanX, dpPanY, detRows, detCols); + const { scX, scY } = view; // Center crosshair - const cx = offX + centerCol * scX; - const cy = offY + centerRow * scY; + const cx = dataColToScreenX(centerCol, view); + const cy = dataRowToScreenY(centerRow, view); ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); @@ -555,17 +1067,17 @@ function ShowDiffraction() { ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy + 10); ctx.stroke(); // BF disk circle - const br = bfRadius * scX; ctx.beginPath(); - ctx.arc(cx, cy, br, 0, 2 * Math.PI); + ctx.ellipse(cx, cy, bfRadius * scX, bfRadius * scY, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); // Spot markers if (spots && spots.length > 0) { spots.forEach((spot, i) => { - const sx = offX + spot.col * scX; - const sy = offY + spot.row * scY; + const dragged = dragPreview?.kind === "spot" && dragPreview.id === spot.id ? dragPreview : null; + const sx = dataColToScreenX(dragged ? dragged.col : spot.col, view); + const sy = dataRowToScreenY(dragged ? dragged.row : spot.row, view); const color = spotColorAt(i); ctx.strokeStyle = color; ctx.lineWidth = 1.5; @@ -577,18 +1089,101 @@ function ShowDiffraction() { ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillText(`${spot.id}`, sx + 8, sy - 2); + if (showHkl && spot.hkl) { + ctx.textBaseline = "top"; + ctx.fillText(spot.hkl, sx + 8, sy + 2); + ctx.textBaseline = "bottom"; + } }); } // Rings if (rings && rings.length > 0) { - ctx.strokeStyle = themeInfo.theme === "dark" ? "#ffb74d" : "#e65100"; - ctx.lineWidth = 1.2; + const ringColor = themeInfo.theme === "dark" ? "#ffb74d" : "#e65100"; for (const ring of rings) { + const selected = selectedRingId !== 0 && ring.id === selectedRingId; + ctx.strokeStyle = selected ? themeColors.accent : ringColor; + ctx.lineWidth = selected ? 2.5 : 1.2; ctx.beginPath(); - ctx.arc(cx, cy, ring.radius_px * scX, 0, 2 * Math.PI); + ctx.ellipse(cx, cy, ring.radius_px * scX, ring.radius_px * scY, 0, 0, 2 * Math.PI); ctx.stroke(); + if (showHkl && ring.hkl) { + const rrX = ring.radius_px * scX * Math.SQRT1_2; + const rrY = ring.radius_px * scY * Math.SQRT1_2; + ctx.fillStyle = selected ? themeColors.accent : ringColor; + ctx.font = "bold 10px -apple-system, sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(ring.hkl, cx + rrX + 4, cy - rrY - 4); + } + } + } + + // Excluded mask regions + if (showMask && maskRegions && maskRegions.length > 0) { + ctx.save(); + ctx.fillStyle = themeInfo.theme === "dark" ? "rgba(244,67,54,0.18)" : "rgba(211,47,47,0.15)"; + for (const region of maskRegions) { + if (region.kind === "disk" && region.radius != null) { + ctx.beginPath(); + ctx.ellipse( + dataColToScreenX(region.col ?? 0, view), dataRowToScreenY(region.row ?? 0, view), + region.radius * scX, region.radius * scY, 0, 0, 2 * Math.PI, + ); + ctx.fill(); + } else if (region.kind === "wedge" && region.start_deg != null && region.end_deg != null) { + const rBig = Math.hypot(detRows * scY, detCols * scX); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, rBig, dataAngleToScreen(region.start_deg, view), dataAngleToScreen(region.end_deg, view)); + ctx.closePath(); + ctx.fill(); + } + } + ctx.restore(); + } + + // Draw-mode preview + if (dragPreview && dragPreview.kind !== "spot") { + ctx.save(); + ctx.strokeStyle = themeInfo.theme === "dark" ? "rgba(244,67,54,0.9)" : "rgba(211,47,47,0.9)"; + ctx.fillStyle = themeInfo.theme === "dark" ? "rgba(244,67,54,0.18)" : "rgba(211,47,47,0.15)"; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1.2; + ctx.beginPath(); + if (dragPreview.kind === "disk") { + ctx.ellipse( + dataColToScreenX(dragPreview.col, view), dataRowToScreenY(dragPreview.row, view), + dragPreview.radius * scX, dragPreview.radius * scY, 0, 0, 2 * Math.PI, + ); + } else { + const rBig = Math.hypot(detRows * scY, detCols * scX); + ctx.moveTo(cx, cy); + ctx.arc(cx, cy, rBig, dataAngleToScreen(dragPreview.start_deg, view), dataAngleToScreen(dragPreview.end_deg, view)); + ctx.closePath(); } + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + + // Fitted ellipse + if (ellipseRatio > 1.002 && rings && rings.length > 0) { + const rMax = Math.max(...rings.map((r) => r.radius_px)); + const s = Math.sqrt(ellipseRatio); + ctx.save(); + ctx.strokeStyle = themeInfo.theme === "dark" ? "#4dd0e1" : "#00838f"; + ctx.setLineDash([6, 4]); + ctx.lineWidth = 1.2; + ctx.beginPath(); + // build the path in data units, then stroke with a uniform screen pen + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(scX, scY); + ctx.ellipse(0, 0, rMax * s, rMax / s, (ellipseAngle * Math.PI) / 180, 0, 2 * Math.PI); + ctx.restore(); + ctx.stroke(); + ctx.restore(); } drawColorbar(ctx, cssW, cssW, dpLut, dpVminRef.current, dpVmaxRef.current, dpScaleMode === "log"); @@ -602,49 +1197,160 @@ function ShowDiffraction() { } ctx.setTransform(1, 0, 0, 1, 0, 0); - }, [dpVersion, dpZoom, dpPanX, dpPanY, canvasSize, detRows, detCols, centerRow, centerCol, bfRadius, spots, rings, dpLut, dpScaleMode, themeInfo.theme]); + }, [dpVersion, dpZoom, dpPanX, dpPanY, canvasSize, detRows, detCols, centerRow, centerCol, bfRadius, spots, rings, showHkl, selectedRingId, ellipseRatio, ellipseAngle, maskRegions, showMask, dragPreview, dpLut, dpScaleMode, themeInfo.theme, themeColors.accent]); - // K-space scale bar on its own canvas. + // Scale bar React.useLayoutEffect(() => { const canvas = dpScaleRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - canvas.width = canvasSize * DPR; // resets + clears + canvas.width = canvasSize * DPR; canvas.height = canvasSize * DPR; ctx.clearRect(0, 0, canvas.width, canvas.height); if (kCalibrated && kPixelSize > 0) { - drawScaleBarHiDPI(canvas, DPR, dpZoom, kPixelSize, "mrad", detCols); + drawScaleBarHiDPI(canvas, DPR, dpZoom, kPixelSize, "1/Å", detCols); } }, [canvasSize, dpZoom, kCalibrated, kPixelSize, detCols]); - // Mouse handlers + // Pointer handlers const dpIsDragging = React.useRef(false); const dpDragStart = React.useRef({ x: 0, y: 0, panX: 0, panY: 0 }); - // Canvas pixel to image (row, col). - const dpToImage = (e: React.MouseEvent) => { + // CSS px to internal canvas px (canvas can render narrower than canvasSize) + const dpDisplayScale = () => { + const canvas = dpCanvasRef.current; + if (!canvas) return 1; + const rect = canvas.getBoundingClientRect(); + return rect.width > 0 ? canvasSize / rect.width : 1; + }; + + // canvas to data (pixel-center) + const dpToImage = (e: { clientX: number; clientY: number }) => { const canvas = dpCanvasRef.current; if (!canvas) return { row: 0, col: 0 }; const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const offX = (canvasSize - canvasSize * dpZoom) / 2 + dpPanX; - const offY = (canvasSize - canvasSize * dpZoom) / 2 + dpPanY; - const col = (mx - offX) / (canvasSize * dpZoom) * detCols; - const row = (my - offY) / (canvasSize * dpZoom) * detRows; - return { row, col }; + const scale = rect.width > 0 ? canvasSize / rect.width : 1; + const mx = (e.clientX - rect.left) * scale; + const my = (e.clientY - rect.top) * scale; + return screenToData(mx, my, viewTransform(canvasSize, dpZoom, dpPanX, dpPanY, detRows, detCols)); + }; + + const angleOf = (row: number, col: number) => + (Math.atan2(row - centerRow, col - centerCol) * 180 / Math.PI + 360) % 360; + + // Two-finger pinch (zoom + pan) via tracked pointers + const activePointersRef = React.useRef(new Map()); + const pinchStartRef = React.useRef< + | { dist: number; midX: number; midY: number; zoom: number; offX: number; offY: number } + | null + >(null); + const pinchRafRef = React.useRef(0); + const multiTouchRef = React.useRef(false); + const lastTapRef = React.useRef({ time: 0, x: 0, y: 0 }); + const pendingTapRef = React.useRef<{ x: number; y: number; row: number; col: number } | null>(null); + + const cancelCanvasDrag = () => { + dragTargetRef.current = null; + cancelAnimationFrame(dragRafRef.current); + dragRafRef.current = 0; + setDragPreview(null); + }; + + const applyPinch = () => { + const start = pinchStartRef.current; + const points = Array.from(activePointersRef.current.values()); + if (!start || points.length < 2) return; + const scale = dpDisplayScale(); + const dist = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y) * scale; + const canvas = dpCanvasRef.current; + if (!canvas || dist <= 0 || start.dist <= 0) return; + const rect = canvas.getBoundingClientRect(); + const midX = ((points[0].x + points[1].x) / 2 - rect.left) * scale; + const midY = ((points[0].y + points[1].y) / 2 - rect.top) * scale; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, start.zoom * (dist / start.dist))); + const zoomRatio = newZoom / start.zoom; + // keep the content point under the start midpoint anchored to the live midpoint + const newOffX = midX - (start.midX - start.offX) * zoomRatio; + const newOffY = midY - (start.midY - start.offY) * zoomRatio; + setDpZoom(newZoom); + setDpPanX(newOffX - (canvasSize - canvasSize * newZoom) / 2); + setDpPanY(newOffY - (canvasSize - canvasSize * newZoom) / 2); }; - const handleDpMouseDown = (e: React.MouseEvent) => { - if (e.button === 1 || e.button === 2 || e.shiftKey) { + const beginPinch = () => { + const points = Array.from(activePointersRef.current.values()); + const canvas = dpCanvasRef.current; + if (!canvas || points.length < 2) return; + const rect = canvas.getBoundingClientRect(); + const scale = rect.width > 0 ? canvasSize / rect.width : 1; + pinchStartRef.current = { + dist: Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y) * scale, + midX: ((points[0].x + points[1].x) / 2 - rect.left) * scale, + midY: ((points[0].y + points[1].y) / 2 - rect.top) * scale, + zoom: dpZoom, + offX: (canvasSize - canvasSize * dpZoom) / 2 + dpPanX, + offY: (canvasSize - canvasSize * dpZoom) / 2 + dpPanY, + }; + }; + + const handleDpPointerDown = (e: React.PointerEvent) => { + try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* unsupported host */ } + activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (activePointersRef.current.size === 2) { + // second finger: switch to pinch, cancel any single-pointer gesture + multiTouchRef.current = true; + dpIsDragging.current = false; + pendingTapRef.current = null; + cancelCanvasDrag(); + beginPinch(); + return; + } + if (pinchStartRef.current) return; + if (e.button === 1 || e.button === 2 || e.shiftKey || (e.pointerType === "touch" && dpZoom !== 1 && drawMode === null && !moveSpots)) { dpIsDragging.current = true; dpDragStart.current = { x: e.clientX, y: e.clientY, panX: dpPanX, panY: dpPanY }; return; } const { row, col } = dpToImage(e); - if (!(row >= 0 && row < detRows && col >= 0 && col < detCols)) return; - // Manual mode: click sets the center instead of adding a spot. + // pixel-center coords: the detector spans [-0.5, det - 0.5) + if (!(row >= -0.5 && row < detRows - 0.5 && col >= -0.5 && col < detCols - 0.5)) return; + dragPosRef.current = { row, col }; + if (drawMode === "disk") { + dragTargetRef.current = { kind: "disk", row, col }; + setDragPreview({ kind: "disk", row, col, radius: 0 }); + return; + } + if (drawMode === "wedge") { + const start = angleOf(row, col); + dragTargetRef.current = { kind: "wedge", start_deg: start }; + setDragPreview({ kind: "wedge", start_deg: start, end_deg: start }); + return; + } + if (moveSpots && spots && spots.length > 0) { + // per-axis screen distance + const { scX, scY } = viewTransform(canvasSize, dpZoom, 0, 0, detRows, detCols); + let nearest = -1, nearestDist = Infinity; + spots.forEach((s, i) => { + const dist = Math.hypot((s.row - row) * scY, (s.col - col) * scX); + if (dist < nearestDist) { nearestDist = dist; nearest = i; } + }); + const hitPx = (e.pointerType === "touch" ? 20 : 12) * dpDisplayScale(); + if (nearest >= 0 && nearestDist <= hitPx) { + dragTargetRef.current = { kind: "spot", id: spots[nearest].id }; + setDragPreview({ kind: "spot", id: spots[nearest].id, row, col }); + return; + } + } + // touch: defer tap actions to pointerup so a pinch's first finger fires nothing + if (e.pointerType === "touch") { + pendingTapRef.current = { x: e.clientX, y: e.clientY, row, col }; + return; + } + commitTapAction(row, col); + }; + + const commitTapAction = (row: number, col: number) => { if (centerMode === "manual") { setCenterRow(row); setCenterCol(col); @@ -653,10 +1359,47 @@ function ShowDiffraction() { setSpotAddRequest([row, col]); }; - const handleDpMouseMove = (e: React.MouseEvent) => { + const handleDpPointerMove = (e: React.PointerEvent) => { + if (activePointersRef.current.has(e.pointerId)) { + activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + } + const pendingTap = pendingTapRef.current; + if (pendingTap && Math.hypot(e.clientX - pendingTap.x, e.clientY - pendingTap.y) > 10) { + pendingTapRef.current = null; // finger wandered: not a tap + } + if (pinchStartRef.current) { + e.preventDefault(); + if (!pinchRafRef.current) { + pinchRafRef.current = requestAnimationFrame(() => { + pinchRafRef.current = 0; + applyPinch(); + }); + } + return; + } if (dpIsDragging.current) { - setDpPanX(dpDragStart.current.panX + (e.clientX - dpDragStart.current.x)); - setDpPanY(dpDragStart.current.panY + (e.clientY - dpDragStart.current.y)); + const scale = dpDisplayScale(); + setDpPanX(dpDragStart.current.panX + (e.clientX - dpDragStart.current.x) * scale); + setDpPanY(dpDragStart.current.panY + (e.clientY - dpDragStart.current.y) * scale); + return; + } + if (dragTargetRef.current) { + dragPosRef.current = dpToImage(e); + if (!dragRafRef.current) { + dragRafRef.current = requestAnimationFrame(() => { + dragRafRef.current = 0; + const target = dragTargetRef.current; + if (!target) return; + const { row, col } = dragPosRef.current; + if (target.kind === "spot") { + setDragPreview({ kind: "spot", id: target.id, row, col }); + } else if (target.kind === "disk") { + setDragPreview({ kind: "disk", row: target.row, col: target.col, radius: Math.hypot(row - target.row, col - target.col) }); + } else { + setDragPreview({ kind: "wedge", start_deg: target.start_deg, end_deg: angleOf(row, col) }); + } + }); + } return; } if (!activeFrame) return; @@ -670,10 +1413,76 @@ function ShowDiffraction() { } }; - const handleDpMouseUp = () => { dpIsDragging.current = false; }; - const handleDpMouseLeave = () => { dpIsDragging.current = false; setCursorInfo(null); }; + const releasePointer = (e: React.PointerEvent) => { + activePointersRef.current.delete(e.pointerId); + if (activePointersRef.current.size === 0) multiTouchRef.current = false; + if (pinchStartRef.current) { + if (activePointersRef.current.size < 2) { + pinchStartRef.current = null; + cancelAnimationFrame(pinchRafRef.current); + pinchRafRef.current = 0; + dpIsDragging.current = false; + } else { + beginPinch(); // remaining fingers become the new baseline + } + } + }; - // Scroll to zoom. + const handleDpPointerUp = (e: React.PointerEvent) => { + const wasMultiTouch = multiTouchRef.current; + const pendingTap = pendingTapRef.current; + pendingTapRef.current = null; + releasePointer(e); + if (wasMultiTouch) return; + // double-tap resets the view (touch counterpart of double-click) + if (e.pointerType === "touch" && !dragTargetRef.current && !dpIsDragging.current) { + const now = performance.now(); + const last = lastTapRef.current; + if (now - last.time < 320 && Math.hypot(e.clientX - last.x, e.clientY - last.y) < 24) { + lastTapRef.current = { time: 0, x: 0, y: 0 }; + resetDpView(); + return; + } + lastTapRef.current = { time: now, x: e.clientX, y: e.clientY }; + } + if (pendingTap) { + commitTapAction(pendingTap.row, pendingTap.col); + return; + } + dpIsDragging.current = false; + const target = dragTargetRef.current; + if (!target) return; + const { row, col } = dragPosRef.current; + if (target.kind === "spot") { + setSpotMoveRequest([target.id, row, col]); + } else if (target.kind === "disk") { + const radius = Math.hypot(row - target.row, col - target.col); + if (radius >= 2) { + setMaskRegions([...(maskRegions || []), { kind: "disk", row: Math.round(target.row * 10) / 10, col: Math.round(target.col * 10) / 10, radius: Math.round(radius * 10) / 10 }]); + setShowMask(true); + } + setDrawMode(null); + } else { + const end = angleOf(row, col); + if (end !== target.start_deg) { + setMaskRegions([...(maskRegions || []), { kind: "wedge", start_deg: Math.round(target.start_deg * 10) / 10, end_deg: Math.round(end * 10) / 10 }]); + setShowMask(true); + } + setDrawMode(null); + } + cancelCanvasDrag(); + }; + const handleDpPointerCancel = (e: React.PointerEvent) => { + releasePointer(e); + pendingTapRef.current = null; + dpIsDragging.current = false; + cancelCanvasDrag(); + setCursorInfo(null); + }; + // pointer capture keeps drags alive past the edge; leave only clears the readout + const handleDpPointerLeave = () => { setCursorInfo(null); }; + + // Scroll zoom const handleDpWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; @@ -682,7 +1491,7 @@ function ShowDiffraction() { const resetDpView = () => { setDpZoom(1); setDpPanX(0); setDpPanY(0); }; - // Wheel scroll prevention + // Wheel guard const dpContainerRef = React.useRef(null); React.useEffect(() => { const prevent = (e: WheelEvent) => e.preventDefault(); @@ -711,7 +1520,7 @@ function ShowDiffraction() { dpCanvasRef.current.toBlob((b) => { if (b) downloadBlob(b, "showdiffraction_dp.png"); }, "image/png"); }; - // Request an HTML export; the effect below downloads the payload. + // HTML request const handleExportHtml = () => { setDpExportAnchor(null); exportCounterRef.current += 1; @@ -725,7 +1534,7 @@ function ShowDiffraction() { setExportRequest(JSON.stringify({ mode: "single", download: true, id, filename })); }; - // Download the HTML payload once it arrives. + // HTML payload React.useEffect(() => { if (!exportPayloadId || exportPayloadId !== pendingExportRef.current) return; const bytes = extractBytes(exportPayload); @@ -740,7 +1549,7 @@ function ShowDiffraction() { }, [exportPayload, exportPayloadId, exportPayloadFilename, setExportRequest]); // Keyboard - // Skip shortcuts while typing in a field. + // Typing guard const isTypingTarget = React.useCallback((target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; @@ -775,6 +1584,11 @@ function ShowDiffraction() { setSpotUndoRequest(true); handled = true; break; + case "Escape": + setDrawMode(null); + cancelCanvasDrag(); + handled = true; + break; } if (handled) { @@ -787,15 +1601,22 @@ function ShowDiffraction() { position: "relative" as const, border: `1px solid ${themeColors.border}`, overflow: "hidden", - width: canvasSize, - height: canvasSize, + width: "100%", + maxWidth: canvasSize, + aspectRatio: "1 / 1", bgcolor: "#000", + touchAction: "none" as const, + boxSizing: "border-box" as const, }; + const sideMenuWidth = 76; + const patternPanelWidth = canvasSize + sideMenuWidth + SPACING.XS; + // fluid panel: full width on small hosts, capped at the canvas size + const panelWidth = { width: "100%", maxWidth: canvasSize, boxSizing: "border-box" as const }; return ( @@ -852,7 +1674,7 @@ function ShowDiffraction() { > Export - setDpExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> + setDpExportAnchor(null)} {...downwardMenuProps}> PNG {exportEnabled && HTML} @@ -881,60 +1703,378 @@ function ShowDiffraction() { {/* DP panel */} - {/* Toolbar: general controls above the display */} + {/* Toolbar */} {controlsVisible && ( - - - + + + {nFrames > 1 && ( + + )} + + Center - setCenterMode(String(e.target.value))} sx={{ ...themedSelect, minWidth: 80 }} MenuProps={topToolbarMenuProps}> Auto Manual - Cmap - setDpColormap(e.target.value)} sx={themedSelect} MenuProps={topToolbarMenuProps}> {COLORMAP_NAMES.map(n => {n})} Scale - setDpScaleMode(e.target.value)} sx={{ ...themedSelect, minWidth: 60 }} MenuProps={topToolbarMenuProps}> Linear Log Sqrt - Invert - setDpInvert(v)} sx={switchStyles.small} /> {centerMode === "manual" && ( click to set )} )} + + setPhaseMenuAnchor(null)} {...downwardMenuProps}> + + Phase library + + setPhaseName("")} sx={{ fontSize: 11, minHeight: 24 }}>None + {(phaseLibrary || []).concat(customPhases || []).map((p) => ( + setPhaseName(p.name)} sx={{ fontSize: 11, minHeight: 24, display: "flex", justifyContent: "space-between" }}> + {p.name} + {[`a=${p.a}`, p.b != null ? `b=${p.b}` : "", p.c != null ? `c=${p.c}` : ""].filter(Boolean).join(" ")} · {p.absences} + + ))} + + + setCustomName(e.target.value)} placeholder="name" style={numInput(70)} /> + setCustomA(e.target.value)} placeholder="a (Å)" style={numInput(56)} /> + + + + + setCustomB(e.target.value)} placeholder="b=a" style={numInput(46)} /> + setCustomC(e.target.value)} placeholder="c=a" style={numInput(46)} /> + setCustomAlpha(e.target.value)} placeholder="α 90" style={numInput(46)} /> + setCustomBeta(e.target.value)} placeholder="β 90" style={numInput(46)} /> + setCustomGamma(e.target.value)} placeholder="γ 90" style={numInput(46)} /> + + + Identify candidates only + setIdentifyCustomOnly(v)} sx={switchStyles.small} /> + + + + + + + + + + + setMaskMenuAnchor(null)} {...downwardMenuProps}> + + Excluded regions + {(maskRegions || []).map((region, i) => ( + + + {region.kind === "wedge" + ? `wedge ${region.start_deg}°–${region.end_deg}°` + : `disk (${Number(region.row).toFixed(0)}, ${Number(region.col).toFixed(0)}) r=${region.radius}`} + + setMaskRegions(maskRegions.filter((_, j) => j !== i))} title="Remove" style={{ cursor: "pointer", color: themeColors.textMuted, fontWeight: "bold", padding: "0 4px" }}>× + + ))} + + + + + + setWedgeStart(e.target.value)} placeholder="start°" style={numInput(52)} /> + setWedgeEnd(e.target.value)} placeholder="end°" style={numInput(52)} /> + + + + setDiskRadius(e.target.value)} placeholder="radius px" style={numInput(66)} /> + + Show + setShowMask(v)} sx={switchStyles.small} /> + + + + + setRefineMenuAnchor(null)} {...downwardMenuProps}> + + Refine center + + + + + {centerMethod && ( + + {centerMethod} + + )} + + {nFrames > 1 ? `Frame ${localFrame + 1} / ${nFrames}` : "Diffraction"} + {drawMode && + drag on the pattern to draw the excluded {drawMode} (Esc cancels) + } + {moveSpots && !drawMode && + drag a spot to move it + } {cursorInfo && ({cursorInfo.row}, {cursorInfo.col}) {formatNumber(cursorInfo.value)} } - - - - + + + + + {/* Resize handle */} - + - {/* Frame slider (3D stacks only) */} + {/* Side menu */} + + {([ + ["Profile", "Radial profile", showProfile, () => setShowProfile(!showProfile)], + ["Azim", "Azimuthal profile", showAzimuthal, () => setShowAzimuthal(!showAzimuthal)], + ["HKL", "hkl labels", showHkl, () => setShowHkl(!showHkl)], + ["Mask View", "Mask overlay", showMask, () => setShowMask(!showMask)], + ["Invert", "Invert colormap", dpInvert, () => setDpInvert(!dpInvert)], + ["Stats", "Statistics", showStats, () => setShowStats(!showStats)], + ["Quality", "Analysis quality", showQc, toggleQuality], + ["Controls", "Control bar", showControls, () => setShowControls(!showControls)], + ] as [string, string, boolean, () => void][]).map(([label, hint, on, toggle]) => ( + + ))} + + + + {/* Quality */} + {showQc && ( + + Analysis quality + {quality?.center && ( + + Center: + {quality.center.method} + + )} + {quality?.calibration && ( + + Calibration: + {quality.calibration.source === "none" ? ( + uncalibrated + ) : ( + + {quality.calibration.source} · rms {quality.calibration.rms_px.toFixed(2)} px + + )} + + )} + {quality?.ellipse && ( + + Ellipse: + {quality.ellipse.ratio.toFixed(3)} @ {quality.ellipse.angle_deg.toFixed(1)}° · {quality.ellipse.corrected ? "corrected" : "not corrected"} + + )} + {quality?.rings && quality.rings.length > 0 && ( + + Ring fits: + {quality.rings.filter((r) => r.fit_quality >= 0.9).length}/{quality.rings.length} + + )} + {quality?.n_unexplained_rings != null && ( + + Unexplained rings: + + {quality.n_unexplained_rings} + + + )} + {quality?.mask_coverage_pct != null && ( + + Mask coverage: + {quality.mask_coverage_pct.toFixed(1)}% + + )} + {quality?.ring_snr && ( + + Ring SNR: + {quality.ring_snr.snr.toFixed(1)} · cov {quality.ring_snr.coverage.toFixed(2)} + + )} + + )} + + {/* Radial profile */} + {showProfile && ( + + + Radial profile + Log + setProfileLog(v)} sx={switchStyles.small} /> + −bg + setProfileSubtract(v)} sx={switchStyles.small} /> + click to add ring + {paneStaleNote && ( + {paneStaleNote} + )} + + + + )} + + {/* Azimuthal */} + {showAzimuthal && ( + + + Azimuthal profile (outermost ring) + {paneStaleNote && ( + {paneStaleNote} + )} + + + + )} + + {/* Candidates */} + {identifyResults && identifyResults.length > 0 && ( + + + Candidate phases + setIdentifyElements(e.target.value)} placeholder="elements e.g. Fe,O" style={numInput(110)} /> + candidates only + setIdentifyCustomOnly(v)} sx={switchStyles.small} /> + + + + {!identifyCollapsed && ( + + + + + + + + + + + + {identifyResults.map((cand) => ( + + setExpandedPhaseId(expandedPhaseId === cand.phase_id ? null : cand.phase_id)} + > + + + + + + {expandedPhaseId === cand.phase_id && ( + + + + )} + + ))} + +
namematchedΔd (%)missing
{cand.name}{cand.matched}/{cand.n_obs}{cand.mean_err != null ? (cand.mean_err * 100).toFixed(2) : "—"}{cand.n_missing_strong ?? "-"}
+ + + + + + + + + + + + + {(cand.lines || []).map((line, li) => { + const lineColor = line.ref_d == null ? statusColors.bad : line.obs_d == null ? themeColors.textMuted : undefined; + return ( + + + + + + + + + ); + })} + +
d_obs (Å)d_ref (Å)hklΔd (%)I_rel
{line.obs_d != null ? line.obs_d.toFixed(3) : "—"}{line.ref_d != null ? line.ref_d.toFixed(3) : "—"}{line.hkl || "—"}{line.err != null ? (line.err * 100).toFixed(2) : "—"}{line.i_rel != null ? line.i_rel.toFixed(0) : "—"}{line.ref_d == null ? "unexplained" : line.obs_d == null ? "missing" : ""}
+
+
+ )} +
+ )} + + {/* Frame slider */} {nFrames > 1 && ( - + Frame )} - {/* DP Stats */} - {showStats && dpStats && dpStats.length === 4 && ( + {/* Stats */} + {showStats && displayStats && displayStats.length === 4 && ( - Mean {formatStat(dpStats[0])} + Mean {formatStat(displayStats[0])} - Min {formatStat(dpStats[1])} + Min {formatStat(displayStats[1])} - Max {formatStat(dpStats[2])} + Max {formatStat(displayStats[2])} - Std {formatStat(dpStats[3])} + Std {formatStat(displayStats[3])} + + + )} + + {/* Indexing */} + {showStats && (zoneAxis || phaseMatch) && ( + + {zoneAxis && ( + + Zone {zoneAxis} + + )} + {phaseMatch && ( + + {phaseMatch} + + )} + + )} + + {/* Status */} + {analysisStatus && ( + + + {analysisStatus} )} - {/* Spots Table */} + {/* Spots */} - - Spots ({spots ? spots.length : 0}) - + + + Spots ({spots ? spots.length : 0}) + + + + @@ -1013,7 +2185,11 @@ function ShowDiffraction() { # d (Å) - |g| (1/Å·1/nm) + hkl + {spots.some((s) => s.d_ref != null) && ( + Δd (%) + )} + |g| (1/Å / 1/nm) ∠ (°) fit I @@ -1027,7 +2203,7 @@ function ShowDiffraction() { ? (spot.d_spacing_err ? `${spot.d_spacing.toFixed(3)}±${spot.d_spacing_err.toFixed(3)}` : spot.d_spacing.toFixed(3)) : "—"; const gStr = spot.g_magnitude != null - ? `${spot.g_magnitude.toFixed(4)}·${(spot.g_magnitude * 10).toFixed(3)}` + ? `${spot.g_magnitude.toFixed(4)} / ${(spot.g_magnitude * 10).toFixed(3)}` : `${spot.r_pixels.toFixed(1)} px`; const aStr = spot.angle_deg != null ? (spot.angle_deg_err ? `${spot.angle_deg.toFixed(1)}±${spot.angle_deg_err.toFixed(1)}` : spot.angle_deg.toFixed(1)) @@ -1036,6 +2212,10 @@ function ShowDiffraction() { {spot.id} {dStr} + {spot.hkl || "—"} + {spots.some((s) => s.d_ref != null) && ( + {spot.d_error != null ? (spot.d_error * 100).toFixed(2) : "—"} + )} {gStr} {aStr} {spot.fit_quality != null ? spot.fit_quality.toFixed(2) : "—"} @@ -1056,18 +2236,24 @@ function ShowDiffraction() { )} - {/* Rings Table */} - {rings && rings.length > 0 && ( - + {/* Rings */} + - - Rings ({rings.length}) - + + + Rings ({rings ? rings.length : 0}) + + + - - + + + + + + {rings && rings.length > 0 && ( @@ -1075,22 +2261,40 @@ function ShowDiffraction() { + + {rings.some((r) => r.d_ref != null) && ( + + )} + {rings.some((r) => r.fwhm_px != null) && ( + + )} {rings.map((ring: RingDict) => ( - + setSelectedRingId(selectedRingId === ring.id ? 0 : ring.id)} + > + + {rings.some((r) => r.d_ref != null) && ( + + )} + {rings.some((r) => r.fwhm_px != null) && ( + + )}
# radius (px) d (Å)hklΔd (%)|g| (1/Å)fwhm (px)I
{ring.id} {ring.radius_px.toFixed(1)} {ring.d_spacing != null ? ring.d_spacing.toFixed(3) : "—"}{ring.hkl || "—"}{ring.d_error != null ? (ring.d_error * 100).toFixed(2) : "—"}{ring.g_magnitude != null ? ring.g_magnitude.toFixed(4) : "—"}{ring.fwhm_px != null ? ring.fwhm_px.toFixed(2) : "—"}{formatNumber(ring.intensity)} setRingRemoveRequest(ring.id)} + onClick={(e) => { e.stopPropagation(); setRingRemoveRequest(ring.id); }} title="Delete this ring" style={{ cursor: "pointer", color: themeColors.textMuted, fontWeight: "bold", padding: "0 3px" }} >× @@ -1100,49 +2304,50 @@ function ShowDiffraction() {
-
- )} + )} +
{/* Fine controls below the display */} {controlsVisible && ( - - Refine - setSpotRefine(v)} - sx={switchStyles.small} - /> - Snap - setSnapEnabled(v)} - sx={switchStyles.small} - disabled={spotRefine} - /> - r - {snapRadius} + + Spot pick + - + { setDpVminPct(min); setDpVmaxPct(max); }} + onRangePreview={previewContrast} + onRangeCommit={commitContrast} theme={themeInfo.theme} /> - Calibrate d (Å): + Calibrate d (Å) setDKnown(e.target.value)} placeholder="2.355" - style={{ width: 64, fontSize: 10, padding: "2px 4px", background: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` }} + style={numInput(64)} /> + + Distortion + + Use Correction + setEllipseCorrected(v)} + sx={switchStyles.small} + title="Use fitted ellipse correction" + disabled={!(ellipseRatio > 1.0)} + /> + {ellipseRatio > 1.0 && ( + + a/b {ellipseRatio.toFixed(3)} @ {ellipseAngle.toFixed(1)}° + + )} + + Center: ({centerRow.toFixed(1)}, {centerCol.toFixed(1)}) BF r={bfRadius.toFixed(1)} @@ -1170,7 +2398,7 @@ function ShowDiffraction() { {kCalibrated && ( - Calib: {calibrationSource} + Calibration: {calibrationSourceLabel(calibrationSource)} {calibrationRefD > 0 && ` (d=${calibrationRefD.toFixed(3)} Å @ r=${calibrationRefRadius.toFixed(1)} px)`} diff --git a/js/showdiffraction/measurements.test.ts b/js/showdiffraction/measurements.test.ts new file mode 100644 index 00000000..5c7b1727 --- /dev/null +++ b/js/showdiffraction/measurements.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + MEASUREMENT_COLUMNS, + buildMeasurementRecords, + measurementCsv, + measurementMetadata, +} from "./measurements"; + +const SPOT = { + id: 1, + row: 10.5, + col: 20.25, + raw_row: 10.0, + raw_col: 20.0, + row_err: 0.1, + col_err: 0.2, + r_pixels: 30.0, + r_pixels_err: 0.15, + g_magnitude: 0.3, + g_magnitude_err: 0.001, + d_spacing: 3.33, + d_spacing_err: 0.02, + angle_deg: 45.0, + angle_deg_err: 0.5, + intensity: 999.0, + fit_quality: 0.98, + hkl: "111", + hkl_candidates: ["111", "200"], + note: "a,b", +}; + +const RING = { + id: 2, + radius_px: 60.0, + g_magnitude: 0.6, + d_spacing: 1.67, + intensity: 500.0, + fit_quality: 0.95, + fwhm_px: 2.5, + fwhm_inv_angstrom: 0.025, + intensity_integrated: 1234.0, + hkl: "220", + hkl_candidates: ["220"], +}; + +describe("MEASUREMENT_COLUMNS", () => { + it("mirrors the Python export schema", () => { + expect(MEASUREMENT_COLUMNS).toEqual([ + "id", "kind", "raw_row", "raw_col", "row", "col", "row_err", "col_err", + "r_pixels", "r_pixels_err", "g_inv_angstrom", "g_inv_angstrom_err", + "d_angstrom", "d_angstrom_err", "angle_deg", "angle_deg_err", + "intensity", "fit_quality", "fwhm_px", "fwhm_inv_angstrom", + "intensity_integrated", "hkl", "hkl_candidates", "note", + ]); + }); +}); + +describe("buildMeasurementRecords", () => { + it("keeps spots before rings and fills kind-specific nulls", () => { + const [spot, ring] = buildMeasurementRecords([SPOT], [RING]); + expect(spot.kind).toBe("spot"); + expect(spot.raw_row).toBe(10.0); + expect(spot.hkl_candidates).toBe("111|200"); + expect(spot.fwhm_px).toBeNull(); + expect(spot.fwhm_inv_angstrom).toBeNull(); + expect(spot.intensity_integrated).toBeNull(); + expect(ring.kind).toBe("ring"); + expect(ring.r_pixels).toBe(60.0); + expect(ring.fwhm_px).toBe(2.5); + expect(ring.fwhm_inv_angstrom).toBe(0.025); + expect(ring.intensity_integrated).toBe(1234.0); + expect(ring.hkl_candidates).toBe("220"); + expect(ring.row).toBeNull(); + expect(ring.angle_deg).toBeNull(); + for (const record of [spot, ring]) { + expect(Object.keys(record)).toEqual(MEASUREMENT_COLUMNS); + } + }); + + it("tolerates missing optional fields", () => { + const [spot] = buildMeasurementRecords([{ id: 3, row: 1, col: 2, r_pixels: 5, intensity: 7 }], []); + expect(spot.hkl_candidates).toBe(""); + expect(spot.d_angstrom).toBeNull(); + expect(spot.note).toBe(""); + }); +}); + +describe("measurementCsv", () => { + it("writes every column and escapes commas", () => { + const csv = measurementCsv(buildMeasurementRecords([SPOT], [RING])); + const lines = csv.split("\n"); + expect(lines[0]).toBe(MEASUREMENT_COLUMNS.join(",")); + expect(lines).toHaveLength(3); + expect(lines[1]).toContain("111|200"); + expect(lines[1]).toContain('"a,b"'); + expect(lines[2].split(",")[1]).toBe("ring"); + }); +}); + +describe("measurementMetadata", () => { + it("mirrors the Python metadata block", () => { + const metadata = measurementMetadata({ + centerRow: 64.5, + centerCol: 63.5, + centerMethod: "symmetry", + kPixelSize: 0.01, + kCalibrated: true, + calibrationSource: "from_phase", + calibrationRefD: 2.355, + calibrationRefRadius: 42.0, + maskRegions: [{ kind: "wedge", start_deg: 0, end_deg: 90 }], + backgroundSubtracted: false, + }); + expect(metadata).toEqual({ + widget_name: "ShowDiffraction", + center_row: 64.5, + center_col: 63.5, + center_method: "symmetry", + k_pixel_size_inv_angstrom_per_px: 0.01, + calibrated: true, + calibration_source: "from_phase", + calibration_ref_d_angstrom: 2.355, + calibration_ref_radius_px: 42.0, + mask_regions: [{ kind: "wedge", start_deg: 0, end_deg: 90 }], + background_subtracted: false, + }); + }); +}); diff --git a/js/showdiffraction/measurements.ts b/js/showdiffraction/measurements.ts new file mode 100644 index 00000000..bd9283ff --- /dev/null +++ b/js/showdiffraction/measurements.ts @@ -0,0 +1,180 @@ +/** ShowDiffraction browser-side measurement export. + * + * Column schema kept in sync with the Python exporter so browser CSV/JSON + * downloads match the notebook output. + */ + +export const MEASUREMENT_COLUMNS = [ + "id", + "kind", + "raw_row", + "raw_col", + "row", + "col", + "row_err", + "col_err", + "r_pixels", + "r_pixels_err", + "g_inv_angstrom", + "g_inv_angstrom_err", + "d_angstrom", + "d_angstrom_err", + "angle_deg", + "angle_deg_err", + "intensity", + "fit_quality", + "fwhm_px", + "fwhm_inv_angstrom", + "intensity_integrated", + "hkl", + "hkl_candidates", + "note", +] as const; + +export type MeasurementRecord = Record< + (typeof MEASUREMENT_COLUMNS)[number], + string | number | null +>; + +interface SpotLike { + id: number; + row: number; + col: number; + raw_row?: number | null; + raw_col?: number | null; + row_err?: number | null; + col_err?: number | null; + r_pixels: number; + r_pixels_err?: number | null; + g_magnitude?: number | null; + g_magnitude_err?: number | null; + d_spacing?: number | null; + d_spacing_err?: number | null; + angle_deg?: number | null; + angle_deg_err?: number | null; + intensity: number; + fit_quality?: number | null; + hkl?: string; + hkl_candidates?: string[]; + note?: string; +} + +interface RingLike { + id: number; + radius_px: number; + g_magnitude?: number | null; + d_spacing?: number | null; + intensity: number; + fit_quality?: number | null; + fwhm_px?: number | null; + fwhm_inv_angstrom?: number | null; + intensity_integrated?: number | null; + hkl?: string; + hkl_candidates?: string[]; + note?: string; +} + +/** Export row for one spot record. */ +export function spotMeasurementRecord(spot: SpotLike): MeasurementRecord { + return { + id: spot.id, + kind: "spot", + raw_row: spot.raw_row ?? null, + raw_col: spot.raw_col ?? null, + row: spot.row, + col: spot.col, + row_err: spot.row_err ?? null, + col_err: spot.col_err ?? null, + r_pixels: spot.r_pixels, + r_pixels_err: spot.r_pixels_err ?? null, + g_inv_angstrom: spot.g_magnitude ?? null, + g_inv_angstrom_err: spot.g_magnitude_err ?? null, + d_angstrom: spot.d_spacing ?? null, + d_angstrom_err: spot.d_spacing_err ?? null, + angle_deg: spot.angle_deg ?? null, + angle_deg_err: spot.angle_deg_err ?? null, + intensity: spot.intensity, + fit_quality: spot.fit_quality ?? null, + fwhm_px: null, + fwhm_inv_angstrom: null, + intensity_integrated: null, + hkl: spot.hkl ?? "", + hkl_candidates: (spot.hkl_candidates ?? []).join("|"), + note: spot.note ?? "", + }; +} + +/** Export row for one ring record. */ +export function ringMeasurementRecord(ring: RingLike): MeasurementRecord { + return { + id: ring.id, + kind: "ring", + raw_row: null, + raw_col: null, + row: null, + col: null, + row_err: null, + col_err: null, + r_pixels: ring.radius_px, + r_pixels_err: null, + g_inv_angstrom: ring.g_magnitude ?? null, + g_inv_angstrom_err: null, + d_angstrom: ring.d_spacing ?? null, + d_angstrom_err: null, + angle_deg: null, + angle_deg_err: null, + intensity: ring.intensity, + fit_quality: ring.fit_quality ?? null, + fwhm_px: ring.fwhm_px ?? null, + fwhm_inv_angstrom: ring.fwhm_inv_angstrom ?? null, + intensity_integrated: ring.intensity_integrated ?? null, + hkl: ring.hkl ?? "", + hkl_candidates: (ring.hkl_candidates ?? []).join("|"), + note: ring.note ?? "", + }; +} + +/** Export rows for all spots and rings. */ +export function buildMeasurementRecords(spots: SpotLike[], rings: RingLike[]): MeasurementRecord[] { + return [...spots.map(spotMeasurementRecord), ...rings.map(ringMeasurementRecord)]; +} + +/** CSV text with the full column schema. */ +export function measurementCsv(records: MeasurementRecord[]): string { + const esc = (v: string | number | null) => { + const s = v == null ? "" : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const rows = records.map((record) => MEASUREMENT_COLUMNS.map((c) => esc(record[c])).join(",")); + return [MEASUREMENT_COLUMNS.join(","), ...rows].join("\n"); +} + +interface MetadataState { + centerRow: number; + centerCol: number; + centerMethod: string; + kPixelSize: number; + kCalibrated: boolean; + calibrationSource: string; + calibrationRefD: number; + calibrationRefRadius: number; + maskRegions: object[]; + backgroundSubtracted: boolean; +} + +/** Export metadata block for CSV/JSON downloads. */ +export function measurementMetadata(state: MetadataState): object { + return { + widget_name: "ShowDiffraction", + center_row: state.centerRow, + center_col: state.centerCol, + center_method: state.centerMethod ?? "", + k_pixel_size_inv_angstrom_per_px: state.kPixelSize, + calibrated: Boolean(state.kCalibrated), + calibration_source: state.calibrationSource ?? "none", + calibration_ref_d_angstrom: state.calibrationRefD ?? 0, + calibration_ref_radius_px: state.calibrationRefRadius ?? 0, + mask_regions: state.maskRegions ?? [], + background_subtracted: Boolean(state.backgroundSubtracted), + }; +} diff --git a/js/showdiffraction/overlayGeometry.test.ts b/js/showdiffraction/overlayGeometry.test.ts new file mode 100644 index 00000000..1a7e11ba --- /dev/null +++ b/js/showdiffraction/overlayGeometry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + dataAngleToScreen, + dataColToScreenX, + dataRowToScreenY, + frameStats, + screenToData, + staleFrameNote, + viewTransform, +} from "./overlayGeometry"; + +describe("viewTransform", () => { + it("scales each detector axis independently on non-square detectors", () => { + // 128x256 detector on a 512 px square canvas: 2 px/col, 4 px/row + const t = viewTransform(512, 1, 0, 0, 128, 256); + expect(t.scX).toBe(2); + expect(t.scY).toBe(4); + // a ring point 40 px below the center lands 40 * scY screen px down + const cy = dataRowToScreenY(64, t); + expect(dataRowToScreenY(64 + 40, t) - cy).toBeCloseTo(40 * t.scY, 10); + }); + + it("applies zoom and pan offsets", () => { + const t = viewTransform(384, 2, 10, -6, 64, 64); + expect(t.scX).toBe(12); + expect(t.offX).toBe((384 - 768) / 2 + 10); + expect(t.offY).toBe((384 - 768) / 2 - 6); + }); +}); + +describe("pixel-center convention", () => { + const t = viewTransform(384, 1, 0, 0, 256, 256); + + it("maps a click on the visual center of pixel (i, j) to exactly (i, j)", () => { + const mx = t.offX + (100 + 0.5) * t.scX; + const my = t.offY + (40 + 0.5) * t.scY; + const { row, col } = screenToData(mx, my, t); + expect(col).toBeCloseTo(100, 10); + expect(row).toBeCloseTo(40, 10); + }); + + it("round-trips data coordinates through the screen", () => { + const mx = dataColToScreenX(123.25, t); + const my = dataRowToScreenY(45.75, t); + const { row, col } = screenToData(mx, my, t); + expect(col).toBeCloseTo(123.25, 10); + expect(row).toBeCloseTo(45.75, 10); + }); + + it("draws integer pixels at the middle of their screen cell", () => { + // pixel column j covers [offX + j*scX, offX + (j+1)*scX) + const j = 7; + expect(dataColToScreenX(j, t)).toBeCloseTo(t.offX + (j + 0.5) * t.scX, 10); + }); +}); + +describe("dataAngleToScreen", () => { + it("is the identity on square detectors", () => { + const t = viewTransform(384, 1, 0, 0, 256, 256); + expect(dataAngleToScreen(45, t)).toBeCloseTo(Math.PI / 4, 10); + }); + + it("stretches data angles by the per-axis scales", () => { + // scX=2, scY=4: a 45 deg data ray renders at atan2(4, 2) + const t = viewTransform(512, 1, 0, 0, 128, 256); + expect(dataAngleToScreen(45, t)).toBeCloseTo(Math.atan2(4, 2), 10); + expect(dataAngleToScreen(0, t)).toBeCloseTo(0, 10); + expect(dataAngleToScreen(90, t)).toBeCloseTo(Math.PI / 2, 10); + }); +}); + +describe("frameStats", () => { + it("computes mean, min, max, std", () => { + const [mean, min, max, std] = frameStats(new Float32Array([1, 2, 3, 4])); + expect(mean).toBeCloseTo(2.5, 6); + expect(min).toBe(1); + expect(max).toBe(4); + expect(std).toBeCloseTo(Math.sqrt(1.25), 6); + }); +}); + +describe("staleFrameNote", () => { + it("labels offline panes scrubbed away from the baked frame", () => { + expect(staleFrameNote(true, 4, 2, 0)).toBe("computed on frame 1"); + expect(staleFrameNote(true, 4, 0, 0)).toBeNull(); + expect(staleFrameNote(false, 4, 2, 0)).toBeNull(); + expect(staleFrameNote(true, 1, 0, 0)).toBeNull(); + }); +}); diff --git a/js/showdiffraction/overlayGeometry.ts b/js/showdiffraction/overlayGeometry.ts new file mode 100644 index 00000000..9567d4dc --- /dev/null +++ b/js/showdiffraction/overlayGeometry.ts @@ -0,0 +1,80 @@ +/** ShowDiffraction canvas geometry: data-space <-> screen-space mapping. + * + * Data coordinates use the pixel-center convention (integer index = sample + * location), matching the Python analysis grid. The square canvas stretches + * non-square detectors anisotropically, so each axis carries its own scale. + */ + +export interface ViewTransform { + scX: number; + scY: number; + offX: number; + offY: number; +} + +/** Per-axis scales and pan/zoom offsets for the square display canvas. */ +export function viewTransform( + canvasSize: number, + zoom: number, + panX: number, + panY: number, + detRows: number, + detCols: number, +): ViewTransform { + return { + scX: (canvasSize / Math.max(detCols, 1)) * zoom, + scY: (canvasSize / Math.max(detRows, 1)) * zoom, + offX: (canvasSize - canvasSize * zoom) / 2 + panX, + offY: (canvasSize - canvasSize * zoom) / 2 + panY, + }; +} + +/** Screen x of a data column (pixel-center). */ +export const dataColToScreenX = (col: number, t: ViewTransform) => t.offX + (col + 0.5) * t.scX; + +/** Screen y of a data row (pixel-center). */ +export const dataRowToScreenY = (row: number, t: ViewTransform) => t.offY + (row + 0.5) * t.scY; + +/** Data coordinates of a canvas point (pixel-center). */ +export function screenToData(mx: number, my: number, t: ViewTransform) { + return { row: (my - t.offY) / t.scY - 0.5, col: (mx - t.offX) / t.scX - 0.5 }; +} + +/** Data-space azimuth (deg, +col toward +row) to canvas arc angle (rad). */ +export function dataAngleToScreen(angleDeg: number, t: ViewTransform): number { + const a = (angleDeg * Math.PI) / 180; + return Math.atan2(Math.sin(a) * t.scY, Math.cos(a) * t.scX); +} + +/** Mean, min, max, std of a frame (offline stats for baked stacks). */ +export function frameStats(frame: Float32Array): [number, number, number, number] { + const n = frame.length; + if (n === 0) return [0, 0, 0, 0]; + let sum = 0; + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < n; i++) { + const v = frame[i]; + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + const mean = sum / n; + let sq = 0; + for (let i = 0; i < n; i++) { + const d = frame[i] - mean; + sq += d * d; + } + return [mean, min, max, Math.sqrt(sq / n)]; +} + +/** Offline panes are baked at export time; label them once scrubbed away. */ +export function staleFrameNote( + offline: boolean, + nFrames: number, + frameIdx: number, + bakedFrameIdx: number, +): string | null { + if (!offline || nFrames <= 1 || frameIdx === bakedFrameIdx) return null; + return `computed on frame ${bakedFrameIdx + 1}`; +} diff --git a/pyproject.toml b/pyproject.toml index 10eb5fe9..88ef5200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ # JupyterLab widget manager 5.0.10 restores saved metadata.widgets state on reopen. "jupyterlab_widgets>=3.0.10", "numpy>=2.0.0", + "scipy>=1.10.0", "traitlets>=5.0.0", "torch>=2.0.0", "matplotlib>=3.7.0", diff --git a/src/quantem/widget/__init__.py b/src/quantem/widget/__init__.py index 071ab8c2..ad59e0ee 100644 --- a/src/quantem/widget/__init__.py +++ b/src/quantem/widget/__init__.py @@ -24,6 +24,8 @@ "Show3DSlices": ("quantem.widget.show3dslices", "Show3DSlices"), "Show4DSTEM": ("quantem.widget.show4dstem_factory", "Show4DSTEM"), "ShowDiffraction": ("quantem.widget.showdiffraction", "ShowDiffraction"), + "Phase": ("quantem.widget.showdiffraction", "Phase"), + "library_phase": ("quantem.widget.showdiffraction", "library_phase"), "ShowEDS": ("quantem.widget.showeds", "ShowEDS"), "ShowFolder": ("quantem.widget.showfolder", "ShowFolder"), "ShowPtycho": ("quantem.widget.showptycho", "ShowPtycho"), @@ -207,7 +209,9 @@ def free_gpu(verbose: bool = True) -> float: "Show3D", "Show3DSlices", "Show4DSTEM", + "Phase", "ShowDiffraction", + "library_phase", "ShowEDS", "ShowFolder", "ShowPtycho", diff --git a/src/quantem/widget/data/__init__.py b/src/quantem/widget/data/__init__.py index 13937210..58b77271 100644 --- a/src/quantem/widget/data/__init__.py +++ b/src/quantem/widget/data/__init__.py @@ -15,6 +15,7 @@ "show2d_gold", "show3d_gold", "show4dstem_gold", + "showdiffraction_fe3o4", "showfolder_gold", } @@ -28,6 +29,7 @@ "show2d_gold", "show3d_gold", "show4dstem_gold", + "showdiffraction_fe3o4", "showfolder_gold", ] diff --git a/src/quantem/widget/data/tutorials.py b/src/quantem/widget/data/tutorials.py index 1a93b3cc..4215ee0a 100644 --- a/src/quantem/widget/data/tutorials.py +++ b/src/quantem/widget/data/tutorials.py @@ -44,6 +44,9 @@ _GOLD_4DSTEM_SOURCE_SIZE = "full" _SHOWFOLDER_GOLD_VIEWER = "showfolder" _SHOWFOLDER_GOLD_NAME = "gold-haadf-session" +_FE3O4_SAED_VIEWER = "showdiffraction" +_FE3O4_SAED_NAME = "fe3o4-saed" +_FE3O4_PACKAGED_PATH = Path(__file__).parent / "fe3o4_saed_512.npy" @dataclass(frozen=True) @@ -346,6 +349,69 @@ def showfolder_gold( return folder +def showdiffraction_fe3o4( + *, + size: str = "small", + cache_dir: str | Path | None = None, + revision: str | None = None, + force_download: bool = False, + verbose: bool = True, + allow_fallback: bool = True, +) -> np.ndarray: + """Load the real Fe3O4 nanoparticle SAED pattern used by the ShowDiffraction tutorial. + + The pattern is downloaded from + ``widget-tutorials/showdiffraction/fe3o4-saed`` and falls back to a packaged + copy when the download is unavailable. It is uncalibrated by design; the + tutorial calibrates it against the Fe3O4 phase. + + Parameters + ---------- + size + Tutorial payload size. Valid values are ``"small"``, ``"medium"``, + ``"large"``, and ``"full"``. The public upload provides the ``"small"`` + payload. + cache_dir + Optional Hugging Face cache directory. + revision + Optional Hugging Face dataset revision. + force_download + If ``True``, ask Hugging Face Hub to refresh the cached files. + verbose + If ``True``, print a short dataset summary. + allow_fallback + If ``True``, use the packaged pattern when the download is unavailable. + + Returns + ------- + np.ndarray + 512 by 512 float32 diffraction pattern. + """ + + size = _normalise_tutorial_size(size) + try: + folder = _download_widget_tutorial_folder( + _FE3O4_SAED_VIEWER, + _FE3O4_SAED_NAME, + size=size, + cache_dir=cache_dir, + revision=revision, + force_download=force_download, + ) + source = folder / "data.npy" + if not source.is_file(): + raise FileNotFoundError(f"tutorial pattern is missing: {source}") + except Exception: + if not allow_fallback or not _FE3O4_PACKAGED_PATH.is_file(): + raise + source = _FE3O4_PACKAGED_PATH + pattern = np.asarray(np.load(source), dtype=np.float32) + if verbose: + print(f"Fe3O4 SAED tutorial pattern: {source}") + print(f"Pattern: {pattern.shape[0]} x {pattern.shape[1]} {pattern.dtype}") + return pattern + + def _gold_haadf_2d_from_folder(folder: Path, *, stride: int, verbose: bool = True) -> Dataset2d: """Build a calibrated 2D HAADF preview from the shared tutorial source.""" diff --git a/src/quantem/widget/datasets.py b/src/quantem/widget/datasets.py index 19100643..7e8b5c21 100644 --- a/src/quantem/widget/datasets.py +++ b/src/quantem/widget/datasets.py @@ -7,6 +7,7 @@ show2d_gold, show3d_gold, show4dstem_gold, + showdiffraction_fe3o4, showfolder_gold, ) @@ -15,5 +16,6 @@ "show2d_gold", "show3d_gold", "show4dstem_gold", + "showdiffraction_fe3o4", "showfolder_gold", ] diff --git a/src/quantem/widget/showdiffraction.py b/src/quantem/widget/showdiffraction.py index eb1917eb..8f3cd422 100644 --- a/src/quantem/widget/showdiffraction.py +++ b/src/quantem/widget/showdiffraction.py @@ -1,41 +1,1676 @@ """ showdiffraction: Interactive d-spacing analysis for 2D/3D diffraction patterns. - -Pick Bragg spots and Debye-Scherrer rings on a diffraction pattern to read off -d-spacings and inter-spot angles, with sub-pixel Gaussian peak refinement. The -detector center can be set manually or auto-detected from the bright-field disk, -and k-space calibration (1/Å per pixel) is taken from metadata or solved from a -spot/ring of known d-spacing. - -A single 2D pattern is shown as a one-frame stack; a 3D ``(N, H, W)`` array is a -simple N-frame stack scrubbed with ``frame_idx``. 4D-STEM data is not handled -here -- use the separate Show4DSTEM widget for that. """ import csv import json import math import pathlib +import re import tempfile import time +import warnings +from collections.abc import Iterable, Sequence from typing import Self import anywidget import numpy as np import torch import traitlets +from scipy import ndimage +from scipy.optimize import linear_sum_assignment +from scipy.signal.windows import tukey -from quantem.widget.utils.array import to_numpy from quantem.widget.export import ensure_mobile_viewport +from quantem.widget.utils.array import to_numpy from quantem.widget.utils.state_io import resolve_widget_version, save_state_file, unwrap_state_payload from quantem.widget.utils.ui import UiMode, resolve_ui_mode -# ============================================================================ -# Constants -# ============================================================================ -DEFAULT_BF_RATIO = 0.125 # BF disk radius as fraction of detector size (1/8) + +# Crystal phases +def _allow_all(*_) -> bool: + return True + + +def _allow_fcc(h: int, k: int, ell: int) -> bool: + return (h % 2) == (k % 2) == (ell % 2) + + +def _allow_bcc(h: int, k: int, ell: int) -> bool: + return (h + k + ell) % 2 == 0 + + +def _allow_diamond(h: int, k: int, ell: int) -> bool: + return (h % 2 == k % 2 == ell % 2 == 1) or ( + h % 2 == k % 2 == ell % 2 == 0 and (h + k + ell) % 4 == 0 + ) + + +def _allow_hcp(h: int, k: int, ell: int) -> bool: + return not ((h + 2 * k) % 3 == 0 and ell % 2 == 1) + + +def _allow_rhombohedral(h: int, k: int, ell: int) -> bool: + return (-h + k + ell) % 3 == 0 + + +def _allow_rhombohedral_c(h: int, k: int, ell: int) -> bool: + if (h == 0 or k == 0 or h == -k) and ell % 2 == 1: + return False + return _allow_rhombohedral(h, k, ell) + + +def _allow_spinel(h: int, k: int, ell: int) -> bool: + if not _allow_fcc(h, k, ell): + return False + low, mid, high = sorted((abs(h), abs(k), abs(ell))) + if mid == 0: # h00 + return high % 4 == 0 + if low == 0: # hk0 + return (mid + high) % 4 == 0 + return True + + +def _allow_i41amd(h: int, k: int, ell: int) -> bool: + if (h + k + ell) % 2 != 0: + return False + if ell % 2 == 1: + return True + if ell % 4 == 0: # includes hk0 + return h % 2 == 0 + return h % 2 == 1 # l = 4n+2 + + +def _allow_rutile(h: int, k: int, ell: int) -> bool: + if h == 0 and (k + ell) % 2 == 1: + return False + if k == 0 and (h + ell) % 2 == 1: + return False + return True + + +def _allow_bixbyite(h: int, k: int, ell: int) -> bool: + if (h + k + ell) % 2 != 0: + return False + if h == 0 and (k % 2 == 1 or ell % 2 == 1): + return False + if k == 0 and (h % 2 == 1 or ell % 2 == 1): + return False + if ell == 0 and (h % 2 == 1 or k % 2 == 1): + return False + return True + + +def _allow_cuprite(h: int, k: int, ell: int) -> bool: + if (h + k + ell) % 2 == 0: + return True + return h % 2 == k % 2 == ell % 2 + + +_ABSENCE_RULES = { + "none": _allow_all, + "fcc": _allow_fcc, + "bcc": _allow_bcc, + "diamond": _allow_diamond, + "hcp": _allow_hcp, + "wurtzite": _allow_hcp, # 2b wurtzite sites zero the same reflections as hcp + "rhombohedral": _allow_rhombohedral, + "rhombohedral-c": _allow_rhombohedral_c, + "spinel": _allow_spinel, # Fd-3m d-glide: h00 needs h=4n, hk0 needs h+k=4n + "i41amd": _allow_i41amd, # I4_1/amd 4a/8e sites: 002, 110, 222 absent + "rutile": _allow_rutile, # P4_2/mnm n-glide: 0kl needs k+l even + "bixbyite": _allow_bixbyite, # Ia-3 a-glide: 0kl needs k, l even + "cuprite": _allow_cuprite, # Pn-3m 2a/4b sites: mixed parity with odd sum absent +} + +# Built-in standards: room-temperature lattice parameters in Å, cited per entry +# from license-clean sources (NIST SRM, NBS circulars/monographs, COD, or +# primary literature); no values from proprietary compilations. Non-cubic +# entries carry their source on the line above. +PHASE_LIBRARY = { + # fcc metals + "Au": {"a": 4.0782, "absences": "fcc"}, # COD 9008463 (Wyckoff 1963) + "Ag": {"a": 4.0855, "absences": "fcc"}, # COD 1100136 (Spreadborough & Christian 1959) + "Al": {"a": 4.0494, "absences": "fcc"}, # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "Cu": {"a": 3.6149, "absences": "fcc"}, # Lu & Chang 1941 (NBS Circ. 539 v1) + "Ni": {"a": 3.5238, "absences": "fcc"}, # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "Pt": {"a": 3.9236, "absences": "fcc"}, # Arblaster 1997, Platin. Met. Rev. 41 12, doi:10.1595/003214097X4111221 + "Pd": {"a": 3.8902, "absences": "fcc"}, # Arblaster 2012, Platin. Met. Rev. 56 181, doi:10.1595/147106712X646113 + "Pb": {"a": 4.9508, "absences": "fcc"}, # Klug 1946 (NBS Circ. 539 v1) + "Ir": {"a": 3.8392, "absences": "fcc"}, # Arblaster 2010, Platin. Met. Rev. 54 93, doi:10.1595/147106710X493124 + "Rh": {"a": 3.8034, "absences": "fcc"}, # Arblaster 1997, Platin. Met. Rev. 41 184 + # bcc metals + "α-Fe": {"a": 2.8665, "absences": "bcc"}, # COD 9008536 (Wyckoff 1963) + "W": {"a": 3.1652, "absences": "bcc"}, # NBS Mono. 25 Sec. 13 (internal standard) + "Cr": {"a": 2.8839, "absences": "bcc"}, # NBS Circ. 539 v5 (1955), COD 5000220 + "Mo": {"a": 3.1472, "absences": "bcc"}, # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "Nb": {"a": 3.3004, "absences": "bcc"}, # COD 9008546 (Wyckoff 1963) + "Ta": {"a": 3.3058, "absences": "bcc"}, # COD 9008552 (Wyckoff 1963) + "V": {"a": 3.0241, "absences": "bcc"}, # COD 9012770 (James & Straumanis 1960) + # diamond cubic + "Si": {"a": 5.4311, "absences": "diamond"}, # NIST SRM 640f + "Ge": {"a": 5.6578, "absences": "diamond"}, # COD 9011999 (Hom et al. 1975) + "C (diamond)": {"a": 3.5668, "absences": "diamond"}, # COD 9008564 (Wyckoff 1963) + "α-Sn": {"a": 6.4912, "absences": "diamond"}, # COD 9008568 (Wyckoff 1963) + # rocksalt + "MgO": {"a": 4.2130, "absences": "fcc"}, # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "NaCl": {"a": 5.6402, "absences": "fcc"}, # NBS Circ. 539 v2 (Swanson & Fuyat 1953) + "LiF": {"a": 4.0270, "absences": "fcc"}, # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "TiN": {"a": 4.2390, "absences": "fcc"}, # COD 1100037 (Christensen 1978) + "TiC": {"a": 4.3280, "absences": "fcc"}, # COD 9012564 (Christensen 1978) + "NiO": {"a": 4.1771, "absences": "fcc"}, # COD 4329325 (Malingowski et al. 2012) + "CaO": {"a": 4.8107, "absences": "fcc"}, # COD 7200686 (Verbraeken et al. 2009) + "ZrN": {"a": 4.5780, "absences": "fcc"}, # COD 1538058 (Gatterer et al. 1975) + "CrN": {"a": 4.1480, "absences": "fcc"}, # COD 1008956 (Nasr Eddine et al. 1977) + "TaC": {"a": 4.4540, "absences": "fcc"}, # COD 9008731 (Wyckoff 1963) + "NbC": {"a": 4.4691, "absences": "fcc"}, # COD 9008682 (Wyckoff 1963) + "ZrC": {"a": 4.7004, "absences": "fcc"}, # COD 1562921 (Chinthaka Silva et al. 2012) + # fluorite + "CaF2": {"a": 5.4630, "absences": "fcc"}, # COD 9009005 (Wyckoff 1963) + "CeO2": {"a": 5.4115, "absences": "fcc"}, # NIST SRM 674b + "UO2": {"a": 5.4704, "absences": "fcc"}, # Grønvold 1955, J. Inorg. Nucl. Chem. 1 357, doi:10.1016/0022-1902(55)80046-2 + # zincblende + "GaAs": {"a": 5.6533, "absences": "fcc"}, # Straumanis & Kim 1965, J. Appl. Phys. 36 3822 + "GaP": {"a": 5.4505, "absences": "fcc"}, # COD 9008846 (Wyckoff 1963) + "InP": {"a": 5.8687, "absences": "fcc"}, # COD 9008852 (Wyckoff 1963) + "InAs": {"a": 6.0580, "absences": "fcc"}, # NBS Mono. 25 Sec. 3 (1964) + "ZnS": {"a": 5.4093, "absences": "fcc"}, # COD 9000107 (Skinner 1961) + "ZnSe": {"a": 5.6676, "absences": "fcc"}, # COD 9008857 (Wyckoff 1963) + "CdTe": {"a": 6.4810, "absences": "fcc"}, # NBS Mono. 25 Sec. 3 (1964) + "3C-SiC": {"a": 4.3596, "absences": "fcc"}, # Sultan et al. 2022, Materials 15 6229, doi:10.3390/ma15186229 + "CuI": {"a": 6.0630, "absences": "fcc"}, # COD 9004456 (Cooper & Hawthorne 1997) + # spinel + "Fe3O4": {"a": 8.3967, "absences": "spinel"}, # COD 9013529 (Bosi et al. 2009) + "γ-Fe2O3": {"a": 8.3474, "absences": "spinel"}, # COD 9017489 (Shmakov et al. 1995) + "MgAl2O4": {"a": 8.0836, "absences": "spinel"}, # COD 9002044 (Redfern et al. 1999) + "Co3O4": {"a": 8.0821, "absences": "spinel"}, # COD 9005887 (Liu & Prewitt 1990) + "CoFe2O4": {"a": 8.3806, "absences": "spinel"}, # COD 1533163 (Ferreira et al. 2003) + "NiFe2O4": {"a": 8.3390, "absences": "spinel"}, # Hill et al. 1979, Phys. Chem. Miner. 4 317, doi:10.1007/BF00307535 + "ZnFe2O4": {"a": 8.4421, "absences": "spinel"}, # COD 9005102 (O'Neill 1992) + # primitive cubic + "SrTiO3": {"a": 3.9050, "absences": "none"}, # NBS Circ. 539 v3 (Swanson et al. 1954) + "CsCl": {"a": 4.1230, "absences": "none"}, # NBS Circ. 539 v2 (Swanson & Fuyat 1953) + # additional cubic phases + "Th": {"a": 5.0843, "absences": "fcc"}, # COD 9008485 (Wyckoff 1963) + "KCl": {"a": 6.2931, "absences": "fcc"}, # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "KBr": {"a": 6.6000, "absences": "fcc"}, # COD 9008650 (Wyckoff 1963) + "CoO": {"a": 4.2630, "absences": "fcc"}, # COD 1533087 (Sasaki et al. 1979) + "MnO": {"a": 4.4449, "absences": "fcc"}, # COD 9005946 (Pacalo & Graham 1991) + "PbS": {"a": 5.9362, "absences": "fcc"}, # NBS Circ. 539 v2 (Swanson & Fuyat 1953) + "PbSe": {"a": 6.1243, "absences": "fcc"}, # COD 9008695 (Wyckoff 1963) + "PbTe": {"a": 6.4541, "absences": "fcc"}, # COD 9011358 (Noda et al. 1987) + "AgCl": {"a": 5.5491, "absences": "fcc"}, # NBS Circ. 539 v4 (Swanson et al. 1955) + "AgBr": {"a": 5.7745, "absences": "fcc"}, # NBS Circ. 539 v4 (Swanson et al. 1955) + "ThO2": {"a": 5.5997, "absences": "fcc"}, # COD 9009046 (Wyckoff 1963) + "BaF2": {"a": 6.2001, "absences": "fcc"}, # COD 9009004 (Wyckoff 1963) + "SrF2": {"a": 5.7996, "absences": "fcc"}, # COD 9009043 (Wyckoff 1963) + "AlAs": {"a": 5.6608, "absences": "fcc"}, # COD 1540257 (Leszczynski et al. 1992) + "GaSb": {"a": 6.0959, "absences": "fcc"}, # Straumanis & Kim 1965, J. Appl. Phys. 36 3822 + "InSb": {"a": 6.4794, "absences": "fcc"}, # Straumanis & Kim 1965, J. Appl. Phys. 36 3822 + "ZnTe": {"a": 6.1026, "absences": "fcc"}, # COD 1540103 (Holland & Beck 1968) + "c-BN": {"a": 3.6153, "absences": "fcc"}, # Kurdyumov et al. 1995, J. Appl. Cryst. 28 540, doi:10.1107/S002188989500197X + "γ-Al2O3": {"a": 7.9140, "absences": "spinel"}, # COD 2107301 (Zhou & Snyder 1991) + "Y2O3": {"a": 10.6040, "absences": "bixbyite"}, # COD 1513300 (Ferreira et al. 2005) + "In2O3": {"a": 10.1170, "absences": "bixbyite"}, # COD 2310009 (Marezio 1966) + "LaB6": {"a": 4.1568, "absences": "none"}, # NIST SRM 660c + "Cu2O": {"a": 4.2696, "absences": "cuprite"}, # NBS Circ. 539 v2 (Swanson & Fuyat 1953) + # tetragonal + # NIST SRM 674b + "TiO2 (rutile)": {"a": 4.5940, "c": 2.9589, "gamma": 90.0, "absences": "rutile"}, + # NBS Mono. 25 Sec. 7 (1969) + "TiO2 (anatase)": {"a": 3.7852, "c": 9.5139, "gamma": 90.0, "absences": "i41amd"}, + # COD 2101853 (Bolzan et al. 1997) + "SnO2": {"a": 4.7374, "c": 3.1864, "gamma": 90.0, "absences": "rutile"}, + # COD 1534488 (Lee & Raynor 1954) + "β-Sn": {"a": 5.8317, "c": 3.1813, "gamma": 90.0, "absences": "i41amd"}, + # NBS Circ. 539 v3 (Swanson & Fuyat 1954) + "BaTiO3": {"a": 3.9940, "c": 4.0380, "gamma": 90.0, "absences": "none"}, + # primitive hexagonal + # COD 1501516 (Litasov et al. 2010) + "WC": {"a": 2.9059, "c": 2.8377, "gamma": 120.0, "absences": "none"}, + # COD 2002799 (Möhr et al. 1996) + "TiB2": {"a": 3.0292, "c": 3.2284, "gamma": 120.0, "absences": "none"}, + # wurtzite + # NIST SRM 674b + "ZnO": {"a": 3.2499, "c": 5.2067, "gamma": 120.0, "absences": "wurtzite"}, + # Detchprohm et al. 1992, Jpn. J. Appl. Phys. 31 L1454, doi:10.1143/JJAP.31.L1454 + "GaN": {"a": 3.1892, "c": 5.1850, "gamma": 120.0, "absences": "wurtzite"}, + # Schulz & Thiemann 1977, Solid State Commun. 23 815, doi:10.1016/0038-1098(77)90959-0 + "AlN": {"a": 3.1100, "c": 4.9800, "gamma": 120.0, "absences": "wurtzite"}, + # Paszkowicz 1999, Powder Diffr. 14 258 + "InN": {"a": 3.5378, "c": 5.7033, "gamma": 120.0, "absences": "wurtzite"}, + # COD 9011663 (Xu & Ching 1993) + "CdS (wurtzite)": {"a": 4.1370, "c": 6.7144, "gamma": 120.0, "absences": "wurtzite"}, + # COD 9011664 (Xu & Ching 1993) + "CdSe (wurtzite)": {"a": 4.2985, "c": 7.0152, "gamma": 120.0, "absences": "wurtzite"}, + # COD 1100044 (Kisi & Elcombe 1989) + "ZnS (wurtzite)": {"a": 3.8227, "c": 6.2607, "gamma": 120.0, "absences": "wurtzite"}, + # COD 1529745 (Cava et al. 1977) + "β-AgI": {"a": 4.5980, "c": 7.5140, "gamma": 120.0, "absences": "wurtzite"}, + # rhombohedral, R-3c/R3c (c glide) + # NIST SRM 676a + "α-Al2O3": {"a": 4.7594, "c": 12.9923, "gamma": 120.0, "absences": "rhombohedral-c"}, + # NBS Mono. 25 Sec. 18 (1981) + "α-Fe2O3 (hematite)": { + "a": 5.0356, + "c": 13.7489, + "gamma": 120.0, + "absences": "rhombohedral-c", + }, + # NIST SRM 674b + "Cr2O3": {"a": 4.9586, "c": 13.5965, "gamma": 120.0, "absences": "rhombohedral-c"}, + # NBS Circ. 539 v2 (Swanson & Fuyat 1953) + "CaCO3 (calcite)": {"a": 4.9890, "c": 17.0620, "gamma": 120.0, "absences": "rhombohedral-c"}, + # COD 1541936 (Abrahams et al. 1966) + "LiNbO3": {"a": 5.1483, "c": 13.8631, "gamma": 120.0, "absences": "rhombohedral-c"}, + # rhombohedral, R-3m + # COD 2310889 (Cucka & Barrett 1962) + "Bi": {"a": 4.5460, "c": 11.8620, "gamma": 120.0, "absences": "rhombohedral"}, + # COD 5000214 (Barrett et al. 1963) + "Sb": {"a": 4.3084, "c": 11.2740, "gamma": 120.0, "absences": "rhombohedral"}, + # hcp metals + graphite + # NBS Circ. 539 v3 (Swanson et al. 1954) + "Ti": {"a": 2.9500, "c": 4.6860, "gamma": 120.0, "absences": "hcp"}, + # Jette & Foote 1935 (NBS Circ. 539 v1) + "Zn": {"a": 2.6649, "c": 4.9468, "gamma": 120.0, "absences": "hcp"}, + # NBS Circ. 539 v1 (Swanson & Tatge 1953) + "Mg": {"a": 3.2094, "c": 5.2103, "gamma": 120.0, "absences": "hcp"}, + # COD 9008492 (Wyckoff 1963) + "Co": {"a": 2.5071, "c": 4.0686, "gamma": 120.0, "absences": "hcp"}, + # NBS Circ. 539 v2 (Swanson & Fuyat 1953) + "Zr": {"a": 3.2320, "c": 5.1470, "gamma": 120.0, "absences": "hcp"}, + # NBS Circ. 539 v4 (Swanson et al. 1955) + "Ru": {"a": 2.7058, "c": 4.2819, "gamma": 120.0, "absences": "hcp"}, + # Mackay & Hill 1963 (NBS Mono. 25 Sec. 9) + "Be": {"a": 2.2858, "c": 3.5843, "gamma": 120.0, "absences": "hcp"}, + # NBS Circ. 539 v3 (Swanson et al. 1954) + "Cd": {"a": 2.9793, "c": 5.6181, "gamma": 120.0, "absences": "hcp"}, + # COD 9008512 (Wyckoff 1963) + "Re": {"a": 2.7608, "c": 4.4582, "gamma": 120.0, "absences": "hcp"}, + # NBS Circ. 539 v4 (Swanson et al. 1955) + "Os": {"a": 2.7341, "c": 4.3197, "gamma": 120.0, "absences": "hcp"}, + # Russell 1953 (COD 1539076) + "Hf": {"a": 3.1964, "c": 5.0511, "gamma": 120.0, "absences": "hcp"}, + # Spedding et al. 1956 (COD 9010984) + "Y": {"a": 3.6474, "c": 5.7306, "gamma": 120.0, "absences": "hcp"}, + # Trucano & Chen 1975 (COD 9011577) + "C (graphite)": {"a": 2.4640, "c": 6.7110, "gamma": 120.0, "absences": "hcp"}, +} + + +def library_phase(name: str) -> "Phase": + """Build a :class:`Phase` from the built-in standards library.""" + if name not in PHASE_LIBRARY: + raise ValueError(f"unknown library phase {name!r}; available: {sorted(PHASE_LIBRARY)}") + entry = PHASE_LIBRARY[name] + a, absences = entry["a"], entry["absences"] + if "c" in entry: + gamma = entry.get("gamma", 90.0) + return Phase(name, a, a, entry["c"], 90.0, 90.0, gamma, absences=absences) + return Phase.from_cubic(name, a, absences=absences) + + +def _format_hkl(hkl: Sequence[float]) -> str: + indices = tuple(int(i) for i in hkl) + if all(0 <= i < 10 for i in indices): + return "".join(str(i) for i in indices) + return "(" + ",".join(str(i) for i in indices) + ")" + + +def _parse_hkl_label(label: str) -> tuple[int, int, int] | None: + body = label.strip().strip("()") + try: + if "," in body: + parts = [int(p) for p in body.split(",")] + else: + parts = [int(c) for c in body] + except ValueError: + return None + return tuple(parts) if len(parts) == 3 else None + + +def _is_cubic_lattice(lattice: tuple[float, float, float, float, float, float] | None) -> bool: + if lattice is None: + return False + a, b, c, alpha, beta, gamma = lattice + return ( + math.isclose(a, b) + and math.isclose(b, c) + and math.isclose(alpha, 90.0) + and math.isclose(beta, 90.0) + and math.isclose(gamma, 90.0) + ) + + +def _is_orthogonal_lattice(lattice: tuple[float, float, float, float, float, float]) -> bool: + return ( + math.isclose(lattice[3], 90.0) + and math.isclose(lattice[4], 90.0) + and math.isclose(lattice[5], 90.0) + ) + + +def _canonical_hkl( + hkl: Sequence[float], + lattice: tuple[float, float, float, float, float, float] | None, +) -> tuple[int, int, int]: + indices = tuple(int(i) for i in hkl) + if _is_cubic_lattice(lattice): + return tuple(sorted((abs(i) for i in indices), reverse=True)) + if lattice is not None and _is_orthogonal_lattice(lattice): + h, k, ell = (abs(i) for i in indices) + if math.isclose(lattice[0], lattice[1]): + h, k = sorted((h, k), reverse=True) + return (h, k, ell) + for value in indices: + if value < 0: + return tuple(-i for i in indices) + if value > 0: + return indices + return indices + + +def _label_preference(hkl: tuple[int, int, int]) -> tuple[int, tuple[int, int, int]]: + """Sort key preferring the conventional family label: fewest negative + indices, then lexicographically largest (h before k before l).""" + return (sum(1 for i in hkl if i < 0), tuple(-i for i in hkl)) + + +class Phase: + """A crystalline phase: lattice parameters (Å, degrees) + absence rule for + geometry-aware indexing, or a reference d-spacing card for pure matching. + """ + + def __init__( + self, + name: str, + a: float, + b: float, + c: float, + alpha: float = 90.0, + beta: float = 90.0, + gamma: float = 90.0, + absences: str = "none", + _reference_lines: list[tuple[float, str, float | None]] | None = None, + ) -> None: + self.name = name + self.absences = absences + if absences not in _ABSENCE_RULES: + raise ValueError(f"unknown absence rule {absences!r}; use {list(_ABSENCE_RULES)}") + self._allowed_rule = _ABSENCE_RULES[absences] + self._reference_lines = _reference_lines + self._reflection_cache: list[dict] | None = None + + if _reference_lines is None: + if min(a, b, c) <= 0: + raise ValueError("lattice edge lengths must be positive") + if not all(0.0 < angle < 180.0 for angle in (alpha, beta, gamma)): + raise ValueError("cell angles must be strictly between 0 and 180 degrees") + self.lattice = (float(a), float(b), float(c), float(alpha), float(beta), float(gamma)) + ca, cb, cg = (math.cos(math.radians(x)) for x in (alpha, beta, gamma)) + g = np.array( + [ + [a * a, a * b * cg, a * c * cb], + [a * b * cg, b * b, b * c * ca], + [a * c * cb, b * c * ca, c * c], + ], + dtype=np.float64, + ) + if np.linalg.det(g) <= 0: + raise ValueError("degenerate cell: angles do not form a valid lattice") + self._g_star = np.linalg.inv(g) + else: + self.lattice = None + self._g_star = None + + # --- Constructors --- + @classmethod + def from_cubic(cls, name: str, a: float, absences: str = "fcc") -> "Phase": + """Cubic phase with edge ``a`` (Å) and a systematic-absence rule.""" + return cls(name, a, a, a, 90.0, 90.0, 90.0, absences=absences) + + @classmethod + def from_dspacings(cls, name: str, entries: Iterable[Sequence]) -> "Phase": + """Phase from reference entries: ``(d_Å, hkl_label[, intensity])``.""" + reference_lines = [] + for entry in entries: + spacing, label = entry[0], entry[1] + intensity = float(entry[2]) if len(entry) > 2 else None + reference_lines.append((float(spacing), str(label), intensity)) + return cls(name, 1.0, 1.0, 1.0, _reference_lines=reference_lines) + + # --- Geometry --- + def d_spacing(self, hkl: Sequence[float]) -> float: + """Interplanar spacing d_hkl in Å.""" + if self._g_star is None: + raise ValueError("d_spacing requires a lattice-based Phase, not a d-spacing table") + indices = np.asarray(hkl, dtype=np.float64) + inverse_d_squared = float(indices @ self._g_star @ indices) + if inverse_d_squared <= 0: + raise ValueError("invalid reflection (000)") + return 1.0 / math.sqrt(inverse_d_squared) + + def plane_angle(self, hkl1: Sequence[float], hkl2: Sequence[float]) -> float: + """Angle in degrees between plane normals (hkl1) and (hkl2).""" + if self._g_star is None: + raise ValueError("plane_angle requires a lattice-based Phase, not a d-spacing table") + indices1 = np.asarray(hkl1, dtype=np.float64) + indices2 = np.asarray(hkl2, dtype=np.float64) + numerator = float(indices1 @ self._g_star @ indices2) + denominator = math.sqrt( + float(indices1 @ self._g_star @ indices1) * float(indices2 @ self._g_star @ indices2) + ) + if denominator == 0: + return 0.0 + return math.degrees(math.acos(max(-1.0, min(1.0, numerator / denominator)))) + + def is_allowed(self, hkl: Sequence[float]) -> bool: + """Whether (hkl) is a non-origin reflection permitted by the absence rule.""" + h, k, ell = (int(i) for i in hkl) + if h == 0 and k == 0 and ell == 0: + return False + return bool(self._allowed_rule(h, k, ell)) + + # --- Reflections --- + def reflections(self, d_min: float = 0.5, max_index: int | None = None) -> list[dict]: + """Allowed reflection families, largest d first. + + By default ``max_index`` is sized so every family above ``d_min`` is + enumerated; ``d_min`` is floored at 0.2 Å and ``max_index`` capped at + 25 to keep the enumeration bounded. + """ + if self._reference_lines is not None: + reflections = [ + { + "hkl": _parse_hkl_label(label), + "hkl_str": label, + "d": spacing, + "multiplicity": None, + "intensity": intensity, + } + for spacing, label, intensity in self._reference_lines + if spacing >= d_min + ] + return sorted(reflections, key=lambda reflection: -reflection["d"]) + + d_min = max(float(d_min), 0.2) + if max_index is None: + max_index = math.ceil(max(self.lattice[:3]) / d_min) + max_index = min(int(max_index), 25) + distinct_labels = _is_orthogonal_lattice(self.lattice) + families_by_d: dict[int, dict] = {} + for h in range(-max_index, max_index + 1): + for k in range(-max_index, max_index + 1): + for ell in range(-max_index, max_index + 1): + hkl = (h, k, ell) + if not self.is_allowed(hkl): + continue + spacing = self.d_spacing(hkl) + if spacing < d_min: + continue + d_key = int(round(spacing * 1e4)) + representative = _canonical_hkl(hkl, self.lattice) + family = families_by_d.setdefault(d_key, {"d": spacing, "labels": {}}) + labels = family["labels"] + labels[representative] = labels.get(representative, 0) + 1 + reflections = [] + for family in families_by_d.values(): + labels = family["labels"] + if distinct_labels: + ordered = sorted( + labels.items(), key=lambda entry: (-entry[1], _label_preference(entry[0])) + ) + representative = ordered[0][0] + hkl_str = "/".join(_format_hkl(label) for label, _ in ordered) + else: + representative = min(labels, key=_label_preference) + hkl_str = _format_hkl(representative) + reflections.append( + { + "hkl": representative, + "hkl_str": hkl_str, + "d": family["d"], + "multiplicity": sum(labels.values()), + "intensity": None, + } + ) + return sorted(reflections, key=lambda reflection: -reflection["d"]) + + def _all_reflections(self) -> list[dict]: + if self._reflection_cache is None: + self._reflection_cache = self.reflections() + return self._reflection_cache + + def match_d(self, d: float, tol: float = 0.03) -> list[dict]: + """Reflections within fractional ``tol`` of ``d``, closest first. + + Errors are relative to the reference d, matching + :func:`match_candidate`. + """ + if d <= 0: + return [] + matches = [] + for reflection in self._all_reflections(): + error = abs(reflection["d"] - d) / reflection["d"] + if error <= tol: + matches.append({**reflection, "d_error": error}) + return sorted(matches, key=lambda reflection: reflection["d_error"]) + + +# Center estimation +# --- Internal helpers --- +def _bandpass(frame: np.ndarray, mask: np.ndarray | None = None, log: bool = True) -> np.ndarray: + work = np.log1p(frame - frame.min()) if log else frame - frame.min() + sigma = max(5.0, 0.02 * min(work.shape)) + work = work - ndimage.gaussian_filter(work, sigma=sigma) + if mask is not None: + work[mask] = 0.0 + return work - work.mean() + + +def _parabolic_offset(values: np.ndarray, p: int, n: int) -> float: + lo, hi = values[(p - 1) % n], values[(p + 1) % n] + denom = 2.0 * values[p] - lo - hi + if denom <= 0: + return 0.0 + delta = 0.5 * (hi - lo) / denom + return delta if abs(delta) <= 1.0 else 0.0 + + +def _peak_to_sidelobe(corr: np.ndarray, p_row: int, p_col: int, exclude: float = 5.0) -> float: + n_rows, n_cols = corr.shape + row_dist = np.abs(np.arange(n_rows) - p_row) + col_dist = np.abs(np.arange(n_cols) - p_col) + row_dist = np.minimum(row_dist, n_rows - row_dist) + col_dist = np.minimum(col_dist, n_cols - col_dist) + outside = (row_dist[:, None] > exclude) | (col_dist[None, :] > exclude) + side = corr[outside] + spread = side.std() + if spread <= 0: + return 0.0 + return float((corr[p_row, p_col] - side.mean()) / spread) + + +def _upsampled_peak( + cross: np.ndarray, row0: float, col0: float, upsample: int +) -> tuple[float, float]: + n_rows, n_cols = cross.shape + f_row = np.fft.fftfreq(n_rows) + f_col = np.fft.fftfreq(n_cols) + offsets = np.arange(-int(np.ceil(1.5 * upsample)), int(np.ceil(1.5 * upsample)) + 1) / upsample + rows = row0 + offsets + cols = col0 + offsets + e_row = np.exp(2j * np.pi * rows[:, None] * f_row[None, :]) + e_col = np.exp(2j * np.pi * f_col[:, None] * cols[None, :]) + local = (e_row @ cross @ e_col).real + p_row, p_col = np.unravel_index(int(np.argmax(local)), local.shape) + d_row = _parabolic_offset(local[:, p_col], p_row, local.shape[0]) / upsample + d_col = _parabolic_offset(local[p_row, :], p_col, local.shape[1]) / upsample + return float(rows[p_row] + d_row), float(cols[p_col] + d_col) + + +def _phase_shift( + ref: np.ndarray, moving: np.ndarray, upsample: int = 1 +) -> tuple[float, float, float]: + window = tukey(ref.shape[0], 0.2)[:, None] * tukey(ref.shape[1], 0.2)[None, :] + ref = ref * window + moving = moving * window + cross = np.fft.fft2(ref) * np.conj(np.fft.fft2(moving)) + cross = cross / np.maximum(np.abs(cross), 1e-12) + f_row = np.fft.fftfreq(ref.shape[0])[:, None] + f_col = np.fft.fftfreq(ref.shape[1])[None, :] + cross = cross * np.exp(-(f_row**2 + f_col**2) / (2.0 * 0.15**2)) + corr = np.fft.ifft2(cross).real + n_rows, n_cols = corr.shape + p_row, p_col = np.unravel_index(int(np.argmax(corr)), corr.shape) + psr = _peak_to_sidelobe(corr, p_row, p_col) + if upsample > 1: + s_row, s_col = _upsampled_peak(cross, float(p_row), float(p_col), upsample) + else: + s_row = p_row + _parabolic_offset(corr[:, p_col], p_row, n_rows) + s_col = p_col + _parabolic_offset(corr[p_row, :], p_col, n_cols) + return float(s_row), float(s_col), psr + + +def _wrap_signed(shift: float, n: int) -> float: + shift = shift % n + return shift - n if shift > n / 2 else shift + + +# --- Center estimation --- +def center_symmetry( + frame: np.ndarray, + guess: tuple[float, float] | None = None, + search_radius: float = 8.0, + mask: np.ndarray | None = None, +) -> tuple[float, float]: + """Refine a center guess by local Friedel-symmetry autocorrelation.""" + frame = np.asarray(frame, dtype=np.float64) + n_rows, n_cols = frame.shape + if guess is None: + guess = ((n_rows - 1) / 2.0, (n_cols - 1) / 2.0) + + work = _bandpass(frame, mask) + spectrum = np.fft.fft2(work) + corr = np.fft.ifft2(spectrum * spectrum).real + target_row = (2.0 * guess[0]) % n_rows + target_col = (2.0 * guess[1]) % n_cols + row_idx = np.arange(n_rows, dtype=np.float64) + col_idx = np.arange(n_cols, dtype=np.float64) + row_dist = np.minimum(np.abs(row_idx - target_row), n_rows - np.abs(row_idx - target_row)) + col_dist = np.minimum(np.abs(col_idx - target_col), n_cols - np.abs(col_idx - target_col)) + near = (row_dist[:, None] <= 2.0 * search_radius) & (col_dist[None, :] <= 2.0 * search_radius) + p_row, p_col = np.unravel_index(int(np.argmax(np.where(near, corr, -np.inf))), corr.shape) + row2 = p_row + _parabolic_offset(corr[:, p_col], p_row, n_rows) + col2 = p_col + _parabolic_offset(corr[p_row, :], p_col, n_cols) + row = min(((row2 + offset) / 2.0 for offset in (0.0, n_rows)), key=lambda c: abs(c - guess[0])) + col = min(((col2 + offset) / 2.0 for offset in (0.0, n_cols)), key=lambda c: abs(c - guess[1])) + return float(row), float(col) + + +def center_phase_correlation( + frame: np.ndarray, mask: np.ndarray | None = None, upsample: int = 20 +) -> tuple[float, float]: + """Estimate the inversion center by phase correlation.""" + frame = np.asarray(frame, dtype=np.float64) + n_rows, n_cols = frame.shape + work = _bandpass(frame, mask) + rot = work[::-1, ::-1] + d_row, d_col, _ = _phase_shift(work, rot, upsample=upsample) + # Inversion center + row_cands = [(n_rows - 1 + d) / 2.0 for d in (d_row % n_rows, d_row % n_rows - n_rows)] + col_cands = [(n_cols - 1 + d) / 2.0 for d in (d_col % n_cols, d_col % n_cols - n_cols)] + candidates = [ + (r, c) + for r in row_cands + for c in col_cands + if 0.0 <= r <= n_rows - 1 and 0.0 <= c <= n_cols - 1 + ] + # skip border candidates + interior = [(r, c) for r, c in candidates if 1.0 <= r <= n_rows - 2 and 1.0 <= c <= n_cols - 2] + if interior: + candidates = interior + if not candidates: + candidates = [((n_rows - 1) / 2.0, (n_cols - 1) / 2.0)] + scored = [(r, c, _symmetry_score(frame, (r, c), mask=mask)) for r, c in candidates] + best = max(score for _, _, score in scored) + mid = ((n_rows - 1) / 2.0, (n_cols - 1) / 2.0) + row, col, _ = min( + (candidate for candidate in scored if candidate[2] >= best - 1e-4), + key=lambda candidate: math.hypot(candidate[0] - mid[0], candidate[1] - mid[1]), + ) + return float(row), float(col) + + +# --- Quality metrics --- +def _symmetry_score( + frame: np.ndarray, center: tuple[float, float], mask: np.ndarray | None = None +) -> float: + # Friedel symmetry + frame = np.asarray(frame, dtype=np.float64) + n_rows, n_cols = frame.shape + work = _bandpass(frame) + rows, cols = np.indices((n_rows, n_cols), dtype=np.float64) + rot_rows = 2.0 * center[0] - rows + rot_cols = 2.0 * center[1] - cols + valid = ( + (rot_rows >= 0.0) & (rot_rows <= n_rows - 1) & (rot_cols >= 0.0) & (rot_cols <= n_cols - 1) + ) + rotated = ndimage.map_coordinates(work, [rot_rows, rot_cols], order=1, mode="nearest") + if mask is not None: + mask = np.asarray(mask, dtype=bool) + rot_mask = ndimage.map_coordinates( + mask.astype(np.float64), [rot_rows, rot_cols], order=1, mode="constant", cval=1.0 + ) + valid &= ~mask & (rot_mask < 0.5) + a = work[valid] + b = rotated[valid] + if a.size < 16: + return 0.0 + a = a - a.mean() + b = b - b.mean() + denom = float(np.sqrt((a * a).sum() * (b * b).sum())) + if denom <= 0: + return 0.0 + return float(max(0.0, (a * b).sum() / denom)) + + +def ring_uniformity( + frame: np.ndarray, + center: tuple[float, float], + radius: float, + half_width: float = 4.0, + n_theta: int = 180, + mask: np.ndarray | None = None, +) -> dict: + """Azimuthal uniformity QC for one ring; ``mask`` pixels are excluded.""" + frame = np.asarray(frame, dtype=np.float64) + rows, cols = np.indices(frame.shape, dtype=np.float64) + d_row = rows - center[0] + d_col = cols - center[1] + annulus = np.abs(np.hypot(d_row, d_col) - radius) <= half_width + if mask is not None: + annulus &= ~np.asarray(mask, dtype=bool) + theta = np.arctan2(d_row[annulus], d_col[annulus]) + sector = np.clip(((theta + np.pi) / (2.0 * np.pi) * n_theta).astype(int), 0, n_theta - 1) + sums = np.bincount(sector, weights=frame[annulus], minlength=n_theta) + counts = np.bincount(sector, minlength=n_theta) + live = counts > 0 + if not live.any(): + return {"cv": 0.0, "coverage": 0.0, "snr": 0.0} + profile = sums[live] / counts[live] + mean = float(profile.mean()) + std = float(profile.std()) + cv = std / mean if mean > 0 else 0.0 + positive = profile[profile > 0] + coverage = float(np.mean(profile > 0.5 * np.median(positive))) if positive.size else 0.0 + snr = min(mean / std, 999.0) if std > 0 else 999.0 + return {"cv": float(cv), "coverage": float(coverage), "snr": float(snr)} + + +# --- Dispatch --- +def pick_center( + frame: np.ndarray, + method: str = "auto", + mask: np.ndarray | None = None, + guess: tuple[float, float] | None = None, + search_radius: float = 8.0, +) -> dict: + """Estimate the pattern center with one method or an automatic pick.""" + frame = np.asarray(frame, dtype=np.float64) + if method == "symmetry": + row, col = center_symmetry(frame, guess=guess, search_radius=search_radius, mask=mask) + name = "symmetry" + elif method == "phase_corr": + row, col = center_phase_correlation(frame, mask=mask) + name = "phase_corr" + elif method == "auto": + p_row, p_col = center_phase_correlation(frame, mask=mask) + s_row, s_col = center_symmetry(frame, guess=guess, search_radius=search_radius, mask=mask) + candidates = [ + ("phase_corr", p_row, p_col, _symmetry_score(frame, (p_row, p_col), mask=mask)), + ("symmetry", s_row, s_col, _symmetry_score(frame, (s_row, s_col), mask=mask)), + ] + name, row, col, _ = max(candidates, key=lambda c: c[3]) + else: + raise ValueError(f"unknown method {method!r}; use auto, symmetry, or phase_corr") + return {"row": float(row), "col": float(col), "method": name} + + +# --- Stack alignment --- +def align_frames( + frames: np.ndarray, + reference: np.ndarray | None = None, + max_shift: float = 8.0, +) -> tuple[np.ndarray, list[tuple[float, float]], list[bool]]: + """Align a stack of patterns by subpixel phase correlation.""" + frames = np.asarray(frames, dtype=np.float64) + n_frames, n_rows, n_cols = frames.shape + ref = frames[0] if reference is None else np.asarray(reference, dtype=np.float64) + # linear bandpass for count statistics + ref = _bandpass(ref, log=False) + aligned = np.empty_like(frames) + shifts: list[tuple[float, float]] = [] + used: list[bool] = [] + for i in range(n_frames): + s_row, s_col, psr = _phase_shift(ref, _bandpass(frames[i], log=False)) + s_row = _wrap_signed(s_row, n_rows) + s_col = _wrap_signed(s_col, n_cols) + peak_quality = psr / (psr + 10.0) if psr > 0 else 0.0 + ok = np.hypot(s_row, s_col) <= max_shift and peak_quality >= 0.2 + shifts.append((float(s_row), float(s_col))) + used.append(bool(ok)) + aligned[i] = ndimage.shift(frames[i], (s_row, s_col), order=1) if ok else frames[i] + return aligned, shifts, used + + +# Reference-line matching +# out-of-tolerance pad cost +_NO_MATCH_COST = 1.0e6 + + +def match_candidate(observed_d: Sequence[float], lines: Sequence[dict], tol: float = 0.03) -> dict: + """Match measured d-spacings against one reference phase.""" + observed = [ + float(spacing) + for spacing in observed_d + if spacing and math.isfinite(float(spacing)) and float(spacing) > 0 + ] + references = [ + (float(line["d"]), float(line.get("i_rel", line.get("intensity")) or 0.0)) + for line in lines + if math.isfinite(float(line["d"])) and float(line["d"]) > 0 + ] + has_intensity = any(rel_intensity > 0 for _, rel_intensity in references) + n_observed = len(observed) + if n_observed == 0 or not references: + return { + "matched": 0, + "n_obs": n_observed, + "mean_err": None, + "n_missing_strong": 0 if has_intensity else None, + "assignments": [], + } + observed_g = [1.0 / spacing for spacing in observed] + reference_g = [1.0 / spacing for spacing, _ in references] + + # in-tolerance pair costs + cost = np.full((n_observed, len(references)), _NO_MATCH_COST) + d_errors = np.zeros_like(cost) + for obs_index, observed_value in enumerate(observed_g): + for ref_index, reference_value in enumerate(reference_g): + error = abs(reference_value - observed_value) / observed_value + if error <= tol: + cost[obs_index, ref_index] = error + d_errors[obs_index, ref_index] = ( + abs(1.0 / observed_value - references[ref_index][0]) / references[ref_index][0] + ) + + # maximum matches first, then lowest total error + assignments = [(obs_index, None) for obs_index in range(n_observed)] + errors = [] + matched_refs = set() + for obs_index, ref_index in zip(*linear_sum_assignment(cost)): + if cost[obs_index, ref_index] >= _NO_MATCH_COST: + continue + assignments[obs_index] = (int(obs_index), int(ref_index)) + errors.append(float(d_errors[obs_index, ref_index])) + matched_refs.add(int(ref_index)) + + n_matched = len(errors) + n_missing_strong = None + if has_intensity: + g_min, g_max = min(observed_g), max(observed_g) + n_missing_strong = sum( + 1 + for ref_index, (_, rel_intensity) in enumerate(references) + if rel_intensity >= 25.0 + and g_min <= reference_g[ref_index] <= g_max + and ref_index not in matched_refs + ) + + return { + "matched": n_matched, + "n_obs": n_observed, + "mean_err": (float(sum(errors) / n_matched) if n_matched else None), + "n_missing_strong": n_missing_strong, + "assignments": assignments, + } + + +def match_sort_key(report: dict) -> tuple: + """Sort phase reports from strongest to weakest match.""" + return ( + -report["matched"], + report["n_missing_strong"] or 0, + report["mean_err"] if report["mean_err"] is not None else 1.0, + ) +# Diffraction analysis +BF_RADIUS_FRACTION = 0.125 +RING_FIT_MODELS = ("gaussian", "pseudo_voigt") + +MEASUREMENT_COLUMNS = [ + "id", + "kind", + "raw_row", + "raw_col", + "row", + "col", + "row_err", + "col_err", + "r_pixels", + "r_pixels_err", + "g_inv_angstrom", + "g_inv_angstrom_err", + "d_angstrom", + "d_angstrom_err", + "angle_deg", + "angle_deg_err", + "intensity", + "fit_quality", + "fwhm_px", + "fwhm_inv_angstrom", + "intensity_integrated", + "hkl", + "hkl_candidates", + "note", +] + + +# --- Records and formatting --- +def element_symbols(text: str) -> set[str]: + """Element symbols found in a formula-like string.""" + return set(re.findall(r"[A-Z][a-z]?", text or "")) + + +def parse_elements(value) -> set[str] | None: + """Element-symbol set from a string or iterable, or None if empty.""" + if not value: + return None + if isinstance(value, str): + value = re.split(r"[,\s]+", value.strip()) + return {symbol.strip().capitalize() for symbol in value if symbol.strip()} or None + + +def index_assignment(candidate: dict | None) -> dict: + """Indexing fields for a matched reflection candidate.""" + if candidate is None: + return {"hkl": "", "d_ref": None, "d_error": None} + return { + "hkl": candidate["hkl_str"], + "d_ref": candidate["d"], + "d_error": candidate["d_error"], + } + + +def empty_index_fields() -> dict: + """Blank indexing fields for a spot or ring record.""" + return { + "hkl": "", + "hkl_candidates": [], + "d_ref": None, + "d_error": None, + "note": "", + } + + +def next_record_id(records) -> int: + """Next one-based id for a list of record dicts.""" + return max((int(record["id"]) for record in records), default=0) + 1 + + +def format_zone_axis(hkl1: tuple[int, int, int], hkl2: tuple[int, int, int]) -> str: + """Zone-axis label ``[uvw]`` from two indexed reflections.""" + h1, k1, l1 = hkl1 + h2, k2, l2 = hkl2 + u = k1 * l2 - l1 * k2 + v = l1 * h2 - h1 * l2 + w = h1 * k2 - k1 * h2 + divisor = math.gcd(math.gcd(abs(u), abs(v)), abs(w)) + if divisor == 0: + return "" + u, v, w = u // divisor, v // divisor, w // divisor + for axis in (u, v, w): + if axis != 0: + if axis < 0: + u, v, w = -u, -v, -w + break + return "[" + "".join(str(axis) for axis in (u, v, w)) + "]" + + +# --- Input and masking --- +def normalize_data_input( + data, + *, + title: str = "", + pixel_size: float | None = None, + k_pixel_size: float | None = None, + replace_title: bool = False, +): + """Unwrap Dataset-like input into array, title, and calibrations.""" + k_calibrated = False + if hasattr(data, "_fields") and "data" in getattr(data, "_fields", ()): + metadata = data.metadata or {} + if pixel_size is None and metadata.get("pixel_size") is not None: + pixel_size = float(metadata["pixel_size"]) + data = data.data + + if hasattr(data, "sampling") and hasattr(data, "array"): + if (replace_title or not title) and getattr(data, "name", ""): + title = str(data.name) + units = list(getattr(data, "units", ["pixels"] * 4)) + if pixel_size is None and units and units[0] in ("Å", "angstrom", "A", "nm"): + pixel_size = float(data.sampling[0]) + if units[0] == "nm": + pixel_size *= 10 + if k_pixel_size is None and len(units) > 2 and units[2] in ("1/Å", "1/A"): + k_pixel_size = float(data.sampling[2]) + k_calibrated = True + data = data.array + return data, title, pixel_size, k_pixel_size, k_calibrated + + +def pack_float32_halves(x: np.ndarray, y: np.ndarray) -> bytes: + """Two arrays packed as concatenated float32 bytes.""" + return np.concatenate([x, y]).astype(np.float32).tobytes() + + +def build_analysis_mask( + shape: tuple[int, int], + regions: list[dict], + center: tuple[float, float], +) -> np.ndarray | None: + """Boolean exclusion mask from disk and wedge regions.""" + if not regions: + return None + n_rows, n_cols = shape + rows = np.arange(n_rows, dtype=np.float64)[:, None] + cols = np.arange(n_cols, dtype=np.float64)[None, :] + center_row, center_col = center + mask = np.zeros((n_rows, n_cols), dtype=bool) + for region in regions: + kind = region.get("kind") + if kind == "disk": + mask |= np.hypot(rows - region["row"], cols - region["col"]) <= region["radius"] + elif kind == "wedge": + # full-circle span: every angle masked + if abs(float(region["end_deg"]) - float(region["start_deg"])) >= 360.0: + mask[:] = True + continue + theta = np.degrees(np.arctan2(rows - center_row, cols - center_col)) % 360.0 + start = float(region["start_deg"]) % 360.0 + end = float(region["end_deg"]) % 360.0 + mask |= ( + (theta >= start) & (theta <= end) + if start <= end + else ((theta >= start) | (theta <= end)) + ) + return mask + + +# --- Radial and azimuthal profiles --- +def corrected_radius( + d_row, + d_col, + *, + ellipse_ratio: float = 1.0, + ellipse_angle: float = 0.0, + ellipse_corrected: bool = False, +): + """Radius with optional elliptical-distortion correction. + + The correction is mean-preserving: an ellipse of semi-axes A, B maps to a + circle of radius sqrt(A*B) (the mean radius), so a calibration set before + the correction stays valid after it. + """ + if not ellipse_corrected or ellipse_ratio == 1.0: + return np.hypot(d_row, d_col) + angle = math.radians(ellipse_angle) + major = d_col * math.cos(angle) + d_row * math.sin(angle) + minor = -d_col * math.sin(angle) + d_row * math.cos(angle) + root_ratio = math.sqrt(ellipse_ratio) + return np.hypot(major / root_ratio, minor * root_ratio) + + +def corrected_vector( + d_row, + d_col, + *, + ellipse_ratio: float = 1.0, + ellipse_angle: float = 0.0, + ellipse_corrected: bool = False, +): + """Displacement vector under the same ellipse transform as corrected_radius.""" + if not ellipse_corrected or ellipse_ratio == 1.0: + return d_row, d_col + angle = math.radians(ellipse_angle) + major = d_col * math.cos(angle) + d_row * math.sin(angle) + minor = -d_col * math.sin(angle) + d_row * math.cos(angle) + root_ratio = math.sqrt(ellipse_ratio) + major, minor = major / root_ratio, minor * root_ratio + return ( + major * math.sin(angle) + minor * math.cos(angle), + major * math.cos(angle) - minor * math.sin(angle), + ) + + +def radial_profile_px( + frame: np.ndarray, + *, + center: tuple[float, float], + n_bins: int | None = None, + max_radius: float | None = None, + mask: np.ndarray | None = None, + angular_range: tuple[float, float] | None = None, + ellipse_ratio: float = 1.0, + ellipse_angle: float = 0.0, + ellipse_corrected: bool = False, +) -> tuple[np.ndarray, np.ndarray]: + """Radial intensity profile in detector pixels.""" + n_rows, n_cols = frame.shape + center_row, center_col = float(center[0]), float(center[1]) + if max_radius is None: + max_radius = float( + min(center_row, center_col, (n_rows - 1) - center_row, (n_cols - 1) - center_col) + ) + max_radius = float(max(1.0, max_radius)) + n_bins = max(1, int(round(max_radius))) if n_bins is None else int(max(1, n_bins)) + + rows = np.arange(n_rows, dtype=np.float64)[:, None] + cols = np.arange(n_cols, dtype=np.float64)[None, :] + d_row, d_col = rows - center_row, cols - center_col + radii = corrected_radius( + d_row, + d_col, + ellipse_ratio=ellipse_ratio, + ellipse_angle=ellipse_angle, + ellipse_corrected=ellipse_corrected, + ) + flat_r = radii.ravel() + flat_i = frame.astype(np.float64).ravel() + keep = None if mask is None else ~mask.ravel() + if angular_range is not None: + start, end = float(angular_range[0]) % 360.0, float(angular_range[1]) % 360.0 + span = abs(float(angular_range[1]) - float(angular_range[0])) + # full-circle span: no angular restriction + if span < 360.0 and start != end: + theta = np.degrees(np.arctan2(d_row, d_col)).ravel() % 360.0 + wedge = ( + (theta >= start) & (theta <= end) + if start <= end + else ((theta >= start) | (theta <= end)) + ) + keep = wedge if keep is None else keep & wedge + if keep is not None: + flat_r, flat_i = flat_r[keep], flat_i[keep] + + edges = np.linspace(0.0, max_radius, n_bins + 1) + indices = np.digitize(flat_r, edges) - 1 + inside = (indices >= 0) & (indices < n_bins) + indices = indices[inside] + values = flat_i[inside] + + counts = np.bincount(indices, minlength=n_bins).astype(np.float64) + sums = np.bincount(indices, weights=values, minlength=n_bins) + with np.errstate(invalid="ignore", divide="ignore"): + intensity = np.where(counts > 0, sums / counts, 0.0) + bin_centers = 0.5 * (edges[:-1] + edges[1:]) + return bin_centers.astype(np.float32), intensity.astype(np.float32) + + +def ring_sectors( + frame: np.ndarray, + *, + center: tuple[float, float], + radius_px: float, + half_width: float, + n_theta: int, + mask: np.ndarray | None = None, + use_corrected_radius: bool = True, + ellipse_ratio: float = 1.0, + ellipse_angle: float = 0.0, + ellipse_corrected: bool = False, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Per-sector counts and intensity sums around one ring.""" + frame = frame.astype(np.float64) + center_row, center_col = center + n_rows, n_cols = frame.shape + rows = np.arange(n_rows, dtype=np.float64)[:, None] + cols = np.arange(n_cols, dtype=np.float64)[None, :] + d_row, d_col = rows - center_row, cols - center_col + if use_corrected_radius: + radii = corrected_radius( + d_row, + d_col, + ellipse_ratio=ellipse_ratio, + ellipse_angle=ellipse_angle, + ellipse_corrected=ellipse_corrected, + ) + else: + radii = np.hypot(d_row, d_col) + + theta_centers = (np.arange(n_theta) + 0.5) * (360.0 / n_theta) + selected = np.abs(radii - radius_px) <= half_width + if mask is not None: + selected &= ~mask + if not selected.any(): + zero = np.zeros(n_theta) + return theta_centers, zero.copy(), zero.copy(), zero.copy(), zero.copy() + + theta = np.degrees(np.arctan2(d_row, d_col)) % 360.0 + sector = np.minimum((theta[selected] / (360.0 / n_theta)).astype(int), n_theta - 1) + intensity = frame[selected] + # median pedestal, negatives clipped + weight = np.clip(intensity - np.median(intensity), 0.0, None) + counts = np.bincount(sector, minlength=n_theta).astype(np.float64) + intensity_sum = np.bincount(sector, weights=intensity, minlength=n_theta) + weight_sum = np.bincount(sector, weights=weight, minlength=n_theta) + weighted_radius_sum = np.bincount(sector, weights=weight * radii[selected], minlength=n_theta) + return theta_centers, counts, intensity_sum, weight_sum, weighted_radius_sum + + +def azimuthal_profile_from_frame( + frame: np.ndarray, + *, + center: tuple[float, float], + radius_px: float, + half_width: float, + n_theta: int, + mask: np.ndarray | None = None, + ellipse_ratio: float = 1.0, + ellipse_angle: float = 0.0, + ellipse_corrected: bool = False, +) -> tuple[np.ndarray, np.ndarray]: + """Azimuthal intensity profile I(theta) around one ring.""" + theta, counts, intensity_sum, _, _ = ring_sectors( + frame, + center=center, + radius_px=radius_px, + half_width=half_width, + n_theta=n_theta, + mask=mask, + use_corrected_radius=True, + ellipse_ratio=ellipse_ratio, + ellipse_angle=ellipse_angle, + ellipse_corrected=ellipse_corrected, + ) + intensity = np.where(counts > 0, intensity_sum / np.maximum(counts, 1.0), 0.0) + return theta.astype(np.float32), intensity.astype(np.float32) + + +def texture_from_profile( + theta_deg: np.ndarray, intensity: np.ndarray, *, return_profile: bool = False +) -> dict: + """Texture strength and preferred angle from an azimuthal profile.""" + values = intensity.astype(np.float64) + covered = values != 0.0 + if covered.sum() >= 8: + live = np.sort(np.asarray(theta_deg, dtype=np.float64)[covered]) + gaps = np.diff(np.concatenate([live, live[:1] + 360.0])) + span = 360.0 - float(gaps.max()) + else: + span = 0.0 + if covered.sum() < 8 or span < 90.0: + strength, angle = 0.0, 0.0 + else: + theta = np.radians(theta_deg)[covered] + design = np.column_stack([np.ones_like(theta), np.cos(2 * theta), np.sin(2 * theta)]) + (mean_level, cosine, sine), *_ = np.linalg.lstsq(design, values[covered], rcond=None) + if mean_level <= 0: + strength, angle = 0.0, 0.0 + else: + strength = float(min(1.0, math.hypot(cosine, sine) / mean_level)) + angle = float(math.degrees(math.atan2(sine, cosine)) / 2.0 % 180.0) + report = {"strength": strength, "angle_deg": angle, "coverage_deg": span} + if return_profile: + report["profile"] = (theta_deg, intensity) + return report + + +# --- Ellipse and background fitting --- +def fit_ellipse_from_sectors( + theta_centers: np.ndarray, + counts: np.ndarray, + weight_sum: np.ndarray, + weighted_radius_sum: np.ndarray, +) -> dict: + """Ellipse ratio and angle from ring-sector radii.""" + valid = (counts >= 10) & (weight_sum > 0) + if valid.sum() < 8: + raise ValueError( + f"could not fit ellipse: ring found in {int(valid.sum())} sectors, need >= 8; " + "check the ring radius and center" + ) + # short arcs leave the order-1/order-2 harmonics collinear + live = np.sort(np.asarray(theta_centers, dtype=np.float64)[valid]) + gaps = np.diff(np.concatenate([live, live[:1] + 360.0])) + span = 360.0 - float(gaps.max()) + if span < 120.0: + raise ValueError( + f"could not fit ellipse: ring sectors span only {span:.0f} deg, need >= 120; " + "reduce the excluded regions or pick a fuller ring" + ) + radii_by_theta = weighted_radius_sum[valid] / weight_sum[valid] + theta = np.radians(theta_centers)[valid] + # order-1 terms absorb residual center error + design = np.column_stack( + [ + np.ones_like(theta), + np.cos(theta), + np.sin(theta), + np.cos(2 * theta), + np.sin(2 * theta), + ] + ) + solution, *_ = np.linalg.lstsq(design, radii_by_theta, rcond=None) + mean_radius, cosine, sine = solution[0], solution[3], solution[4] + epsilon = math.hypot(cosine, sine) / mean_radius + ratio = (1.0 + epsilon) / (1.0 - epsilon) if epsilon < 1.0 else float("inf") + angle = (0.5 * math.degrees(math.atan2(sine, cosine))) % 180.0 + residual = radii_by_theta - design @ solution + return { + "ratio": float(ratio), + "angle_deg": float(angle), + "r_mean": float(mean_radius), + "residual_px": float(np.sqrt(np.mean(residual**2))), + "n_sectors": int(valid.sum()), + } + + +def fit_radial_background( + radii_px: np.ndarray, + intensity: np.ndarray, + *, + peak_windows: list[tuple[float, float]], + exclude_radius: float, + method: str = "power", + poly_order: int = 3, +) -> np.ndarray: + """Smooth background under a radial profile, excluding peak windows.""" + if method not in ("power", "poly"): + raise ValueError(f"method must be 'power' or 'poly', got {method!r}") + if poly_order < 0: + raise ValueError(f"poly_order must be non-negative, got {poly_order}") + + radii = radii_px.astype(np.float64) + values = intensity.astype(np.float64) + keep = radii > float(exclude_radius) + for lo, hi in peak_windows: + keep &= ~((radii >= lo) & (radii <= hi)) + if method == "power": + keep &= values > 0 + + min_points = 2 if method == "power" else max(2, poly_order + 1) + if keep.sum() < min_points: + raise ValueError( + "not enough background bins to fit; widen the profile or narrow peak_windows" + ) + + if method == "power": + coefficients = np.polyfit(np.log(radii[keep]), np.log(values[keep]), 1) + positive_radii = radii[radii > 0] + eval_radii = np.maximum(radii, positive_radii.min()) + background = np.exp(np.polyval(coefficients, np.log(eval_radii))) + else: + coefficients = np.polyfit(radii[keep], values[keep], poly_order) + background = np.polyval(coefficients, radii) + return background.astype(np.float32) + + +# --- Peak fitting --- +def fit_gaussian_spot( + frame: np.ndarray, + row: float, + col: float, + *, + half_window: int, +) -> dict | None: + """Subpixel 2D Gaussian fit around a spot.""" + frame = np.asarray(frame, dtype=np.float32) + half = max(4, int(half_window)) + row0, col0 = int(round(row)), int(round(col)) + row_lo, row_hi = max(0, row0 - half), min(frame.shape[0], row0 + half + 1) + col_lo, col_hi = max(0, col0 - half), min(frame.shape[1], col0 + half + 1) + patch = frame[row_lo:row_hi, col_lo:col_hi].astype(np.float64) + if patch.shape[0] < 5 or patch.shape[1] < 5: + return None + try: + from scipy.optimize import OptimizeWarning, curve_fit + except Exception: + return None + + n_rows, n_cols = patch.shape + row_grid, col_grid = np.meshgrid(np.arange(n_rows), np.arange(n_cols), indexing="ij") + + def gaussian_2d(coords, amplitude, row_center, col_center, sigma_row, sigma_col, offset): + rows, cols = coords + exponent = ((rows - row_center) / sigma_row) ** 2 + exponent += ((cols - col_center) / sigma_col) ** 2 + return (amplitude * np.exp(-0.5 * exponent) + offset).ravel() + + peak = np.unravel_index(int(np.argmax(patch)), patch.shape) + initial = ( + float(patch.max() - patch.min()), + float(peak[0]), + float(peak[1]), + 2.0, + 2.0, + float(patch.min()), + ) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", OptimizeWarning) + fit_params, covariance = curve_fit( + gaussian_2d, + (row_grid, col_grid), + patch.ravel(), + p0=initial, + maxfev=5000, + ) + except Exception: + return None + + _, fit_row, fit_col, sigma_row, sigma_col, _ = fit_params + if not (0 <= fit_row < n_rows and 0 <= fit_col < n_cols): + return None + + parameter_errors = np.sqrt(np.abs(np.diag(covariance))) + residual = patch.ravel() - gaussian_2d((row_grid, col_grid), *fit_params) + ss_res = float(np.sum(residual**2)) + ss_tot = float(np.sum((patch.ravel() - patch.mean()) ** 2)) + r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0 + return { + "row": float(row_lo + fit_row), + "col": float(col_lo + fit_col), + "row_err": float(parameter_errors[1]) if np.isfinite(parameter_errors[1]) else 0.0, + "col_err": float(parameter_errors[2]) if np.isfinite(parameter_errors[2]) else 0.0, + "sigma_row": float(abs(sigma_row)), + "sigma_col": float(abs(sigma_col)), + "fit_quality": float(r_squared), + } + + +def _gaussian_peak(radius, amplitude, center, sigma, offset): + return amplitude * np.exp(-0.5 * ((radius - center) / sigma) ** 2) + offset + + +def _pseudo_voigt_peak(radius, amplitude, center, sigma, offset, eta): + gamma = sigma * 2.3548 / 2.0 + lorentzian = 1.0 / (1.0 + ((radius - center) / gamma) ** 2) + gaussian_part = np.exp(-0.5 * ((radius - center) / sigma) ** 2) + return amplitude * (eta * lorentzian + (1.0 - eta) * gaussian_part) + offset + + +def _ring_fit_window(radius_guess: float, centers: list[float], window: float | None) -> float: + if window is not None: + return float(window) + gaps = [abs(radius_guess - center) for center in centers if center != radius_guess] + return max(3.0, min(gaps) / 2.0) if gaps else max(6.0, 0.2 * radius_guess) + + +def _fit_ring_peak( + radii_px: np.ndarray, + intensity: np.ndarray, + *, + radius_guess: float, + half_width: float, + model: str, +) -> dict | None: + from scipy.optimize import OptimizeWarning, curve_fit + + in_window = (radii_px >= radius_guess - half_width) & (radii_px <= radius_guess + half_width) + radius_window = radii_px[in_window].astype(np.float64) + intensity_window = intensity[in_window].astype(np.float64) + if radius_window.size < 5: + return None + + initial = [ + max(float(intensity_window.max() - intensity_window.min()), 1e-6), + radius_guess, + 2.0, + float(intensity_window.min()), + ] + bounds = ( + [0.0, radius_guess - half_width, 0.1, -np.inf], + [np.inf, radius_guess + half_width, half_width, np.inf], + ) + if model == "pseudo_voigt": + initial = initial + [0.5] + bounds = (bounds[0] + [0.0], bounds[1] + [1.0]) + peak_model = _gaussian_peak if model == "gaussian" else _pseudo_voigt_peak + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", OptimizeWarning) + fit_params, _ = curve_fit( + peak_model, + radius_window, + intensity_window, + p0=initial, + bounds=bounds, + maxfev=5000, + ) + + residual = intensity_window - peak_model(radius_window, *fit_params) + ss_tot = float(np.sum((intensity_window - intensity_window.mean()) ** 2)) + r_squared = 1.0 - float(np.sum(residual**2)) / ss_tot if ss_tot > 0 else 0.0 + amplitude = float(fit_params[0]) + fit_radius = float(fit_params[1]) + sigma = float(abs(fit_params[2])) + if model == "pseudo_voigt": + eta = float(fit_params[4]) + gamma = sigma * 2.3548 / 2.0 + integrated = amplitude * ( + eta * math.pi * gamma + (1.0 - eta) * sigma * math.sqrt(2.0 * math.pi) + ) + else: + integrated = amplitude * sigma * math.sqrt(2.0 * math.pi) + return { + "raw_radius_px": float(radius_guess), + "radius_px": fit_radius, + "intensity": amplitude, + "fwhm_px": 2.3548 * sigma, + "intensity_integrated": integrated, + "fit_quality": float(r_squared), + } + + +def fit_ring_peaks( + radii_px: np.ndarray, + intensity: np.ndarray, + rings, + *, + model: str = "gaussian", + window: float | None = None, +) -> list[dict | None]: + """Fit one radial peak for each ring record.""" + if model not in RING_FIT_MODELS: + raise ValueError(f"model must be one of {RING_FIT_MODELS}, got {model!r}") + if window is not None and window <= 0: + raise ValueError(f"window must be positive, got {window}") + try: + import scipy.optimize # noqa: F401 + except ImportError as exc: + raise ImportError("fit_ring_peaks needs scipy; install it to fit ring peaks") from exc + + centers = sorted(float(ring["radius_px"]) for ring in rings) + updates = [] + for ring in rings: + radius_guess = float(ring["radius_px"]) + half_width = _ring_fit_window(radius_guess, centers, window) + try: + updates.append( + _fit_ring_peak( + radii_px, + intensity, + radius_guess=radius_guess, + half_width=half_width, + model=model, + ) + ) + except Exception: + updates.append(None) + return updates + + +# --- Measurement export --- +def spot_measurement_record(spot: dict) -> dict: + """Export row for one spot record.""" + return { + "id": spot.get("id"), + "kind": "spot", + "raw_row": spot.get("raw_row"), + "raw_col": spot.get("raw_col"), + "row": spot.get("row"), + "col": spot.get("col"), + "row_err": spot.get("row_err"), + "col_err": spot.get("col_err"), + "r_pixels": spot.get("r_pixels"), + "r_pixels_err": spot.get("r_pixels_err"), + "g_inv_angstrom": spot.get("g_magnitude"), + "g_inv_angstrom_err": spot.get("g_magnitude_err"), + "d_angstrom": spot.get("d_spacing"), + "d_angstrom_err": spot.get("d_spacing_err"), + "angle_deg": spot.get("angle_deg"), + "angle_deg_err": spot.get("angle_deg_err"), + "intensity": spot.get("intensity"), + "fit_quality": spot.get("fit_quality"), + "fwhm_px": None, + "fwhm_inv_angstrom": None, + "intensity_integrated": None, + "hkl": spot.get("hkl", ""), + "hkl_candidates": "|".join(spot.get("hkl_candidates") or []), + "note": spot.get("note", ""), + } + + +def ring_measurement_record(ring: dict) -> dict: + """Export row for one ring record.""" + return { + "id": ring.get("id"), + "kind": "ring", + "raw_row": None, + "raw_col": None, + "row": None, + "col": None, + "row_err": None, + "col_err": None, + "r_pixels": ring.get("radius_px"), + "r_pixels_err": None, + "g_inv_angstrom": ring.get("g_magnitude"), + "g_inv_angstrom_err": None, + "d_angstrom": ring.get("d_spacing"), + "d_angstrom_err": None, + "angle_deg": None, + "angle_deg_err": None, + "intensity": ring.get("intensity"), + "fit_quality": ring.get("fit_quality"), + "fwhm_px": ring.get("fwhm_px"), + "fwhm_inv_angstrom": ring.get("fwhm_inv_angstrom"), + "intensity_integrated": ring.get("intensity_integrated"), + "hkl": ring.get("hkl", ""), + "hkl_candidates": "|".join(ring.get("hkl_candidates") or []), + "note": ring.get("note", ""), + } + + +def build_measurement_records(spots, rings) -> list[dict]: + """Export rows for all spots and rings.""" + return [spot_measurement_record(spot) for spot in spots] + [ + ring_measurement_record(ring) for ring in rings + ] + + +def measurement_metadata(state) -> dict: + """Export metadata block from widget state values.""" + return { + "widget_name": "ShowDiffraction", + "center_row": state.get("center_row"), + "center_col": state.get("center_col"), + "center_method": state.get("center_method", ""), + "k_pixel_size_inv_angstrom_per_px": state.get("k_pixel_size"), + "calibrated": bool(state.get("k_calibrated")), + "calibration_source": state.get("calibration_source", "none"), + "calibration_ref_d_angstrom": state.get("calibration_ref_d", 0.0), + "calibration_ref_radius_px": state.get("calibration_ref_radius", 0.0), + "mask_regions": state.get("mask_regions", []), + "background_subtracted": bool(state.get("profile_subtract_background")), + } + + +def write_measurement_file(path, records, metadata) -> pathlib.Path: + """Write measurement records to CSV or JSON.""" + path = pathlib.Path(path) + if path.suffix.lower() == ".json": + path.write_text(json.dumps({"metadata": metadata, "measurements": records}, indent=2)) + else: + with open(path, "w", newline="") as output: + writer = csv.DictWriter(output, fieldnames=MEASUREMENT_COLUMNS) + writer.writeheader() + writer.writerows(records) + return path + class ShowDiffraction(anywidget.AnyWidget): """ Interactive d-spacing analysis for 2D/3D diffraction patterns. @@ -43,8 +1678,7 @@ class ShowDiffraction(anywidget.AnyWidget): Pick Bragg spots and rings on the diffraction pattern to measure d-spacings, g-vectors, and inter-spot angles, with optional sub-pixel Gaussian refinement. Works with a single 2D pattern (SAED) or a 3D stack of patterns, and accepts - NumPy arrays, PyTorch tensors, or quantem datasets. 4D-STEM stacks are not - supported here; use Show4DSTEM instead. + NumPy arrays, PyTorch tensors, or quantem datasets. 4D input is not supported. Parameters ---------- @@ -106,10 +1740,80 @@ class ShowDiffraction(anywidget.AnyWidget): """ _esm = pathlib.Path(__file__).parent / "static" / "showdiffraction.js" + _CENTER_MODES = ("auto", "manual") + _CENTER_METHODS = ("symmetry", "auto", "phase_corr") + _SCALE_MODES = ("linear", "log", "sqrt") + _LIST_STATE_FIELDS = {"spots", "rings", "custom_phases", "mask_regions"} + _STATE_FIELDS = ( + "title", + "n_source_frames", + "frame_idx", + "panel_width_px", + "pixel_size", + "k_pixel_size", + "k_calibrated", + "center_row", + "center_col", + "bf_radius", + "spots", + "rings", + "zone_axis", + "phase_match", + "show_hkl", + "snap_enabled", + "snap_radius", + "spot_refine", + "center_mode", + "calibration_source", + "calibration_ref_d", + "calibration_ref_radius", + "calibration_rms_px", + "ellipse_ratio", + "ellipse_angle", + "ellipse_corrected", + "dp_colormap", + "dp_scale_mode", + "dp_invert", + "dp_vmin_pct", + "dp_vmax_pct", + "show_title", + "show_stats", + "show_controls", + "controls_collapsed", + "show_profile", + "profile_log", + "profile_subtract_background", + "phase_name", + "custom_phases", + "mask_regions", + "show_mask", + "profile_theta_min", + "profile_theta_max", + "show_azimuthal", + "refine_method", + "center_method", + "identify_elements", + "identify_custom_only", + ) + _GEOMETRY_TRAITS = ( + "center_row", + "center_col", + "k_pixel_size", + "k_calibrated", + "ellipse_ratio", + "ellipse_angle", + "ellipse_corrected", + "mask_regions", + ) + _PROFILE_TRAITS = ( + "show_profile", + "profile_subtract_background", + "profile_theta_min", + "profile_theta_max", + "frame_idx", + ) - # ========================================================================= - # Core State / Frame Stack + Detector - # ========================================================================= + # Core state widget_version = traitlets.Unicode("unknown").tag(sync=True) title = traitlets.Unicode("").tag(sync=True) n_frames = traitlets.Int(1).tag(sync=True) @@ -118,18 +1822,13 @@ class ShowDiffraction(anywidget.AnyWidget): det_cols = traitlets.Int(1).tag(sync=True) frame_bytes = traitlets.Bytes(b"").tag(sync=True) - # whole stack as float32, baked only when offline so the kernel-less HTML can scrub - # frames client-side (live widgets stay empty and stream frame_bytes per frame). + # Offline frame stack offline_frames = traitlets.Bytes(b"").tag(sync=True) - # Offline/export render flag. The frontend forces a light background when set - # so standalone HTML exports read on any OS theme. Frames are always embedded - # as exact float32. + # Offline render mode offline = traitlets.Bool(False).tag(sync=True) - # ========================================================================= - # Standalone HTML export bridge (see quantem.widget.export protocol) - # ========================================================================= + # HTML export bridge export_request = traitlets.Unicode("").tag(sync=True) export_status = traitlets.Unicode("").tag(sync=True) export_enabled = traitlets.Bool(True).tag(sync=True) @@ -137,9 +1836,7 @@ class ShowDiffraction(anywidget.AnyWidget): export_payload_id = traitlets.Unicode("").tag(sync=True) export_filename = traitlets.Unicode("").tag(sync=True) - # ========================================================================= - # Detector Calibration - # ========================================================================= + # Detector calibration center_row = traitlets.Float(0.0).tag(sync=True) center_col = traitlets.Float(0.0).tag(sync=True) bf_radius = traitlets.Float(0.0).tag(sync=True) @@ -152,10 +1849,17 @@ class ShowDiffraction(anywidget.AnyWidget): calibration_source = traitlets.Unicode("none").tag(sync=True) calibration_ref_d = traitlets.Float(0.0).tag(sync=True) calibration_ref_radius = traitlets.Float(0.0).tag(sync=True) + calibration_rms_px = traitlets.Float(0.0).tag(sync=True) + + refine_method = traitlets.Unicode("auto").tag(sync=True) + center_method = traitlets.Unicode("").tag(sync=True) - # ========================================================================= - # Spots & Rings - # ========================================================================= + # Ellipse correction + ellipse_ratio = traitlets.Float(1.0).tag(sync=True) + ellipse_angle = traitlets.Float(0.0).tag(sync=True) + ellipse_corrected = traitlets.Bool(False).tag(sync=True) + + # Spots and rings spots = traitlets.List(traitlets.Dict()).tag(sync=True) snap_enabled = traitlets.Bool(False).tag(sync=True) snap_radius = traitlets.Int(5).tag(sync=True) @@ -164,9 +1868,12 @@ class ShowDiffraction(anywidget.AnyWidget): spot_refine = traitlets.Bool(True).tag(sync=True) - # ========================================================================= - # Frontend request channel - # ========================================================================= + # Indexing + zone_axis = traitlets.Unicode("").tag(sync=True) + phase_match = traitlets.Unicode("").tag(sync=True) + show_hkl = traitlets.Bool(True).tag(sync=True) + + # Frontend requests _spot_add_request = traitlets.List(traitlets.Float(), default_value=[]).tag(sync=True) _spot_undo_request = traitlets.Bool(False).tag(sync=True) _spot_clear_request = traitlets.Bool(False).tag(sync=True) @@ -179,30 +1886,60 @@ class ShowDiffraction(anywidget.AnyWidget): _calibrate_from_spot_request = traitlets.List(traitlets.Float(), default_value=[]).tag( sync=True ) - _detect_spots_request = traitlets.Int(0).tag(sync=True) # carries max_spots - _detect_rings_request = traitlets.Int(0).tag(sync=True) # carries max_rings - _spot_remove_request = traitlets.Int(0).tag(sync=True) # carries spot id (0 = none) - _ring_remove_request = traitlets.Int(0).tag(sync=True) # carries ring id (0 = none) + _detect_spots_request = traitlets.Int(0).tag(sync=True) # max_spots, -1 = all + _detect_rings_request = traitlets.Int(0).tag(sync=True) # max_rings, -1 = all + _spot_remove_request = traitlets.Int(0).tag(sync=True) # spot id + _spot_move_request = traitlets.List(traitlets.Float(), default_value=[]).tag( + sync=True + ) # id, row, col + _ring_remove_request = traitlets.Int(0).tag(sync=True) # ring id + _refine_center_request = traitlets.Bool(False).tag(sync=True) + _fit_rings_request = traitlets.Bool(False).tag(sync=True) + _fit_ellipse_request = traitlets.Bool(False).tag(sync=True) + _calibrate_phase_request = traitlets.Bool(False).tag(sync=True) + _index_rings_request = traitlets.Bool(False).tag(sync=True) + _index_spots_request = traitlets.Bool(False).tag(sync=True) + _identify_request = traitlets.Bool(False).tag(sync=True) + _auto_request = traitlets.Bool(False).tag(sync=True) + _merge_request = traitlets.Bool(False).tag(sync=True) + _quality_request = traitlets.Bool(False).tag(sync=True) + analysis_status = traitlets.Unicode("").tag(sync=True) + _quality = traitlets.Dict().tag(sync=True) + selected_ring_id = traitlets.Int(0).tag(sync=True) + + # Analysis mask + mask_regions = traitlets.List(traitlets.Dict()).tag(sync=True) + show_mask = traitlets.Bool(True).tag(sync=True) + + # Phase workbench + phase_name = traitlets.Unicode("").tag(sync=True) + custom_phases = traitlets.List(traitlets.Dict()).tag(sync=True) + _phase_library = traitlets.List(traitlets.Dict()).tag(sync=True) + identify_elements = traitlets.Unicode("").tag(sync=True) + identify_custom_only = traitlets.Bool(False).tag(sync=True) + _identify_results = traitlets.List(traitlets.Dict()).tag(sync=True) - # ========================================================================= # Display - # ========================================================================= dp_colormap = traitlets.Unicode("inferno").tag(sync=True) dp_scale_mode = traitlets.Unicode("log").tag(sync=True) dp_invert = traitlets.Bool(False).tag(sync=True) dp_vmin_pct = traitlets.Float(0.0).tag(sync=True) dp_vmax_pct = traitlets.Float(100.0).tag(sync=True) - # ========================================================================= + # Profiles + show_profile = traitlets.Bool(False).tag(sync=True) + profile_log = traitlets.Bool(True).tag(sync=True) + profile_subtract_background = traitlets.Bool(False).tag(sync=True) + profile_theta_min = traitlets.Float(0.0).tag(sync=True) + profile_theta_max = traitlets.Float(360.0).tag(sync=True) + _profile_data = traitlets.Bytes(b"").tag(sync=True) # float32 pairs + show_azimuthal = traitlets.Bool(False).tag(sync=True) + _azimuthal_data = traitlets.Bytes(b"").tag(sync=True) # float32 pairs + # Statistics - # ========================================================================= - dp_stats = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0, 0.0, 0.0]).tag( - sync=True - ) + dp_stats = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0, 0.0, 0.0]).tag(sync=True) - # ========================================================================= - # UI Visibility - # ========================================================================= + # UI visibility show_title = traitlets.Bool(True).tag(sync=True) show_stats = traitlets.Bool(True).tag(sync=True) show_controls = traitlets.Bool(True).tag(sync=True) @@ -212,14 +1949,13 @@ class ShowDiffraction(anywidget.AnyWidget): @traitlets.validate("center_mode") def _validate_center_mode(self, proposal): val = proposal["value"] - allowed = ("auto", "manual") - if val not in allowed: - raise ValueError(f"center_mode must be one of {allowed}, got {val!r}") + if val not in self._CENTER_MODES: + raise ValueError(f"center_mode must be one of {self._CENTER_MODES}, got {val!r}") return val @traitlets.validate("frame_idx") def _validate_frame_idx(self, proposal): - # Clamp to [0, n_frames) so stale indices don't IndexError on reload. + # Saved-state bounds val = int(proposal["value"]) n = max(1, int(self.n_frames)) return max(0, min(val, n - 1)) @@ -227,9 +1963,8 @@ def _validate_frame_idx(self, proposal): @traitlets.validate("dp_scale_mode") def _validate_dp_scale_mode(self, proposal): val = proposal["value"] - allowed = ("linear", "log", "sqrt") - if val not in allowed: - raise ValueError(f"dp_scale_mode must be one of {allowed}, got {val!r}") + if val not in self._SCALE_MODES: + raise ValueError(f"dp_scale_mode must be one of {self._SCALE_MODES}, got {val!r}") return val def __init__( @@ -255,48 +1990,29 @@ def __init__( state=None, **kwargs, ): + # apply after ingest, else clamped against n_frames=1 + initial_frame_idx = kwargs.pop("frame_idx", None) super().__init__(**kwargs) t_start = time.perf_counter() self.widget_version = resolve_widget_version() - - if hasattr(data, "_fields") and "data" in getattr(data, "_fields", ()): - meta = data.metadata or {} - if pixel_size is None and meta.get("pixel_size") is not None: - pixel_size = meta.get("pixel_size") - data = data.data - - k_calibrated = False - if hasattr(data, "sampling") and hasattr(data, "array"): - if not title and hasattr(data, "name") and data.name: - title = str(data.name) - units = list(getattr(data, "units", ["pixels"] * 4)) - if pixel_size is None and units and units[0] in ("Å", "angstrom", "A", "nm"): - pixel_size = float(data.sampling[0]) - if units[0] == "nm": - pixel_size *= 10 - if k_pixel_size is None and len(units) > 2 and units[2] in ("1/Å", "1/A"): - k_pixel_size = float(data.sampling[2]) - k_calibrated = True - data = data.array - - self._device = torch.device( - "mps" - if torch.backends.mps.is_available() - else "cuda" - if torch.cuda.is_available() - else "cpu" + user_k_pixel_size = k_pixel_size is not None + data, title, pixel_size, k_pixel_size, metadata_calibrated = normalize_data_input( + data, + title=title, + pixel_size=pixel_size, + k_pixel_size=k_pixel_size, ) - self._ingest_data(data) - if pixel_size is not None: - self.pixel_size = float(pixel_size) - if k_pixel_size is not None and k_pixel_size > 0: - self.k_pixel_size = float(k_pixel_size) - self.k_calibrated = True - self.calibration_source = "manual" - elif k_calibrated: - self.k_calibrated = True - self.calibration_source = "metadata" + self._device = self._best_device() + self._ingest_data(data) + if initial_frame_idx is not None: + self.frame_idx = int(initial_frame_idx) + self._set_initial_calibration( + pixel_size, + k_pixel_size, + metadata_calibrated=metadata_calibrated, + user_k_pixel_size=user_k_pixel_size, + ) self.title = title self.dp_scale_mode = dp_scale_mode @@ -326,24 +2042,66 @@ def __init__( self.panel_width_px = int(panel_width_px) self.offline = offline - if center is not None: - self.center_row = float(center[0]) - self.center_col = float(center[1]) - else: + self._set_initial_geometry(center, bf_radius) + + self._update_frame() + self._bake_offline_frames() + self._phase_library = [{"name": name, **entry} for name, entry in PHASE_LIBRARY.items()] + self._observe_traits() + + if verbose: + mem_mb = self._data.nelement() * 4 / 1e6 + print(f" to {self._device}: {time.perf_counter() - t_start:.2f}s ({mem_mb:.1f} MB)") + + self._load_initial_state(state) + + @staticmethod + def _best_device() -> torch.device: + if torch.backends.mps.is_available(): + return torch.device("mps") + if torch.cuda.is_available(): + return torch.device("cuda") + return torch.device("cpu") + + def _set_initial_calibration( + self, + pixel_size: float | None, + k_pixel_size: float | None, + *, + metadata_calibrated: bool, + user_k_pixel_size: bool, + ) -> None: + if pixel_size is not None: + self.pixel_size = float(pixel_size) + if k_pixel_size is not None and k_pixel_size > 0: + self.k_pixel_size = float(k_pixel_size) + self.k_calibrated = True + self.calibration_source = "manual" if user_k_pixel_size else "metadata" + elif metadata_calibrated: + self.k_calibrated = True + self.calibration_source = "metadata" + + def _set_initial_geometry( + self, + center: tuple[float, float] | None, + bf_radius: float | None, + ) -> None: + if center is None: self.center_row = float(self.det_rows / 2) self.center_col = float(self.det_cols / 2) - - if bf_radius is not None: - self.bf_radius = float(bf_radius) else: - self.bf_radius = min(self.det_rows, self.det_cols) * DEFAULT_BF_RATIO + self.center_row = float(center[0]) + self.center_col = float(center[1]) + self.bf_radius = ( + float(bf_radius) + if bf_radius is not None + else min(self.det_rows, self.det_cols) * BF_RADIUS_FRACTION + ) if center is None and bf_radius is None: self.auto_detect_center() - self._update_frame() - self._bake_offline_frames() - + def _observe_traits(self) -> None: self.observe(self._update_frame, names=["frame_idx"]) self.observe(self._bake_offline_frames, names=["offline"]) self.observe(self._on_spot_add_request, names=["_spot_add_request"]) @@ -352,74 +2110,81 @@ def __init__( self.observe(self._on_ring_add_request, names=["_ring_add_request"]) self.observe(self._on_ring_undo_request, names=["_ring_undo_request"]) self.observe(self._on_ring_clear_request, names=["_ring_clear_request"]) - self.observe( - self._on_calibrate_from_ring_request, names=["_calibrate_from_ring_request"] - ) - self.observe( - self._on_calibrate_from_spot_request, names=["_calibrate_from_spot_request"] - ) - # Recompute derived quantities when center / calibration change. - self.observe( - self._on_geometry_change, - names=["center_row", "center_col", "k_pixel_size", "k_calibrated"], - ) + self.observe(self._on_calibrate_from_ring_request, names=["_calibrate_from_ring_request"]) + self.observe(self._on_calibrate_from_spot_request, names=["_calibrate_from_spot_request"]) + self.observe(self._on_geometry_change, names=list(self._GEOMETRY_TRAITS)) self.observe(self._on_detect_spots_request, names=["_detect_spots_request"]) self.observe(self._on_detect_rings_request, names=["_detect_rings_request"]) self.observe(self._on_spot_remove_request, names=["_spot_remove_request"]) + self.observe(self._on_spot_move_request, names=["_spot_move_request"]) self.observe(self._on_ring_remove_request, names=["_ring_remove_request"]) + self.observe(self._on_status_request, names=list(self._STATUS_REQUESTS)) + self.observe(self._on_quality_request, names=["_quality_request"]) + self.observe(self._update_profile, names=list(self._PROFILE_TRAITS)) + self.observe(self._update_azimuthal, names=["show_azimuthal", "rings", "frame_idx"]) self.observe(self._on_export_request_change, names=["export_request"]) - if verbose: - mem_mb = self._data.nelement() * 4 / 1e6 - print(f" to {self._device}: {time.perf_counter() - t_start:.2f}s ({mem_mb:.1f} MB)") + @staticmethod + def _resolve_state(state) -> dict: + if isinstance(state, (str, pathlib.Path)): + return unwrap_state_payload( + json.loads(pathlib.Path(state).read_text()), + require_envelope=True, + expected_widget="ShowDiffraction", + ) + return unwrap_state_payload(state, expected_widget="ShowDiffraction") - if state is not None: - if isinstance(state, (str, pathlib.Path)): - state = unwrap_state_payload( - json.loads(pathlib.Path(state).read_text()), - require_envelope=True, - ) - else: - state = unwrap_state_payload(state) - self.load_state_dict(state) + def _load_initial_state(self, state) -> None: + if state is None: + return + self.load_state_dict(self._resolve_state(state)) def _ingest_data(self, data): - data_np = to_numpy(data) - is_integer = np.issubdtype(data_np.dtype, np.integer) - data_np = data_np.astype(np.float32) - if data_np.size > 2**31 - 1 and self._device.type == "mps": + array = to_numpy(data) + is_integer = np.issubdtype(array.dtype, np.integer) + array = array.astype(np.float32) + array = np.nan_to_num(array, copy=False, nan=0.0, posinf=0.0, neginf=0.0) + if array.size > 2**31 - 1 and self._device.type == "mps": self._device = torch.device("cpu") if is_integer: - global_max = float(data_np.max()) - p999 = float(np.percentile(data_np, 99.9)) - if global_max > p999 * 5: - data_np[data_np > p999 * 3] = 0 - ndim = data_np.ndim + global_max = float(array.max()) + p999 = float(np.percentile(array, 99.9)) + # sparse counting frames (p999 = 0) + if p999 > 0 and global_max > p999 * 5: + array[array > p999 * 3] = 0 + ndim = array.ndim if ndim == 2: - # Single 2D pattern (SAED): a one-frame stack. - data_np = data_np[None, ...] - elif ndim == 3: - # A simple N-frame stack of patterns. - pass + array = array[None, ...] elif ndim == 4: raise ValueError( - "ShowDiffraction is for 2D/3D diffraction patterns; " - "use Show4DSTEM for 4D-STEM data." + "ShowDiffraction is for 2D/3D diffraction patterns; 4D input is not supported." ) - else: + elif ndim != 3: raise ValueError(f"Expected a 2D or 3D array, got {ndim}D") - self._det_shape = (data_np.shape[1], data_np.shape[2]) - self._data = torch.from_numpy(np.ascontiguousarray(data_np)).to(self._device) - self.n_frames = int(data_np.shape[0]) + self._det_shape = (array.shape[1], array.shape[2]) + self._data = torch.from_numpy(np.ascontiguousarray(array)).to(self._device) + self.n_frames = int(array.shape[0]) self.det_rows = self._det_shape[0] self.det_cols = self._det_shape[1] + # new stack: reset merge provenance + self._n_source_frames = None @property def detector_shape(self) -> tuple[int, int]: + """Detector shape as ``(rows, cols)``.""" return self._det_shape - def auto_detect_center(self) -> Self: - """Auto-detect BF disk center and radius from the summed diffraction stack.""" + @property + def n_source_frames(self) -> int | None: + """Source-frame count before merge_frames appended a merged frame.""" + return self._n_source_frames + + @n_source_frames.setter + def n_source_frames(self, value) -> None: + self._n_source_frames = None if value is None else int(value) + + def auto_detect_center(self, *, refine: bool = False) -> Self: + """Find the BF disk center/radius from the summed stack.""" summed_dp = self._data.sum(dim=0) threshold = summed_dp.mean() + summed_dp.std() @@ -429,18 +2194,32 @@ def auto_detect_center(self) -> Self: if total == 0: return self - row_coords = torch.arange(self.det_rows, device=self._device, dtype=torch.float32)[ - :, None - ] - col_coords = torch.arange(self.det_cols, device=self._device, dtype=torch.float32)[ - None, : - ] + row_coords = torch.arange(self.det_rows, device=self._device, dtype=torch.float32)[:, None] + col_coords = torch.arange(self.det_cols, device=self._device, dtype=torch.float32)[None, :] self.center_row = float((row_coords * mask).sum() / total) self.center_col = float((col_coords * mask).sum() / total) - # central beam only: rings are also above threshold, so whole-mask area would - # overestimate the radius and later mask out the inner rings in detect_rings. + # Central component self.bf_radius = self._central_beam_radius(mask, self.center_row, self.center_col) self.center_mode = "auto" + if refine: + self.refine_center() + return self + + def refine_center(self, *, method: str = "symmetry", search_radius: float = 8.0) -> Self: + """Refine the center with symmetry, phase correlation, or auto.""" + if method not in self._CENTER_METHODS: + raise ValueError(f"unknown refine method {method!r}") + + picked = pick_center( + self._displayed_frame().astype(np.float64), + method=method, + mask=self._analysis_mask(), + guess=(self.center_row, self.center_col), + search_radius=search_radius, + ) + self.center_row, self.center_col = float(picked["row"]), float(picked["col"]) + self.center_mode = "auto" + self.center_method = picked["method"] return self def _central_beam_radius(self, mask, center_row: float, center_col: float) -> float: @@ -456,7 +2235,7 @@ def _central_beam_radius(self, mask, center_row: float, center_col: float) -> fl col_idx = int(min(max(round(center_col), 0), mask_np.shape[1] - 1)) central_label = int(labels[row_idx, col_idx]) if central_label == 0: - # center is dark (beam stop): use the nearest bright component + # Beam stop comp_rows, comp_cols = np.nonzero(labels) nearest = int(np.argmin((comp_rows - center_row) ** 2 + (comp_cols - center_col) ** 2)) central_label = int(labels[comp_rows[nearest], comp_cols[nearest]]) @@ -488,7 +2267,7 @@ def _update_frame(self, change=None): self.frame_bytes = frame.tobytes() def _bake_offline_frames(self, change=None) -> None: - # skip single frames and live widgets (which stream per frame) to avoid bloat + # Offline stack if self.offline and self.n_frames > 1 and getattr(self, "_data", None) is not None: frames = self._data.cpu().numpy().astype(np.float32) self.offline_frames = np.ascontiguousarray(frames).tobytes() @@ -496,19 +2275,33 @@ def _bake_offline_frames(self, change=None) -> None: self.offline_frames = b"" def _compute_spot_info( - self, row: float, col: float, row_err: float = 0.0, col_err: float = 0.0 + self, + row: float, + col: float, + row_err: float = 0.0, + col_err: float = 0.0, + frame_idx: int | None = None, ) -> dict: d_row = row - self.center_row d_col = col - self.center_col - r_pixels = math.hypot(d_row, d_col) + r_pixels = float( + corrected_radius( + d_row, + d_col, + ellipse_ratio=self.ellipse_ratio, + ellipse_angle=self.ellipse_angle, + ellipse_corrected=self.ellipse_corrected, + ) + ) - # Project the centroid uncertainty onto the radial direction. + # Radial uncertainty if r_pixels > 0: r_err = math.hypot((d_row / r_pixels) * row_err, (d_col / r_pixels) * col_err) else: r_err = math.hypot(row_err, col_err) - frame = self._displayed_frame() + # sample the record's source frame + frame = self._get_frame(self.frame_idx if frame_idx is None else frame_idx) r_int = max(0, min(self.det_rows - 1, int(round(row)))) c_int = max(0, min(self.det_cols - 1, int(round(col)))) intensity = float(frame[r_int, c_int]) @@ -516,7 +2309,7 @@ def _compute_spot_info( if self.k_calibrated and self.k_pixel_size > 0 and r_pixels > 0: g_magnitude = r_pixels * self.k_pixel_size d_spacing = 1.0 / g_magnitude - # d = 1/(k r) ⇒ σ_d/d = σ_g/g = σ_r/r (calibration treated as exact). + # Propagated d error frac = r_err / r_pixels g_err = g_magnitude * frac d_err = d_spacing * frac @@ -533,121 +2326,36 @@ def _compute_spot_info( "intensity": intensity, } - def _fit_gaussian_2d(self, row: float, col: float) -> dict | None: - frame = self._displayed_frame() - half = max(4, int(self.snap_radius)) - r0, c0 = int(round(row)), int(round(col)) - r_lo, r_hi = max(0, r0 - half), min(self.det_rows, r0 + half + 1) - c_lo, c_hi = max(0, c0 - half), min(self.det_cols, c0 + half + 1) - patch = frame[r_lo:r_hi, c_lo:c_hi].astype(np.float64) - if patch.shape[0] < 5 or patch.shape[1] < 5: - return None - try: - from scipy.optimize import OptimizeWarning, curve_fit - except Exception: - return None - - ny, nx = patch.shape - rr, cc = np.meshgrid(np.arange(ny), np.arange(nx), indexing="ij") - - def gauss2d(coords, amp, fr, fc, sr, sc, off): - r, c = coords - return (amp * np.exp(-0.5 * (((r - fr) / sr) ** 2 + ((c - fc) / sc) ** 2)) + off).ravel() - - peak = np.unravel_index(int(np.argmax(patch)), patch.shape) - p0 = ( - float(patch.max() - patch.min()), - float(peak[0]), - float(peak[1]), - 2.0, - 2.0, - float(patch.min()), - ) - try: - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", OptimizeWarning) - popt, pcov = curve_fit(gauss2d, (rr, cc), patch.ravel(), p0=p0, maxfev=5000) - except Exception: - return None - _, fr, fc, sigma_row, sigma_col, _ = popt - if not (0 <= fr < ny and 0 <= fc < nx): - return None - - perr = np.sqrt(np.abs(np.diag(pcov))) - residual = patch.ravel() - gauss2d((rr, cc), *popt) - ss_res = float(np.sum(residual**2)) - ss_tot = float(np.sum((patch.ravel() - patch.mean()) ** 2)) - r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0 - - return { - "row": float(r_lo + fr), - "col": float(c_lo + fc), - "row_err": float(perr[1]) if np.isfinite(perr[1]) else 0.0, - "col_err": float(perr[2]) if np.isfinite(perr[2]) else 0.0, - "sigma_row": float(abs(sigma_row)), - "sigma_col": float(abs(sigma_col)), - "fit_quality": float(r_squared), - } - def _with_angles(self, spots) -> list: - # Angles are measured relative to the first spot. if not spots: return spots - ref = spots[0] - cr, cc = self.center_row, self.center_col - ref_dr, ref_dc = ref["row"] - cr, ref["col"] - cc - ref_r = math.hypot(ref_dr, ref_dc) - ref_perp = math.hypot(ref.get("row_err", 0.0), ref.get("col_err", 0.0)) - out = [] - for s in spots: - d_row, d_col = s["row"] - cr, s["col"] - cc - r = math.hypot(d_row, d_col) - if ref_r > 0 and r > 0: - cos_a = max(-1.0, min(1.0, (ref_dr * d_row + ref_dc * d_col) / (ref_r * r))) - angle = math.degrees(math.acos(cos_a)) - perp = math.hypot(s.get("row_err", 0.0), s.get("col_err", 0.0)) - angle_err = math.degrees(math.hypot(perp / r, ref_perp / ref_r)) + reference = spots[0] + ref_row, ref_col = self._spot_vector(reference) + ref_radius = math.hypot(ref_row, ref_col) + ref_error = math.hypot(reference.get("row_err", 0.0), reference.get("col_err", 0.0)) + with_angles = [] + for spot in spots: + delta_row, delta_col = self._spot_vector(spot) + radius = math.hypot(delta_row, delta_col) + if ref_radius > 0 and radius > 0: + angle = self._measured_angle(reference, spot) + spot_error = math.hypot(spot.get("row_err", 0.0), spot.get("col_err", 0.0)) + angle_err = math.degrees(math.hypot(spot_error / radius, ref_error / ref_radius)) else: angle = None angle_err = None - out.append({**s, "angle_deg": angle, "angle_deg_err": angle_err}) - return out + with_angles.append({**spot, "angle_deg": angle, "angle_deg_err": angle_err}) + return with_angles def detect_spots( self, - max_spots: int = 20, + max_spots: int | None = None, min_distance: int = 6, - threshold_rel: float = 0.15, + min_relative: float = 0.1, exclude_radius: float | None = None, replace: bool = True, ) -> Self: - """Auto-detect Bragg spots as local maxima in the current frame. - - The saturated central beam is log-compressed and high-passed so its halo - does not dominate, peaks within ``exclude_radius`` of the center are - dropped, and the strongest remaining peaks are kept. - - Parameters - ---------- - max_spots : int, default 20 - Maximum number of spots to keep, ordered by prominence. - min_distance : int, default 6 - Minimum separation in pixels between detected peaks. - threshold_rel : float, default 0.15 - Relative prominence threshold; higher keeps fewer, stronger peaks. - exclude_radius : float, optional - Radius in pixels around the center to ignore. Defaults to the larger - of ``bf_radius`` and ``2 * min_distance``. - replace : bool, default True - Clear existing spots before adding the detected ones. - - Returns - ------- - Self - The widget, for chaining. - """ + """Detect Bragg spots with contrast at least ``min_relative`` of the strongest peak.""" frame = self._displayed_frame().astype(np.float64) n_rows, n_cols = frame.shape if exclude_radius is None: @@ -656,16 +2364,18 @@ def detect_spots( from scipy.ndimage import gaussian_filter, maximum_filter except Exception: return self - # Log-compress the saturated beam, then high-pass to flatten its halo so spots stand out. work = np.log1p(np.clip(frame - frame.min(), 0.0, None)) work = work - gaussian_filter(work, sigma=max(2.0, float(min_distance))) - size = max(3, int(min_distance) | 1) # odd window enforces a min separation + size = max(3, int(min_distance) | 1) # odd window local_max = maximum_filter(work, size=size) == work rows = np.arange(n_rows)[:, None] cols = np.arange(n_cols)[None, :] radius = np.hypot(rows - self.center_row, cols - self.center_col) local_max &= radius > float(exclude_radius) + exclusion = self._analysis_mask() + if exclusion is not None: + local_max &= ~exclusion local_max[0, :] = local_max[-1, :] = False local_max[:, 0] = local_max[:, -1] = False coords = np.argwhere(local_max) @@ -673,68 +2383,63 @@ def detect_spots( self.clear_spots() if coords.size == 0: return self - # Threshold on prominence (high-passed value), robust to the saturated beam. prominence = work[coords[:, 0], coords[:, 1]] - positive = prominence[prominence > 0] - if positive.size: - level = float(positive.mean() + (threshold_rel / 0.15) * positive.std()) - keep = prominence >= level - coords, prominence = coords[keep], prominence[keep] - order = np.argsort(-prominence)[: int(max_spots)] + # contrast relative to the strongest peak, with a noise floor on noisy data + contrast = np.expm1(prominence) + sigma = 1.4826 * float(np.median(np.abs(work - np.median(work)))) + level = max(min_relative * float(contrast.max()), float(np.expm1(5.0 * sigma))) + keep = (prominence > 0) & (contrast >= level) + coords, prominence = coords[keep], prominence[keep] + if coords.size: + # min over opposite samples: crests high both sides, lone neighbors one + angles = np.linspace(0.0, 2.0 * np.pi, 16, endpoint=False) + ring_r = float(max(3, min_distance)) + rr = np.clip(coords[:, :1] + ring_r * np.sin(angles), 0, n_rows - 1).astype(int) + cc = np.clip(coords[:, 1:] + ring_r * np.cos(angles), 0, n_cols - 1).astype(int) + ring_vals = work[rr, cc] + peak = work[coords[:, 0], coords[:, 1]] + hi = np.minimum(ring_vals[:, :8], ring_vals[:, 8:]).max(axis=1) + lo = np.percentile(ring_vals, 10, axis=1) + isolated = (peak - hi) >= 0.5 * np.maximum(peak - lo, 1e-9) + coords, prominence = coords[isolated], prominence[isolated] + kept: list[int] = [] + for idx in np.argsort(-prominence): + r0, c0 = coords[idx] + if all(np.hypot(r0 - coords[j][0], c0 - coords[j][1]) > min_distance for j in kept): + kept.append(int(idx)) + order = np.asarray(kept, dtype=int) + if max_spots is not None: + order = order[: int(max_spots)] for r0, c0 in coords[order]: self.add_spot(float(r0), float(c0)) return self def detect_rings( self, - max_rings: int = 10, + max_rings: int | None = None, prominence_rel: float = 0.05, min_separation: int = 5, exclude_radius: float | None = None, replace: bool = True, ) -> Self: - """Auto-detect Debye-Scherrer rings as peaks in the radial profile. - - The radial profile is log-compressed and detrended so rings read as - peaks, peaks inside ``exclude_radius`` are dropped, and the innermost - (low-order) rings are kept. Use this instead of ``detect_spots`` for - polycrystalline / powder patterns. - - Parameters - ---------- - max_rings : int, default 10 - Maximum number of rings to keep. - prominence_rel : float, default 0.05 - Peak prominence as a fraction of the detrended profile span. - min_separation : int, default 5 - Minimum separation in radial bins between detected peaks. - exclude_radius : float, optional - Radius in pixels around the center to ignore. Defaults to ``bf_radius``. - replace : bool, default True - Clear existing rings before adding the detected ones. - - Returns - ------- - Self - The widget, for chaining. - """ + """Detect Debye-Scherrer rings from radial profile peaks (max_rings=None keeps all).""" try: radii_px, intensity = self._radial_profile() except Exception: return self y = np.asarray(intensity, dtype=np.float64) - if replace: - self.clear_rings() if y.size < 5: return self + # import before clearing picks try: from scipy.ndimage import gaussian_filter1d from scipy.signal import find_peaks except Exception: return self + if replace: + self.clear_rings() if exclude_radius is None: exclude_radius = self.bf_radius - # Log-compress the steep central-beam falloff, then detrend so rings read as peaks. y_log = np.log1p(np.clip(y - y.min(), 0.0, None)) detrended = y_log - gaussian_filter1d(y_log, sigma=max(3.0, y_log.size / 20.0)) span = float(detrended.max() - detrended.min()) @@ -749,23 +2454,23 @@ def detect_rings( prominences = props["prominences"][outside_beam] if peaks.size == 0: return self - # strongest, not innermost: a noise bump in a dark gap can sit inside a real ring - # but has far lower prominence. Keep the top max_rings, then order inner -> outer. - strongest = np.argsort(prominences)[::-1][: int(max_rings)] + strongest = np.argsort(prominences)[::-1] + if max_rings is not None: + strongest = strongest[: int(max_rings)] for p in sorted(peaks[strongest]): self.add_ring(float(radii_px[p])) return self def _on_detect_spots_request(self, change=None): n = self._detect_spots_request - if n and n > 0: - self.detect_spots(max_spots=int(n)) + if n: + self.detect_spots(max_spots=int(n) if n > 0 else None) self._detect_spots_request = 0 def _on_detect_rings_request(self, change=None): n = self._detect_rings_request - if n and n > 0: - self.detect_rings(max_rings=int(n)) + if n: + self.detect_rings(max_rings=int(n) if n > 0 else None) self._detect_rings_request = 0 def _snap_to_peak(self, row: float, col: float) -> tuple[float, float]: @@ -782,13 +2487,18 @@ def _snap_to_peak(self, row: float, col: float) -> tuple[float, float]: idx = np.unravel_index(region.argmax(), region.shape) return float(r0 + idx[0]), float(c0 + idx[1]) - def add_spot(self, row: float, col: float) -> Self: - """Add a spot at (row, col). Sub-pixel Gaussian refine if spot_refine, else snap if enabled.""" + def _pick_spot_fields(self, row: float, col: float) -> dict: + # Position + measurement fields per the current pick mode (fit/snap/exact) raw_row, raw_col = float(row), float(col) row_err = col_err = 0.0 fit_quality = None if self.spot_refine: - fit = self._fit_gaussian_2d(raw_row, raw_col) + fit = fit_gaussian_spot( + self._displayed_frame(), + raw_row, + raw_col, + half_window=self.snap_radius, + ) if fit is not None: row, col = fit["row"], fit["col"] row_err, col_err = fit["row_err"], fit["col_err"] @@ -796,8 +2506,7 @@ def add_spot(self, row: float, col: float) -> Self: elif self.snap_enabled: row, col = self._snap_to_peak(raw_row, raw_col) info = self._compute_spot_info(row, col, row_err=row_err, col_err=col_err) - spot = { - "id": (max(s["id"] for s in self.spots) + 1) if self.spots else 1, + return { "row": float(row), "col": float(col), "raw_row": raw_row, @@ -805,15 +2514,33 @@ def add_spot(self, row: float, col: float) -> Self: "row_err": float(row_err), "col_err": float(col_err), "fit_quality": fit_quality, + # source-frame provenance + "frame_idx": int(self.frame_idx), + **empty_index_fields(), + **info, + } + + def add_spot(self, row: float, col: float) -> Self: + """Add a spot, optionally refining or snapping it.""" + spot = { + "id": next_record_id(self.spots), "angle_deg": None, "angle_deg_err": None, - "hkl": "", - "note": "", - **info, + **self._pick_spot_fields(row, col), } self.spots = self._with_angles(list(self.spots) + [spot]) return self + def move_spot(self, spot_id: int, row: float, col: float) -> Self: + """Move the spot with id ``spot_id``, re-picking it at the new position.""" + idx = next((i for i, s in enumerate(self.spots) if s["id"] == spot_id), None) + if idx is None: + return self + spots = list(self.spots) + spots[idx] = {**spots[idx], **self._pick_spot_fields(row, col)} + self.spots = self._with_angles(spots) + return self + def clear_spots(self) -> Self: """Remove all spots.""" self.spots = [] @@ -853,6 +2580,12 @@ def _on_spot_remove_request(self, change=None): self.remove_spot(int(self._spot_remove_request)) self._spot_remove_request = 0 + def _on_spot_move_request(self, change=None): + val = self._spot_move_request + if val and len(val) == 3: + self.move_spot(int(val[0]), val[1], val[2]) + self._spot_move_request = [] + def _recompute_spots(self): if not self.spots: return @@ -860,7 +2593,11 @@ def _recompute_spots(self): { **s, **self._compute_spot_info( - s["row"], s["col"], s.get("row_err", 0.0), s.get("col_err", 0.0) + s["row"], + s["col"], + s.get("row_err", 0.0), + s.get("col_err", 0.0), + frame_idx=s.get("frame_idx"), ), } for s in self.spots @@ -868,17 +2605,20 @@ def _recompute_spots(self): self.spots = self._with_angles(spots) def _on_geometry_change(self, change=None): - # Center/calibration moved → existing spot and ring d-spacings are stale. + # Derived geometry self._recompute_spots() self._recompute_rings() + self._update_profile() + self._update_azimuthal() - def _compute_ring_info(self, radius_px: float) -> dict: + def _compute_ring_info(self, radius_px: float, frame_idx: int | None = None) -> dict: if self.k_calibrated and self.k_pixel_size > 0: g_magnitude = float(radius_px) * self.k_pixel_size d_spacing = 1.0 / g_magnitude if g_magnitude > 0 else None else: g_magnitude = d_spacing = None - radii_px, intensity = self._radial_profile() + # sample the record's source frame + radii_px, intensity = self._radial_profile(frame_idx=frame_idx) ring_intensity = ( float(intensity[int(np.argmin(np.abs(radii_px - radius_px)))]) if radii_px.size @@ -893,10 +2633,13 @@ def _compute_ring_info(self, radius_px: float) -> dict: def add_ring(self, radius_px: float) -> Self: """Add a ring at radius_px from the center (polycrystalline d-spacing pick).""" + if radius_px <= 0: + raise ValueError(f"radius_px must be positive, got {radius_px}") ring = { - "id": (max(r["id"] for r in self.rings) + 1) if self.rings else 1, - "hkl": "", - "note": "", + "id": next_record_id(self.rings), + # source-frame provenance + "frame_idx": int(self.frame_idx), + **empty_index_fields(), **self._compute_ring_info(radius_px), } self.rings = list(self.rings) + [ring] @@ -923,12 +2666,64 @@ def remove_ring(self, ring_id: int) -> Self: def _recompute_rings(self): if not self.rings: return - self.rings = [{**r, **self._compute_ring_info(r["radius_px"])} for r in self.rings] + calibrated = self.k_calibrated and self.k_pixel_size > 0 + rings = [] + for r in self.rings: + ring = {**r, **self._compute_ring_info(r["radius_px"], frame_idx=r.get("frame_idx"))} + if ring.get("fwhm_px") is not None: + ring["fwhm_inv_angstrom"] = ( + ring["fwhm_px"] * self.k_pixel_size if calibrated else None + ) + rings.append(ring) + self.rings = rings + + def fit_ring_profile( + self, + *, + window: float | None = None, + model: str = "gaussian", + subtract_background: bool = True, + ) -> Self: + """Fit each ring peak and store refined radius, width, area, and quality.""" + if not self.rings: + raise ValueError("no rings to fit; call add_ring or detect_rings first") + + radii_px, intensity = self._radial_profile() + profile = intensity.astype(np.float64) + if subtract_background: + try: + _, background = self.radial_background() + profile = profile - background.astype(np.float64) + except ValueError: + pass + + calibrated = self.k_calibrated and self.k_pixel_size > 0 + updates = fit_ring_peaks(radii_px, profile, self.rings, model=model, window=window) + rings = [] + for ring, update in zip(self.rings, updates): + ring = dict(ring) + if update is None: + ring["fit_quality"] = None + rings.append(ring) + continue + raw_radius = update.pop("raw_radius_px") + ring.setdefault("raw_radius_px", raw_radius) + # fit re-measures on the displayed frame + ring["frame_idx"] = int(self.frame_idx) + ring.update(self._compute_ring_info(update["radius_px"])) + ring.update(update) + ring["fwhm_inv_angstrom"] = ring["fwhm_px"] * self.k_pixel_size if calibrated else None + rings.append(ring) + self.rings = rings + return self def _on_ring_add_request(self, change=None): val = self._ring_add_request if val and len(val) == 1: - self.add_ring(val[0]) + try: + self.add_ring(val[0]) + except ValueError: + pass self._ring_add_request = [] def _on_ring_undo_request(self, change=None): @@ -946,126 +2741,934 @@ def _on_ring_remove_request(self, change=None): self.remove_ring(int(self._ring_remove_request)) self._ring_remove_request = 0 + # Request dispatch + _STATUS_REQUESTS = { + "_refine_center_request": ("Refine", "_do_refine_center"), + "_fit_rings_request": ("Ring fit", "_do_fit_rings"), + "_fit_ellipse_request": ("Ellipse", "_do_fit_ellipse"), + "_calibrate_phase_request": ("Phase calibration", "_do_calibrate_phase"), + "_index_rings_request": ("Ring indexing", "_do_index_rings"), + "_index_spots_request": ("Spot indexing", "_do_index_spots"), + "_identify_request": ("Identify", "_do_identify"), + "_auto_request": ("Auto", "_do_auto"), + "_merge_request": ("Merge", "_do_merge"), + } + + def _on_status_request(self, change): + if not change["new"]: + return + prefix, method = self._STATUS_REQUESTS[change["name"]] + try: + # KeyError/TypeError cover malformed custom_phases entries + self.analysis_status = getattr(self, method)() + except (ValueError, ImportError, KeyError, TypeError) as exc: + self.analysis_status = f"{prefix} failed: {exc}" + finally: + setattr(self, change["name"], False) + try: + self.quality_report() + except (ValueError, ImportError): + pass + + def _on_quality_request(self, change=None): + if not self._quality_request: + return + try: + self.quality_report() + self.analysis_status = "Quality updated" + except (ValueError, ImportError) as exc: + self.analysis_status = f"Quality failed: {exc}" + self._quality_request = False + + def _do_refine_center(self) -> str: + self.refine_center(method=self.refine_method) + return f"Center ({self.center_row:.1f}, {self.center_col:.1f}) via {self.center_method}" + + def _do_fit_rings(self) -> str: + self.fit_ring_profile() + n_ok = sum(1 for r in self.rings if r.get("fit_quality") is not None) + status = f"Fitted {n_ok}/{len(self.rings)} rings" + try: + tex = self.texture() + status += f", texture {tex['strength']:.2f} at {tex['angle_deg']:.0f}°" + except (ValueError, ImportError): + pass + return status + + def _do_fit_ellipse(self) -> str: + report = self.fit_ellipse() + return f"Ellipse ratio {report['ratio']:.3f} at {report['angle_deg']:.1f}°" + + def _do_calibrate_phase(self) -> str: + phase = self._require_phase() + self.calibrate_from_phase(phase) + return ( + f"Calibrated from {phase.name}: k={self.k_pixel_size:.5f} 1/Å/px " + f"(rms {self.calibration_rms_px:.2f} px)" + ) + + def _do_index_rings(self) -> str: + phase = self._require_phase() + self.index_rings(phase) + n = sum(1 for r in self.rings if r.get("hkl")) + return f"Indexed {n}/{len(self.rings)} rings against {phase.name}" + + def _do_index_spots(self) -> str: + phase = self._require_phase() + self.index_spots(phase) + zone = f", zone {self.zone_axis}" if self.zone_axis else "" + return f"Indexed spots against {phase.name}{zone}" + + def _do_identify(self) -> str: + ranked = self.search_phases() + return self._identify_summary(ranked) + + def _do_auto(self) -> str: + self.run_auto() + return self.analysis_status + + def _do_merge(self) -> str: + report = self.merge_frames() + status = f"Merged {report['n_used']}/{report['n_frames']} frames" + if "after" in report: + status += ( + f", ring coverage {report['before']['coverage']:.2f} to " + f"{report['after']['coverage']:.2f}" + ) + return status + + def _selected_phase(self) -> Phase | None: + if not self.phase_name: + return None + if self.phase_name in PHASE_LIBRARY: + return library_phase(self.phase_name) + for entry in self.custom_phases: + if entry.get("name") == self.phase_name: + return self._custom_phase(entry) + return None + + @staticmethod + def _custom_phase(entry: dict) -> Phase: + a = float(entry["a"]) + return Phase( + entry["name"], + a, + float(entry.get("b", a)), + float(entry.get("c", a)), + float(entry.get("alpha", 90.0)), + float(entry.get("beta", 90.0)), + float(entry.get("gamma", 90.0)), + absences=entry.get("absences", "none"), + ) + + def _require_phase(self) -> Phase: + phase = self._selected_phase() + if phase is None: + raise ValueError("no phase selected; set phase_name or add a custom phase") + return phase + + def _all_phases(self, custom_only: bool = False) -> list[Phase]: + phases = [] if custom_only else [library_phase(name) for name in PHASE_LIBRARY] + for entry in self.custom_phases: + try: + phases.append(self._custom_phase(entry)) + except (KeyError, ValueError, TypeError): + continue + return phases + + def run_auto( + self, + phase: Phase | None = None, + *, + max_rings: int = 8, + exclude_radius: float | None = None, + ) -> Self: + """Run center finding, ring detection, fitting, calibration, and indexing. + + Silent on success; ``analysis_status`` only reports steps that failed. + """ + phase = phase or self._selected_phase() + problems = [] + self.auto_detect_center(refine=True) + self.detect_rings(max_rings=max_rings, exclude_radius=exclude_radius) + if not self.rings: + problems.append("ring detection failed (no rings found)") + if self.rings: + try: + self.fit_ring_profile() + if all(r.get("fit_quality") is None for r in self.rings): + problems.append("ring fit failed") + except (ValueError, ImportError): + problems.append("ring fit failed") + if phase is None and self.phase_name: + problems.append(f'calibration failed (phase "{self.phase_name}" not found)') + if phase is not None and self.rings: + try: + self.calibrate_from_phase(phase) + except ValueError as exc: + problems.append(f"calibration failed ({exc})") + phase = None + if phase is not None and self.k_calibrated: + try: + self.index_rings(phase) + if not any(r.get("hkl") for r in self.rings): + problems.append("indexing failed (no rings matched)") + except ValueError as exc: + problems.append(f"indexing failed ({exc})") + unindexed = sum(1 for r in self.rings if not r.get("hkl")) + if unindexed and any(r.get("hkl") for r in self.rings): + problems.append( + f"calibration left {unindexed} of {len(self.rings)} rings unindexed " + "(check excluded regions)" + ) + self.analysis_status = "Auto: " + ", ".join(problems) if problems else "" + return self + + def merge_frames( + self, *, statistic: str = "mean", align: bool = True, max_shift: float = 8.0 + ) -> dict: + """Align the stack and append the combined pattern as a new frame.""" + if self.n_frames < 2: + raise ValueError("merge_frames needs a multi-frame stack") + if statistic not in ("mean", "median", "max"): + raise ValueError(f"statistic must be mean, median or max, got {statistic!r}") + frames = self._data.cpu().numpy().astype(np.float64) + # merge original frames only + n_source = getattr(self, "_n_source_frames", None) + if n_source is not None: + frames = frames[:n_source] + if align: + aligned, shifts, used = align_frames(frames, max_shift=max_shift) + else: + aligned, shifts, used = frames, [(0.0, 0.0)] * len(frames), [True] * len(frames) + if not any(used): + raise ValueError("no frames survived alignment; raise max_shift or set align=False") + stack = np.asarray([f for f, u in zip(aligned, used) if u]) + merged = getattr(np, statistic)(stack, axis=0) + report = { + "n_frames": int(len(frames)), + "n_used": int(len(stack)), + "shifts": [(float(s[0]), float(s[1])) for s in shifts], + "used": [bool(u) for u in used], + } + if self.rings: + r0 = max(r["radius_px"] for r in self.rings) + center = (self.center_row, self.center_col) + mask = self._analysis_mask() + before_idx = min(self.frame_idx, len(frames) - 1) + report["before"] = ring_uniformity(frames[before_idx], center, r0, mask=mask) + report["after"] = ring_uniformity(merged, center, r0, mask=mask) + self._ingest_data(np.concatenate([frames, merged[None]], axis=0).astype(np.float32)) + self._n_source_frames = int(len(frames)) + self.frame_idx = self.n_frames - 1 + self._update_frame() + self._bake_offline_frames() + # refresh panes explicitly + self._update_profile() + self._update_azimuthal() + return report + + def quality_report(self) -> dict: + """QC snapshot: center method, calibration, ellipse, ring fits, + unexplained rings, mask coverage, and outermost-ring SNR. + """ + mask = self._analysis_mask() + indexed = [r for r in self.rings if r.get("hkl")] + report = { + # snapshot attribution + "frame_idx": int(self.frame_idx), + "center": {"method": self.center_method or self.center_mode}, + "calibration": { + "source": self.calibration_source, + "k_pixel_size": float(self.k_pixel_size), + "rms_px": float(self.calibration_rms_px), + }, + "ellipse": { + "ratio": float(self.ellipse_ratio), + "angle_deg": float(self.ellipse_angle), + "corrected": bool(self.ellipse_corrected), + }, + "rings": [{"id": r["id"], "fit_quality": r.get("fit_quality")} for r in self.rings], + "n_unexplained_rings": (len(self.rings) - len(indexed)) if indexed else 0, + "mask_coverage_pct": float(mask.mean() * 100.0) if mask is not None else 0.0, + } + if self.rings: + outermost = max(self.rings, key=lambda r: r["radius_px"]) + # SNR on the frame the ring was measured on + snr_idx = outermost.get("frame_idx") + frame = ( + self._displayed_frame() if snr_idx is None else self._get_frame(snr_idx) + ).astype(np.float64) + report["ring_snr"] = ring_uniformity( + frame, (self.center_row, self.center_col), float(outermost["radius_px"]), mask=mask + ) + self._quality = report + return report + + def _update_profile(self, change=None): + if not self.show_profile: + self._profile_data = b"" + return + sector = (self.profile_theta_min, self.profile_theta_max) + sector = None if sector == (0.0, 360.0) else sector + try: + radii, intensity = self.radial_profile( + units="px", + subtract_background=self.profile_subtract_background, + angular_range=sector, + ) + except ValueError as exc: + self.analysis_status = f"Background subtract failed: {exc}" + radii, intensity = self.radial_profile(units="px", angular_range=sector) + self._profile_data = pack_float32_halves(radii, intensity) + + def _update_azimuthal(self, change=None): + if not self.show_azimuthal: + self._azimuthal_data = b"" + return + try: + self._azimuthal_data = pack_float32_halves(*self.azimuthal_profile()) + except ValueError as exc: + self.analysis_status = f"Azimuthal profile failed: {exc}" + self._azimuthal_data = b"" + def _radial_profile( self, *, n_bins: int | None = None, max_radius: float | None = None, + center: tuple[float, float] | None = None, + angular_range: tuple[float, float] | None = None, + frame_idx: int | None = None, ) -> tuple[np.ndarray, np.ndarray]: - """Azimuthally averaged radial intensity profile of the current frame. + frame = self._displayed_frame() if frame_idx is None else self._get_frame(frame_idx) + return radial_profile_px( + frame, + center=center or (self.center_row, self.center_col), + n_bins=n_bins, + max_radius=max_radius, + mask=self._analysis_mask(), + angular_range=angular_range, + ellipse_ratio=self.ellipse_ratio, + ellipse_angle=self.ellipse_angle, + ellipse_corrected=self.ellipse_corrected, + ) - Internal helper for ring detection and ring intensity readout. Bins the - displayed frame by distance (in pixels) from the center and averages the - intensity in each radial bin. + def _analysis_mask(self) -> "np.ndarray | None": + return build_analysis_mask( + (self.det_rows, self.det_cols), + self.mask_regions, + (self.center_row, self.center_col), + ) - Returns - ------- - tuple[np.ndarray, np.ndarray] - ``(radii_px, intensity)`` as float32 arrays, with the radial axis in - pixels. + def radial_profile( + self, + *, + n_bins: int | None = None, + max_radius: float | None = None, + center: tuple[float, float] | None = None, + units: str = "auto", + angular_range: tuple[float, float] | None = None, + subtract_background: bool = False, + ) -> tuple[np.ndarray, np.ndarray]: + """Azimuthally averaged profile in px, g, or d units. + + ``units="q"`` is kept as a legacy alias for ``"g"``. """ - frame = self._displayed_frame() - n_rows, n_cols = frame.shape - center_row = float(self.center_row) - center_col = float(self.center_col) + if units not in ("auto", "px", "g", "q", "d"): + raise ValueError(f"units must be 'auto', 'px', 'g', 'q' or 'd', got {units!r}") + if n_bins is not None and n_bins <= 0: + raise ValueError(f"n_bins must be positive, got {n_bins}") + if max_radius is not None and max_radius <= 0: + raise ValueError(f"max_radius must be positive, got {max_radius}") + if angular_range is not None and len(angular_range) != 2: + raise ValueError("angular_range must be a (start_deg, end_deg) pair") + calibrated = self.k_calibrated and self.k_pixel_size > 0 + if units in ("g", "q", "d") and not calibrated: + raise ValueError( + f"radial_profile(units={units!r}) needs a calibrated pattern; call " + "calibrate_from_ring / calibrate_from_spot / calibrate_from_phase first" + ) + radii_px, intensity = self._radial_profile( + n_bins=n_bins, max_radius=max_radius, center=center, angular_range=angular_range + ) + if subtract_background: + _, background = self.radial_background( + n_bins=n_bins, max_radius=max_radius, center=center + ) + intensity = intensity - background + if units == "auto": + units = "g" if calibrated else "px" + if units == "px": + return radii_px, intensity + if units in ("g", "q"): + return (radii_px * self.k_pixel_size).astype(np.float32), intensity + keep = radii_px > 0 + d_axis = (1.0 / (radii_px[keep] * self.k_pixel_size)).astype(np.float32) + return d_axis, intensity[keep] + + def _ring_radius_for(self, ring_id: int | None, radius_px: float | None) -> float: + if radius_px is not None: + if radius_px <= 0: + raise ValueError(f"radius_px must be positive, got {radius_px}") + return float(radius_px) + if not self.rings: + raise ValueError("no ring to analyze; call detect_rings / add_ring or pass radius_px") + if ring_id is None: + return float(max(self.rings, key=lambda r: r["radius_px"])["radius_px"]) + matches = [r for r in self.rings if r["id"] == ring_id] + if not matches: + raise ValueError(f"no ring with id {ring_id}; have {[r['id'] for r in self.rings]}") + return float(matches[0]["radius_px"]) + + def _ring_half_width(self, radius_px: float) -> float: + """Default annulus half-width that keeps neighboring rings out.""" + gaps = [abs(radius_px - r["radius_px"]) for r in self.rings if r["radius_px"] != radius_px] + return min(6.0, max(3.0, min(gaps) / 2.0)) if gaps else max(6.0, 0.25 * radius_px) + + def azimuthal_profile( + self, + *, + ring_id: int | None = None, + radius_px: float | None = None, + width: float | None = None, + n_theta: int = 180, + ) -> tuple[np.ndarray, np.ndarray]: + """Intensity vs azimuth around a ring.""" + radius = self._ring_radius_for(ring_id, radius_px) + half_width = float(width) if width is not None else self._ring_half_width(radius) + return azimuthal_profile_from_frame( + self._displayed_frame(), + center=(self.center_row, self.center_col), + radius_px=radius, + half_width=half_width, + n_theta=n_theta, + mask=self._analysis_mask(), + ellipse_ratio=self.ellipse_ratio, + ellipse_angle=self.ellipse_angle, + ellipse_corrected=self.ellipse_corrected, + ) + + def texture( + self, + *, + ring_id: int | None = None, + radius_px: float | None = None, + width: float | None = None, + n_theta: int = 180, + return_profile: bool = False, + ) -> dict: + """Order-2 ring texture: strength in [0, 1] and 180-degree angle.""" + theta_deg, intensity = self.azimuthal_profile( + ring_id=ring_id, radius_px=radius_px, width=width, n_theta=n_theta + ) + return texture_from_profile(theta_deg, intensity, return_profile=return_profile) + + def fit_ellipse(self, ring_id: int | None = None, *, n_theta: int = 180) -> dict: + """Fit ellipse distortion from ring radius vs azimuth.""" + radius = self._ring_radius_for(ring_id, None) + half_width = self._ring_half_width(radius) + theta_centers, counts, _, weight_sum, weighted_radius_sum = ring_sectors( + self._displayed_frame(), + center=(self.center_row, self.center_col), + radius_px=radius, + half_width=half_width, + n_theta=n_theta, + mask=self._analysis_mask(), + use_corrected_radius=False, + ) + report = fit_ellipse_from_sectors(theta_centers, counts, weight_sum, weighted_radius_sum) + self.ellipse_ratio = report["ratio"] + self.ellipse_angle = report["angle_deg"] + return report + + def apply_ellipse_correction(self, *, enable: bool = True) -> Self: + """Enable or disable radius circularization by the fitted ellipse.""" + self.ellipse_corrected = bool(enable) + return self - if max_radius is None: - max_radius = float( - min(center_row, center_col, (n_rows - 1) - center_row, (n_cols - 1) - center_col) + def radial_background( + self, + *, + n_bins: int | None = None, + max_radius: float | None = None, + center: tuple[float, float] | None = None, + method: str = "power", + poly_order: int = 3, + peak_windows: list[tuple[float, float]] | None = None, + exclude_radius: float | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """Fit a smooth radial background while excluding peaks.""" + radii_px, intensity = self._radial_profile( + n_bins=n_bins, max_radius=max_radius, center=center + ) + if exclude_radius is None: + exclude_radius = self.bf_radius + if peak_windows is None: + peak_windows = [] + for ring in self.rings: + half = ring.get("fwhm_px") or 6.0 + peak_windows.append((ring["radius_px"] - half, ring["radius_px"] + half)) + return radii_px, fit_radial_background( + radii_px, + intensity, + peak_windows=peak_windows, + exclude_radius=exclude_radius, + method=method, + poly_order=poly_order, + ) + + # Indexing and phase identification + def _require_calibrated(self) -> None: + if not (self.k_calibrated and self.k_pixel_size > 0): + raise ValueError( + "pattern is uncalibrated; call calibrate_from_ring / calibrate_from_spot first" ) - max_radius = float(max(1.0, max_radius)) - if n_bins is None: - n_bins = max(1, int(round(max_radius))) - n_bins = int(max(1, n_bins)) + def _match_report(self, phase: Phase, d_values: Sequence[float | None], tol: float) -> dict: + errors = [] + for d in d_values: + if d and d > 0: + cands = phase.match_d(d, tol) + if cands: + errors.append(cands[0]["d_error"]) + n_total = sum(1 for d in d_values if d and d > 0) + mean_err = float(np.mean(errors)) if errors else 0.0 + return { + "name": phase.name, + "n_matched": len(errors), + "n_total": n_total, + "mean_error": mean_err, + } - rows = np.arange(n_rows, dtype=np.float64)[:, None] - cols = np.arange(n_cols, dtype=np.float64)[None, :] - radii = np.sqrt((rows - center_row) ** 2 + (cols - center_col) ** 2) - flat_r = radii.ravel() - flat_i = frame.astype(np.float64).ravel() + def _set_phase_match(self, report: dict, absences: str) -> None: + pct = 100.0 * report["mean_error"] + self.phase_match = ( + f"{report['name']} ({absences}): " + f"{report['n_matched']}/{report['n_total']} matched, {pct:.1f}% mean error" + ) - edges = np.linspace(0.0, max_radius, n_bins + 1) - idx = np.digitize(flat_r, edges) - 1 - inside = (idx >= 0) & (idx < n_bins) - idx = idx[inside] - vals = flat_i[inside] + def index_rings(self, phase: Phase, tol: float = 0.03, replace: bool = True) -> Self: + """Label rings by d-spacing match against a calibrated phase.""" + self._require_calibrated() + rings = [dict(r) for r in self.rings] + for r in rings: + if not replace and r.get("hkl"): + continue + d = r.get("d_spacing") + cands = phase.match_d(d, tol) if d else [] + r["hkl_candidates"] = [c["hkl_str"] for c in cands] + r.update(index_assignment(cands[0] if cands else None)) + self.rings = rings + self._set_phase_match( + self._match_report(phase, [r.get("d_spacing") for r in rings], tol), phase.absences + ) + return self - counts = np.bincount(idx, minlength=n_bins).astype(np.float64) - sums = np.bincount(idx, weights=vals, minlength=n_bins) - with np.errstate(invalid="ignore", divide="ignore"): - intensity = np.where(counts > 0, sums / counts, 0.0) + def identify_phase(self, database, tol: float = 0.03) -> list[dict]: + """Rank an explicit list of candidate phases against measured d-spacings. - bin_centers_px = 0.5 * (edges[:-1] + edges[1:]) - return bin_centers_px.astype(np.float32), intensity.astype(np.float32) + This is the primary verification workflow: build the candidates you + expect (:func:`~quantem.widget.library_phase`, + :meth:`Phase.from_cubic`, :meth:`Phase.from_dspacings`, ...) and rank + only those. Use :meth:`search_phases` when you have no candidates in + mind. + """ + self._require_calibrated() + phases = list(database) + reports = self._rank_phases(self._observed_d(), phases, tol, max(len(phases), 1)) + self._set_identify_results(reports) + return reports - def calibrate_from_spot(self, row: float, col: float, d_known: float) -> Self: - """Calibrate ``k_pixel_size`` from a spot of known d-spacing. - - Sets the k-space sampling so the spot at (row, col), measured from the - current center, corresponds to a d-spacing of ``d_known``. - - Parameters - ---------- - row, col : float - Spot position in detector pixels. - d_known : float - Known d-spacing in Å (must be positive). - - Returns - ------- - Self - The widget, for chaining. - - Raises - ------ - ValueError - If ``d_known`` is not positive or the spot lies at the center. + def search_phases( + self, + *, + tol: float = 0.03, + elements=None, + exclude_elements=None, + extra=None, + custom_only: bool | None = None, + top_n: int = 10, + ) -> list[dict]: + """Rank library, custom, and extra phases against measured d-spacings. + + With ``custom_only`` (default: the ``identify_custom_only`` trait) the + library is skipped and only user candidates (custom phases plus + ``extra``) are ranked. """ - if d_known <= 0: - raise ValueError(f"d_known must be positive, got {d_known}") - r_pixels = math.hypot(row - self.center_row, col - self.center_col) - if r_pixels <= 0: - raise ValueError("calibration point is at the center; no g-vector") + self._require_calibrated() + observed = self._observed_d() + if custom_only is None: + custom_only = self.identify_custom_only + allowed = parse_elements(elements if elements is not None else self.identify_elements) + excluded = parse_elements(exclude_elements) + candidates = list(self._all_phases(custom_only=custom_only)) + list(extra or []) + if custom_only and not candidates: + raise ValueError("no candidate phases; add custom phases or pass extra") + phases = [] + for phase in candidates: + els = element_symbols(phase.name) + if allowed is not None and els and not els <= allowed: + continue + if excluded and els & excluded: + continue + phases.append(phase) + reports = self._rank_phases(observed, phases, tol, top_n) + self._set_identify_results(reports) + return reports + + def _observed_d(self) -> list[float]: + source = self.rings if self.rings else self.spots + observed = sorted(d for d in (x.get("d_spacing") for x in source) if d and d > 0) + if not observed: + raise ValueError("no measured d-spacings; add rings or spots first") + return observed + + @staticmethod + def _phase_lines(phase: Phase, d_min: float) -> list[dict]: + return [ + { + "d": reflection["d"], + "hkl": reflection["hkl_str"], + "i_rel": reflection["intensity"], + } + for reflection in phase.reflections(d_min=d_min) + ] + + def _rank_phases(self, observed, phases, tol, top_n) -> list[dict]: + reports = [] + d_min = min(observed) * 0.8 + for phase in phases: + # skip degenerate candidates + try: + lines = self._phase_lines(phase, d_min=d_min) + except ValueError: + continue + if len(lines) < 2: + continue + report = match_candidate(observed, lines, tol=tol) + report.update( + { + "phase_id": f"phase-{phase.name}", + "name": phase.name, + "lines": self._match_lines(observed, lines, report), + } + ) + report.pop("assignments", None) + reports.append(report) + if not reports: + raise ValueError("no candidate phases pass the filters") + reports.sort(key=match_sort_key) + return reports[: int(top_n)] + + def _identify_summary(self, reports: list[dict]) -> str: + top = reports[0] + status = f"{top['name']}: {top['matched']}/{top['n_obs']} lines" + if top["n_obs"] < 4: + status += "; few measured lines" + runners = ", ".join( + report["name"] + + ( + f" (also {report['matched']}/{report['n_obs']})" + if report["matched"] == top["matched"] + else "" + ) + for report in reports[1:3] + ) + return f"{status}; next: {runners}" if runners else status + + def _set_identify_results(self, reports: list[dict]) -> None: + self._identify_results = reports[:10] + self.phase_match = self._identify_summary(reports) + + @staticmethod + def _match_lines(observed: list[float], lines: list[dict], report: dict) -> list[dict]: + rows = [] + assignments = dict(report.get("assignments") or []) + used_refs = set() + for obs_index, measured_d in enumerate(observed): + ref_index = assignments.get(obs_index) + if ref_index is None: + rows.append( + { + "obs_d": float(measured_d), + "ref_d": None, + "hkl": "", + "err": None, + "i_rel": None, + } + ) + else: + ref = lines[ref_index] + used_refs.add(ref_index) + rows.append( + { + "obs_d": float(measured_d), + "ref_d": float(ref["d"]), + "hkl": ref.get("hkl", ""), + "err": abs(ref["d"] - measured_d) / ref["d"], + "i_rel": ref.get("i_rel"), + } + ) + lo, hi = min(observed), max(observed) + missing = [ + (j, ref) + for j, ref in enumerate(lines) + if j not in used_refs and lo <= ref["d"] <= hi and (ref.get("i_rel") or 0) >= 25 + ] + missing.sort(key=lambda x: -(x[1].get("i_rel") or 0)) + for _, ref in missing[:5]: + rows.append( + { + "obs_d": None, + "ref_d": float(ref["d"]), + "hkl": ref.get("hkl", ""), + "err": None, + "i_rel": ref.get("i_rel"), + } + ) + return rows + + def _spot_vector(self, spot: dict) -> tuple[float, float]: + return corrected_vector( + spot["row"] - self.center_row, + spot["col"] - self.center_col, + ellipse_ratio=self.ellipse_ratio, + ellipse_angle=self.ellipse_angle, + ellipse_corrected=self.ellipse_corrected, + ) + + def _measured_angle(self, s1: dict, s2: dict) -> float: + dr1, dc1 = self._spot_vector(s1) + dr2, dc2 = self._spot_vector(s2) + r1, r2 = math.hypot(dr1, dc1), math.hypot(dr2, dc2) + if r1 == 0 or r2 == 0: + return 0.0 + cos_a = max(-1.0, min(1.0, (dr1 * dr2 + dc1 * dc2) / (r1 * r2))) + return math.degrees(math.acos(cos_a)) + + @staticmethod + def _hkl_variants(phase: Phase, hkl: Sequence[int]) -> list[tuple[int, int, int]]: + """All equal-d images of a reflection, canonical first. + + Signed permutations do not close hexagonal families (the 60-degree + images of (110) are (-1,2,0)-type), so the whole index grid within a + sum-of-|indices| bound is scanned for reflections at the same d. + """ + d_ref = phase.d_spacing(hkl) + bound = max(1, int(sum(abs(int(i)) for i in hkl))) + axis = np.arange(-bound, bound + 1) + grid = np.stack(np.meshgrid(axis, axis, axis, indexing="ij"), axis=-1).reshape(-1, 3) + inv_d_sq = np.einsum("ij,jk,ik->i", grid, phase._g_star, grid) + ref = 1.0 / d_ref**2 + close = np.abs(inv_d_sq - ref) <= 2e-6 * ref # rel_tol 1e-6 on d + variants = { + tuple(int(i) for i in row) for row in grid[close] if phase.is_allowed(row) + } + return sorted(variants, key=_label_preference) + + def _find_anchor_pair( + self, + phase: Phase, + spots: list[dict], + cand_lists: list[list[dict]], + angle_tol: float, + ) -> tuple[int, int, dict, dict] | None: + """First non-collinear spot pair that matches phase angle geometry.""" + for i in range(len(spots)): + for j in range(i + 1, len(spots)): + if not cand_lists[i] or not cand_lists[j]: + continue + measured = self._measured_angle(spots[i], spots[j]) + if measured < 1e-6: + continue + best = None + for ci in cand_lists[i]: + for cj in cand_lists[j]: + err = abs(phase.plane_angle(ci["hkl"], cj["hkl"]) - measured) + if err <= angle_tol and (best is None or err < best[0]): + best = (err, ci, cj) + if best is not None: + return i, j, best[1], best[2] + return None + + def index_spots(self, phase: Phase, tol: float = 0.03, angle_tol: float = 3.0) -> Self: + """Index spots and solve the zone axis from an angle-consistent anchor pair.""" + self._require_calibrated() + if phase.lattice is None: + raise ValueError( + "index_spots needs a lattice-based Phase (from_cubic / full constructor) " + "for the inter-spot angle check; a d-spacing card has no angles" + ) + spots = [dict(s) for s in self.spots] + cand_lists = [] + for s in spots: + d = s.get("d_spacing") + cands = phase.match_d(d, tol) if d else [] + s["hkl_candidates"] = [c["hkl_str"] for c in cands] + cand_lists.append(cands) + + anchors = self._find_anchor_pair(phase, spots, cand_lists, angle_tol) + if anchors is None: + for s, cands in zip(spots, cand_lists): + s.update(index_assignment(cands[0] if cands else None)) + self.spots = self._with_angles(spots) + self.zone_axis = "" + return self + + i, j, ci, cj = anchors + # zone axis from the second-reflection variant matching the measured angle + measured_ij = self._measured_angle(spots[i], spots[j]) + cj_variant = min( + self._hkl_variants(phase, cj["hkl"]), + key=lambda v: round(abs(phase.plane_angle(ci["hkl"], v) - measured_ij), 6), + ) + h1, k1, l1 = (int(x) for x in ci["hkl"]) + h2, k2, l2 = cj_variant + axis = (k1 * l2 - l1 * k2, l1 * h2 - h1 * l2, h1 * k2 - k1 * h2) + + def in_zone(hkl): + return hkl[0] * axis[0] + hkl[1] * axis[1] + hkl[2] * axis[2] == 0 + + # remaining spots: family variant satisfying the zone law and angle + anchor_choice = {i: (ci, tuple(int(x) for x in ci["hkl"])), j: (cj, cj_variant)} + for idx, (s, cands) in enumerate(zip(spots, cand_lists)): + if idx in anchor_choice: + chosen, variant = anchor_choice[idx] + else: + measured = self._measured_angle(spots[i], s) + chosen, variant, best_err = None, None, None + for c in cands: + for v in self._hkl_variants(phase, c["hkl"]): + if not in_zone(v): + continue + err = round(abs(phase.plane_angle(ci["hkl"], v) - measured), 6) + if err <= angle_tol and (best_err is None or err < best_err): + chosen, variant, best_err = c, v, err + if chosen is not None and not in_zone(tuple(int(x) for x in chosen["hkl"])): + chosen = {**chosen, "hkl_str": _format_hkl(variant)} + s.update(index_assignment(chosen)) + + self.spots = self._with_angles(spots) + self.zone_axis = format_zone_axis(ci["hkl"], cj_variant) + self._set_phase_match( + self._match_report(phase, [s.get("d_spacing") for s in spots], tol), phase.absences + ) + return self + + def _apply_calibration(self, d_known: float, r_pixels: float, source: str) -> Self: self.k_pixel_size = 1.0 / (d_known * r_pixels) self.k_calibrated = True - self.calibration_source = "from_spot" + self.calibration_source = source self.calibration_ref_d = float(d_known) self.calibration_ref_radius = float(r_pixels) return self + def calibrate_from_spot(self, row: float, col: float, d_known: float) -> Self: + """Calibrate ``k_pixel_size`` from a spot of known d-spacing.""" + if d_known <= 0: + raise ValueError(f"d_known must be positive, got {d_known}") + r_pixels = float( + corrected_radius( + row - self.center_row, + col - self.center_col, + ellipse_ratio=self.ellipse_ratio, + ellipse_angle=self.ellipse_angle, + ellipse_corrected=self.ellipse_corrected, + ) + ) + if r_pixels <= 0: + raise ValueError("calibration point is at the center; no g-vector") + return self._apply_calibration(d_known, r_pixels, "from_spot") + def calibrate_from_ring(self, radius_px: float, d_known: float) -> Self: - """Calibrate ``k_pixel_size`` from a ring of known d-spacing. - - Sets the k-space sampling so a ring at ``radius_px`` from the center - corresponds to a d-spacing of ``d_known``. - - Parameters - ---------- - radius_px : float - Ring radius in detector pixels (must be positive). - d_known : float - Known d-spacing in Å (must be positive). - - Returns - ------- - Self - The widget, for chaining. - - Raises - ------ - ValueError - If ``d_known`` or ``radius_px`` is not positive. - """ + """Calibrate ``k_pixel_size`` from a ring of known d-spacing.""" if d_known <= 0: raise ValueError(f"d_known must be positive, got {d_known}") if radius_px <= 0: raise ValueError(f"radius_px must be positive, got {radius_px}") - self.k_pixel_size = 1.0 / (d_known * radius_px) + return self._apply_calibration(d_known, radius_px, "from_ring") + + def calibrate_from_phase(self, phase: Phase, *, tol: float = 0.03, d_min: float = 0.5) -> Self: + """Fit ``k_pixel_size`` by assigning ring-radius ratios to a known phase.""" + if len(self.rings) < 2: + raise ValueError( + "calibrate_from_phase needs >= 2 rings; use calibrate_from_ring for a single ring" + ) + refl = phase.reflections(d_min=d_min) + if not refl: + raise ValueError(f"{phase.name} has no reflections above d_min={d_min}") + radii = [float(r["radius_px"]) for r in self.rings] + inv_d = [1.0 / rf["d"] for rf in refl] + + r_inner = min(radii) + best = None + for x0 in inv_d: + scale = r_inner / x0 + assigned, errs = [], [] + for r in radii: + x_pred = r / scale + if x_pred <= 0: + assigned.append(None) + errs.append(None) + continue + nearest = min(range(len(inv_d)), key=lambda i: abs(inv_d[i] - x_pred)) + err = abs(inv_d[nearest] - x_pred) / abs(x_pred) + assigned.append(nearest if err <= tol else None) + errs.append(err if err <= tol else None) + used = [a for a in assigned if a is not None] + n_ok = len(used) + if n_ok < 2: + continue + in_tol = [e for e in errs if e is not None] + mean_err = float(np.mean(in_tol)) + # only machine-precision assignments outrank the low-order preference + worst_px = max(e * r for e, r in zip(errs, radii) if e is not None) + exact = 1 if worst_px <= 1e-6 else 0 + candidate_key = (n_ok, exact, -sum(inv_d[a] for a in used), -mean_err) + if best is None or candidate_key > best[0]: + best = (candidate_key, assigned) + if best is None: + raise ValueError( + f"could not assign >= 2 rings to {phase.name} reflections within tol={tol}; " + "check the phase or calibrate_from_ring manually" + ) + _, assigned = best + + pairs = [ + (radius_px, inv_d[reflection_index]) + for radius_px, reflection_index in zip(radii, assigned) + if reflection_index is not None + ] + scale = sum(radius_px * q for radius_px, q in pairs) / sum(q * q for _, q in pairs) + self.k_pixel_size = 1.0 / scale self.k_calibrated = True - self.calibration_source = "from_ring" - self.calibration_ref_d = float(d_known) - self.calibration_ref_radius = float(radius_px) + self.calibration_source = "from_phase" + self.calibration_ref_d = 0.0 + self.calibration_ref_radius = 0.0 + + resids = [] + rings = [dict(r) for r in self.rings] + for ring, radius_px, reflection_index in zip(rings, radii, assigned): + if reflection_index is None: + ring["hkl_candidates"] = [] + ring.update(index_assignment(None)) + ring["radius_resid_px"] = None + continue + reflection = refl[reflection_index] + measured_d = 1.0 / (radius_px * self.k_pixel_size) + assignment = { + "hkl_str": reflection["hkl_str"], + "d": reflection["d"], + "d_error": abs(measured_d - reflection["d"]) / reflection["d"], + } + ring["hkl_candidates"] = [reflection["hkl_str"]] + ring.update(index_assignment(assignment)) + residual_px = radius_px - scale * inv_d[reflection_index] + ring["radius_resid_px"] = residual_px + resids.append(residual_px) + self.rings = rings + self.calibration_rms_px = float(np.sqrt(np.mean(np.square(resids)))) return self def _on_calibrate_from_ring_request(self, change=None): @@ -1073,8 +3676,8 @@ def _on_calibrate_from_ring_request(self, change=None): if val and len(val) == 2: try: self.calibrate_from_ring(val[0], val[1]) - except ValueError: - pass + except ValueError as exc: + self.analysis_status = f"Calibrate failed: {exc}" self._calibrate_from_ring_request = [] def _on_calibrate_from_spot_request(self, change=None): @@ -1082,156 +3685,26 @@ def _on_calibrate_from_spot_request(self, change=None): if val and len(val) == 3: try: self.calibrate_from_spot(val[0], val[1], val[2]) - except ValueError: - pass + except ValueError as exc: + self.analysis_status = f"Calibrate failed: {exc}" self._calibrate_from_spot_request = [] - _MEASUREMENT_COLUMNS = [ - "id", "kind", "raw_row", "raw_col", "row", "col", "row_err", "col_err", - "r_pixels", "r_pixels_err", "g_inv_angstrom", "g_inv_angstrom_err", - "d_angstrom", "d_angstrom_err", "angle_deg", "angle_deg_err", - "intensity", "fit_quality", "hkl", "note", - ] - - @staticmethod - def _build_measurement_records(spots, rings) -> list: - # Flatten spots and rings into unified measurement rows. - records = [] - for s in spots: - records.append({ - "id": s.get("id"), - "kind": "spot", - "raw_row": s.get("raw_row"), - "raw_col": s.get("raw_col"), - "row": s.get("row"), - "col": s.get("col"), - "row_err": s.get("row_err"), - "col_err": s.get("col_err"), - "r_pixels": s.get("r_pixels"), - "r_pixels_err": s.get("r_pixels_err"), - "g_inv_angstrom": s.get("g_magnitude"), - "g_inv_angstrom_err": s.get("g_magnitude_err"), - "d_angstrom": s.get("d_spacing"), - "d_angstrom_err": s.get("d_spacing_err"), - "angle_deg": s.get("angle_deg"), - "angle_deg_err": s.get("angle_deg_err"), - "intensity": s.get("intensity"), - "fit_quality": s.get("fit_quality"), - "hkl": s.get("hkl", ""), - "note": s.get("note", ""), - }) - for r in rings: - records.append({ - "id": r.get("id"), - "kind": "ring", - "raw_row": None, - "raw_col": None, - "row": None, - "col": None, - "row_err": None, - "col_err": None, - "r_pixels": r.get("radius_px"), - "r_pixels_err": None, - "g_inv_angstrom": r.get("g_magnitude"), - "g_inv_angstrom_err": None, - "d_angstrom": r.get("d_spacing"), - "d_angstrom_err": None, - "angle_deg": None, - "angle_deg_err": None, - "intensity": r.get("intensity"), - "fit_quality": None, - "hkl": r.get("hkl", ""), - "note": r.get("note", ""), - }) - return records - - def _measurement_records(self) -> list: - return self._build_measurement_records(self.spots, self.rings) - - @staticmethod - def _measurement_metadata(state) -> dict: - # Provenance header for the measurement table. - return { - "widget_name": "ShowDiffraction", - "center_row": state.get("center_row"), - "center_col": state.get("center_col"), - "k_pixel_size_inv_angstrom_per_px": state.get("k_pixel_size"), - "calibrated": bool(state.get("k_calibrated")), - "calibration_source": state.get("calibration_source", "none"), - "calibration_ref_d_angstrom": state.get("calibration_ref_d", 0.0), - "calibration_ref_radius_px": state.get("calibration_ref_radius", 0.0), - } - - @staticmethod - def _write_measurement_file(path, records, meta) -> pathlib.Path: - # .json writes a {"metadata", "measurements"} document; anything else CSV. - p = pathlib.Path(path) - if p.suffix.lower() == ".json": - p.write_text(json.dumps({"metadata": meta, "measurements": records}, indent=2)) - else: - with open(p, "w", newline="") as f: - writer = csv.DictWriter(f, fieldnames=ShowDiffraction._MEASUREMENT_COLUMNS) - writer.writeheader() - writer.writerows(records) - return p - def export_measurements(self, path: str) -> pathlib.Path: - """Export the spot and ring measurements to a CSV or JSON file. - - The format is inferred from the file extension: ``.json`` writes a - ``{"metadata": ..., "measurements": ...}`` document, anything else - writes CSV with the columns in ``_MEASUREMENT_COLUMNS``. This table is - fully contained in the saved state, so you do not need to keep it as a - separate file -- ``measurements_from_state`` rebuilds it on demand. - - Parameters - ---------- - path : str - Output file path. A ``.json`` suffix selects JSON; otherwise CSV. - - Returns - ------- - pathlib.Path - The written file path. - """ - return self._write_measurement_file( - path, self._measurement_records(), self._measurement_metadata(self.state_dict()) + """Export spot and ring measurements as CSV or JSON.""" + return write_measurement_file( + path, + build_measurement_records(self.spots, self.rings), + measurement_metadata(self.state_dict()), ) @classmethod def measurements_from_state(cls, state, path=None): - """Rebuild the spot/ring measurement table from a saved state. - - The saved state already holds every spot and ring, so the measurement - table is derived from it -- there is no need to keep a separate CSV/JSON - export next to the state file. Regenerate it whenever needed, without - loading the image data. - - Parameters - ---------- - state : dict, str, or pathlib.Path - A saved state file path, or an already-loaded state dict/envelope. - path : str or pathlib.Path, optional - Where to write the table (``.json`` selects JSON, otherwise CSV). If - omitted, the list of measurement records is returned instead. - - Returns - ------- - list[dict] or pathlib.Path - The measurement records, or the written file path when ``path`` is set. - """ - if isinstance(state, (str, pathlib.Path)): - state = unwrap_state_payload( - json.loads(pathlib.Path(state).read_text()), require_envelope=True - ) - else: - state = unwrap_state_payload(state) - records = cls._build_measurement_records( - state.get("spots", []), state.get("rings", []) - ) + """Rebuild the measurement table from a saved state.""" + state = cls._resolve_state(state) + records = build_measurement_records(state.get("spots", []), state.get("rings", [])) if path is None: return records - return cls._write_measurement_file(path, records, cls._measurement_metadata(state)) + return write_measurement_file(path, records, measurement_metadata(state)) def export_html( self, @@ -1240,32 +3713,7 @@ def export_html( title: str | None = None, **options, ) -> pathlib.Path: - """Write a standalone HTML viewer for this widget. - - The exported file mounts the live anywidget JS bundle with the current - widget state (frames, center, calibration, spots, rings, display - settings) and opens in any browser without a Jupyter kernel. - - ShowDiffraction always embeds the exact ``full`` float32 frames -- there - is no gallery and no reduced encoding. The ``mode``/``encoding``/ - ``downsample`` keys are accepted via ``**options`` for cross-widget API - compatibility but are treated as no-ops. - - Parameters - ---------- - path : str or pathlib.Path, optional - Destination HTML path. Defaults to a slug derived from the title. - title : str, optional - Browser page title. Defaults to the widget ``title`` or - ``"ShowDiffraction"``. - **options - Accepted and ignored (compatibility with the HTML export protocol). - - Returns - ------- - pathlib.Path - The written file path. - """ + """Write a standalone HTML viewer with exact float32 frames.""" if not hasattr(self, "_data") or self._data is None: raise ValueError("Cannot export HTML after free(); rebuild the widget first.") export_path = pathlib.Path(path) if path is not None else self._default_html_export_path() @@ -1349,6 +3797,9 @@ def _clone_for_html_export(self) -> Self: if not hasattr(self, "_data") or self._data is None: raise ValueError("Cannot export HTML after free(); rebuild the widget first.") clone = type(self)(to_numpy(self._data), state=self.state_dict(), verbose=False) + # derived panels are not state fields + clone._identify_results = list(self._identify_results) + clone._quality = dict(self._quality) clone.offline = True clone.export_enabled = False clone.export_status = "" @@ -1360,24 +3811,19 @@ def _clone_for_html_export(self) -> Self: def set_image(self, data) -> Self: """Replace data. Preserves display settings, clears spots and rings.""" - if hasattr(data, "_fields") and "data" in getattr(data, "_fields", ()): - meta = data.metadata or {} - if meta.get("pixel_size") is not None: - self.pixel_size = float(meta.get("pixel_size")) - data = data.data - if hasattr(data, "sampling") and hasattr(data, "array"): - units = list(getattr(data, "units", ["pixels"] * 4)) - if units and units[0] in ("Å", "angstrom", "A", "nm"): - px = float(data.sampling[0]) - if units[0] == "nm": - px *= 10 - self.pixel_size = px - if len(units) > 2 and units[2] in ("1/Å", "1/A"): - self.k_pixel_size = float(data.sampling[2]) - self.k_calibrated = True - if hasattr(data, "name") and data.name: - self.title = str(data.name) - data = data.array + data, title, pixel_size, k_pixel_size, metadata_calibrated = normalize_data_input( + data, + title=self.title, + replace_title=True, + ) + self.title = title + if pixel_size is not None: + self.pixel_size = float(pixel_size) + if k_pixel_size is not None and k_pixel_size > 0: + self.k_pixel_size = float(k_pixel_size) + self.k_calibrated = True + if metadata_calibrated: + self.calibration_source = "metadata" self._ingest_data(data) self.frame_idx = min(self.frame_idx, self.n_frames - 1) self.spots = [] @@ -1385,39 +3831,21 @@ def set_image(self, data) -> Self: self.auto_detect_center() self._update_frame() self._bake_offline_frames() + # refresh panes explicitly + self._update_profile() + self._update_azimuthal() return self def state_dict(self): - return { - "title": self.title, - "frame_idx": self.frame_idx, - "pixel_size": self.pixel_size, - "k_pixel_size": self.k_pixel_size, - "k_calibrated": self.k_calibrated, - "center_row": self.center_row, - "center_col": self.center_col, - "bf_radius": self.bf_radius, - "spots": list(self.spots), - "rings": list(self.rings), - "snap_enabled": self.snap_enabled, - "snap_radius": self.snap_radius, - "spot_refine": self.spot_refine, - "center_mode": self.center_mode, - "calibration_source": self.calibration_source, - "calibration_ref_d": self.calibration_ref_d, - "calibration_ref_radius": self.calibration_ref_radius, - "dp_colormap": self.dp_colormap, - "dp_scale_mode": self.dp_scale_mode, - "dp_invert": self.dp_invert, - "dp_vmin_pct": self.dp_vmin_pct, - "dp_vmax_pct": self.dp_vmax_pct, - "show_title": self.show_title, - "show_stats": self.show_stats, - "show_controls": self.show_controls, - "controls_collapsed": self.controls_collapsed, - } + """Return the persistable widget state as a plain dict.""" + state = {} + for field in self._STATE_FIELDS: + value = getattr(self, field) + state[field] = list(value) if field in self._LIST_STATE_FIELDS else value + return state def save(self, path: str): + """Write the widget state to a JSON file.""" save_state_file(path, "ShowDiffraction", self.state_dict()) def collapse_controls(self) -> Self: @@ -1436,25 +3864,56 @@ def toggle_controls(self) -> Self: return self def load_state_dict(self, state): - allowed_keys = set(self.state_dict().keys()) + """Restore widget state from a dict; unknown keys are ignored.""" + deferred = {} for key, val in state.items(): - # frame_idx is clamped by its validator against the current stack. - if key in allowed_keys: - setattr(self, key, val) + if key not in self._STATE_FIELDS: + continue + # measurement records last, so geometry restores do not resample them + if key in ("spots", "rings"): + deferred[key] = val + continue + if key == "frame_idx": + requested = int(val) + self.frame_idx = requested + if self.frame_idx != requested: + self.analysis_status = ( + f"Saved frame_idx {requested} clamped to {self.frame_idx}: " + f"data has {self.n_frames} frames" + ) + continue + setattr(self, key, val) + for key, val in deferred.items(): + setattr(self, key, val) def summary(self): - lines = [self.title or "ShowDiffraction", "═" * 32] + """Print a text summary of calibration, spots, rings, and indexing.""" + lines = [self.title or "ShowDiffraction"] lines.append(f"Frames: {self.n_frames} (showing #{self.frame_idx})") - k_unit = "1/Å" if self.k_calibrated else "px" - k_val = f"{self.k_pixel_size:.4f}" if self.k_calibrated else "uncalibrated" - lines.append(f"Detector: {self.det_rows}×{self.det_cols} ({k_val} {k_unit}/px)") + k_info = f"{self.k_pixel_size:.4f} 1/Å/px" if self.k_calibrated else "uncalibrated" + lines.append(f"Detector: {self.det_rows}x{self.det_cols} ({k_info})") if self.k_calibrated: - cal = f"Calib: {self.calibration_source}" + source = { + "from_phase": "phase", + "from_ring": "ring", + "from_spot": "spot", + }.get(self.calibration_source, self.calibration_source) + cal = f"Calibration: {source}" if self.calibration_ref_d > 0: - cal += f" (d={self.calibration_ref_d:.3f} Å @ r={self.calibration_ref_radius:.1f} px)" + cal += ( + f" (d={self.calibration_ref_d:.3f} Å at r={self.calibration_ref_radius:.1f} px)" + ) + elif self.calibration_source == "from_phase": + cal += f" (rms {self.calibration_rms_px:.2f} px)" lines.append(cal) + if self.ellipse_ratio != 1.0: + state = "corrected" if self.ellipse_corrected else "not corrected" + lines.append( + f"Ellipse: a/b={self.ellipse_ratio:.3f} at {self.ellipse_angle:.1f}° ({state})" + ) lines.append( - f"Center: ({self.center_row:.1f}, {self.center_col:.1f}) BF r={self.bf_radius:.1f} px" + f"Center: ({self.center_row:.1f}, {self.center_col:.1f}) " + f"BF r={self.bf_radius:.1f} px" ) lines.append(f"Spots: {len(self.spots)}") if self.spots: @@ -1462,14 +3921,19 @@ def summary(self): if s.get("d_spacing"): derr = s.get("d_spacing_err") d = f"{s['d_spacing']:.3f}±{derr:.3f} Å" if derr else f"{s['d_spacing']:.3f} Å" + label = f"d={d}" else: - d = f"{s['r_pixels']:.1f} px" - ang = f" ∠={s['angle_deg']:.1f}°" if s.get("angle_deg") is not None else "" + label = f"r={s['r_pixels']:.1f} px" + ang = f" angle={s['angle_deg']:.1f}°" if s.get("angle_deg") is not None else "" hkl = f" {s['hkl']}" if s.get("hkl") else "" - lines.append(f" #{s['id']} ({s['row']:.1f}, {s['col']:.1f}) d={d}{ang}{hkl}") + lines.append(f" #{s['id']} ({s['row']:.1f}, {s['col']:.1f}) {label}{ang}{hkl}") if len(self.spots) > 5: lines.append(f" ... +{len(self.spots) - 5} more") lines.append(f"Rings: {len(self.rings)}") + if self.zone_axis: + lines.append(f"Zone: {self.zone_axis}") + if self.phase_match: + lines.append(f"Phase: {self.phase_match}") lines.append(f"Display: {self.dp_colormap} | {self.dp_scale_mode}") if self.snap_enabled: lines.append(f"Snap: radius={self.snap_radius}") @@ -1487,6 +3951,7 @@ def __repr__(self) -> str: ) def free(self): + """Free GPU memory held by this widget.""" if hasattr(self, "_data"): del self._data import gc diff --git a/src/quantem/widget/showeds.py b/src/quantem/widget/showeds.py index 416db1c9..24d81b3d 100644 --- a/src/quantem/widget/showeds.py +++ b/src/quantem/widget/showeds.py @@ -178,8 +178,12 @@ def eds_line_hints( if allowed and line["element"] not in allowed: continue out.append(dict(line)) + if len(out) > max_lines: + # cap by importance so major lines survive at every energy + out.sort(key=lambda item: (_line_priority(item["line"]), -float(item["intensity"] or 0.0))) + out = out[:max_lines] out.sort(key=lambda item: (float(item["energy_keV"]), item["element"], _line_priority(item["line"]))) - return out[:max_lines] + return out MN_KA_KEV = 5.8988 diff --git a/tests/test_showdiffraction.py b/tests/test_showdiffraction.py new file mode 100644 index 00000000..14729ef0 --- /dev/null +++ b/tests/test_showdiffraction.py @@ -0,0 +1,1923 @@ +import json +import math +import sys +from collections import namedtuple + +import numpy as np +import pytest +import torch + +from quantem.widget import ShowDiffraction +from quantem.widget import Phase +from quantem.widget.showdiffraction import build_measurement_records, measurement_metadata + +LoadResult = namedtuple("LoadResult", ("data", "metadata")) + + +def _disk_dp(size=64, center=(32, 30), radius=6): + rows = np.arange(size)[:, None] + cols = np.arange(size)[None, :] + r2 = (rows - center[0]) ** 2 + (cols - center[1]) ** 2 + return np.exp(-r2 / (2 * radius**2)).astype(np.float32) + + +def _two_spot_dp(size=64, center=(32, 32), spot=(28, 44), sigma=2.0): + rows = np.arange(size)[:, None] + cols = np.arange(size)[None, :] + beam = np.exp(-((rows - center[0]) ** 2 + (cols - center[1]) ** 2) / (2 * 2.0**2)) + blob = np.exp(-((rows - spot[0]) ** 2 + (cols - spot[1]) ** 2) / (2 * sigma**2)) + return (50.0 * beam + 40.0 * blob).astype(np.float32) + + +def test_showdiffraction_construction_and_ingest(): + # 2D single frame + dp = np.random.rand(32, 48).astype(np.float32) + w = ShowDiffraction(dp, verbose=False) + assert w.n_frames == 1 + assert w.frame_idx == 0 + assert (w.det_rows, w.det_cols) == (32, 48) + assert w.detector_shape == (32, 48) + assert len(w.frame_bytes) == 32 * 48 * 4 + assert w.dp_scale_mode == "log" + assert w.panel_width_px == 384 + # 3D stack + data = np.random.rand(5, 16, 16).astype(np.float32) + w = ShowDiffraction(data, verbose=False) + assert w.n_frames == 5 + assert (w.det_rows, w.det_cols) == (16, 16) + # 4D and wrong ndim raise + with pytest.raises(ValueError, match="4D input"): + ShowDiffraction(np.random.rand(4, 4, 16, 16).astype(np.float32), verbose=False) + with pytest.raises(ValueError, match="Expected a 2D or 3D"): + ShowDiffraction(np.zeros((4,), dtype=np.float32), verbose=False) + with pytest.raises(ValueError, match="Expected a 2D or 3D"): + ShowDiffraction(np.zeros((2, 2, 4, 4, 4), dtype=np.float32), verbose=False) + # torch tensor input + w = ShowDiffraction(torch.rand(4, 16, 16), verbose=False) + assert w.n_frames == 4 + # LoadResult input applies metadata + result = LoadResult( + data=np.random.rand(4, 16, 16).astype(np.float32), + metadata={"pixel_size": 2.0}, + ) + w = ShowDiffraction(result, verbose=False) + assert w.pixel_size == 2.0 + # set_image replaces the stack and clears spots + w = ShowDiffraction(np.random.rand(32, 32).astype(np.float32), verbose=False) + w.add_spot(10, 10) + new_data = np.random.rand(8, 64, 64).astype(np.float32) + w.set_image(new_data) + assert w.n_frames == 8 + assert w.det_rows == 64 + assert len(w.spots) == 0 + # set_image with a LoadResult applies metadata + result = LoadResult( + data=np.random.rand(8, 32, 32).astype(np.float32), + metadata={"pixel_size": 3.0}, + ) + w.set_image(result) + assert w.pixel_size == 3.0 + + +def test_showdiffraction_frames_offline_and_hot_pixels(): + data = np.zeros((4, 8, 8), dtype=np.float32) + for i in range(4): + data[i] = float(i + 1) # distinct frames + # frame_idx switches the streamed frame + w = ShowDiffraction(data, verbose=False) + assert w.n_frames == 4 + f0 = np.frombuffer(w.frame_bytes, dtype=np.float32).copy() + assert np.allclose(f0, 1.0) + w.frame_idx = 2 + f2 = np.frombuffer(w.frame_bytes, dtype=np.float32) + assert np.allclose(f2, 3.0) + # bounds clamp + w.frame_idx = 99 + assert w.frame_idx == 3 + # offline stack bakes every frame + frame_len = 8 * 8 + w = ShowDiffraction(data, offline=True, verbose=False) + assert len(w.offline_frames) == 4 * frame_len * 4 + baked = np.frombuffer(w.offline_frames, dtype=np.float32).reshape(4, 8, 8) + assert np.allclose(baked[2], 3.0) + # live frame stream + live = ShowDiffraction(data, offline=False, verbose=False) + assert live.offline_frames == b"" + # offline toggle + live.offline = True + assert len(live.offline_frames) == 4 * frame_len * 4 + live.offline = False + assert live.offline_frames == b"" + # single frame + single = ShowDiffraction(np.ones((8, 8), dtype=np.float32), offline=True, verbose=False) + assert single.offline_frames == b"" + # hot pixels zeroed on load + hot = np.ones((4, 32, 32), dtype=np.uint16) * 100 + hot[0, 3, 5] = 65535 + w = ShowDiffraction(hot, verbose=False) + assert w._get_frame(0)[3, 5] == 0 + + +def test_ingest_sparse_counting_frame(): + # sparse electron counting: the hot-pixel clamp must not zero real counts + rng = np.random.default_rng(0) + frame = np.zeros((256, 256), dtype=np.uint16) + pixels = rng.choice(frame.size, size=40, replace=False) + frame.flat[pixels] = rng.integers(1, 4, size=40) + w = ShowDiffraction(frame, verbose=False) + assert float(w._get_frame(0).sum()) == float(frame.sum()) + + +def test_showdiffraction_center_and_modes(): + # auto-detect finds the bright disk + data = np.zeros((3, 7, 7), dtype=np.float32) + for i in range(7): + for j in range(7): + if np.sqrt((i - 3) ** 2 + (j - 3) ** 2) <= 1.5: + data[:, i, j] = 100.0 + w = ShowDiffraction(data, verbose=False) + assert abs(w.center_row - 3.0) < 0.5 + assert abs(w.center_col - 3.0) < 0.5 + assert w.bf_radius > 0 + assert w.auto_detect_center() is w + # manual center via kwarg and set_center + data = np.random.rand(16, 16).astype(np.float32) + w = ShowDiffraction(data, center=(5.0, 6.0), bf_radius=3.0, verbose=False) + assert w.center_row == 5.0 + assert w.center_col == 6.0 + assert w.bf_radius == 3.0 + w.set_center(7.0, 8.0) + assert (w.center_row, w.center_col) == (7.0, 8.0) + assert w.center_mode == "manual" + # center_mode accepts known modes only + w = ShowDiffraction(_disk_dp(), verbose=False) + w.center_mode = "manual" + assert w.center_mode == "manual" + with pytest.raises(ValueError): + w.center_mode = "midpoint" + + +def test_showdiffraction_spot_picking_and_refine(): + # calibrated spot gets r/g/d + data = np.random.rand(32, 32).astype(np.float32) + w = ShowDiffraction( + data, k_pixel_size=0.1, spot_refine=False, center=(16, 16), bf_radius=5, verbose=False + ) + w.add_spot(16, 26) + spot = w.spots[0] + assert spot["id"] == 1 + assert abs(spot["r_pixels"] - 10.0) < 0.01 + assert abs(spot["g_magnitude"] - 1.0) < 0.01 + assert abs(spot["d_spacing"] - 1.0) < 0.01 + # uncalibrated spot has no g/d + w = ShowDiffraction(data, center=(16, 16), bf_radius=5, verbose=False) + w.add_spot(16, 26) + assert w.spots[0]["d_spacing"] is None + assert w.spots[0]["g_magnitude"] is None + # spot at the center + data = np.random.rand(16, 16).astype(np.float32) + w = ShowDiffraction( + data, k_pixel_size=0.1, spot_refine=False, center=(8, 8), bf_radius=3, verbose=False + ) + w.add_spot(8, 8) + assert w.spots[0]["r_pixels"] == pytest.approx(0.0) + assert w.spots[0]["d_spacing"] is None + # snap to local peak + data = np.zeros((16, 16), dtype=np.float32) + data[5, 8] = 100.0 + w = ShowDiffraction( + data, + snap_enabled=True, + spot_refine=False, + snap_radius=3, + center=(8, 8), + bf_radius=3, + verbose=False, + ) + w.add_spot(6, 7) + assert w.spots[0]["row"] == 5.0 + assert w.spots[0]["col"] == 8.0 + assert w.spots[0]["raw_row"] == 6.0 + # Gaussian refine pulls the click onto the true spot + spot = (28, 44) + w = ShowDiffraction( + _two_spot_dp(spot=spot), k_pixel_size=0.05, center=(32, 32), bf_radius=3, verbose=False + ) + w.add_spot(spot[0] + 1.4, spot[1] - 1.2) # click ~2 px off the true spot + s = w.spots[0] + assert ( + abs(s["row"] - spot[0]) < 0.5 and abs(s["col"] - spot[1]) < 0.5 + ) # refined to the centroid + assert s["raw_row"] == pytest.approx(spot[0] + 1.4) + assert s["fit_quality"] > 0.9 + assert s["row_err"] is not None and s["d_spacing_err"] is not None and s["d_spacing_err"] >= 0 + # interplanar angles are measured relative to the first spot + w = ShowDiffraction(_disk_dp(), spot_refine=False, center=(32, 32), bf_radius=3, verbose=False) + w.set_center(32, 32) + w.add_spot(32, 42) + w.add_spot(42, 32) + assert w.spots[0]["angle_deg"] == pytest.approx(0.0, abs=1e-6) + assert w.spots[1]["angle_deg"] == pytest.approx(90.0, abs=1e-6) + + +def test_showdiffraction_spot_editing(): + # undo and clear + data = np.random.rand(16, 16).astype(np.float32) + w = ShowDiffraction(data, center=(8, 8), bf_radius=3, verbose=False) + w.add_spot(5, 5).add_spot(10, 10) + assert len(w.spots) == 2 + w.undo_spot() + assert len(w.spots) == 1 + w.clear_spots() + assert len(w.spots) == 0 + w.undo_spot() + assert len(w.spots) == 0 + # remove by id + w.add_spot(5, 5).add_spot(10, 10) + sid = w.spots[0]["id"] + w.remove_spot(sid) + assert len(w.spots) == 1 + assert all(s["id"] != sid for s in w.spots) + + +def test_showdiffraction_state_dict_and_file(tmp_path): + # state_dict -> state kwarg + data = np.random.rand(16, 16).astype(np.float32) + w = ShowDiffraction(data, center=(5.0, 6.0), bf_radius=3.0, k_pixel_size=0.1, verbose=False) + w.dp_scale_mode = "linear" + w.dp_colormap = "viridis" + w.snap_enabled = True + w.add_spot(8, 8) + sd = w.state_dict() + assert sd["dp_scale_mode"] == "linear" + assert sd["dp_colormap"] == "viridis" + assert sd["center_row"] == 5.0 + assert sd["k_pixel_size"] == pytest.approx(0.1) + assert sd["snap_enabled"] is True + assert "frame_idx" in sd + assert len(sd["spots"]) == 1 + w2 = ShowDiffraction(data, state=sd, verbose=False) + assert w2.dp_scale_mode == "linear" + assert w2.dp_colormap == "viridis" + assert w2.bf_radius == 3.0 + assert w2.snap_enabled is True + assert len(w2.spots) == 1 + # save to file and load via state=path + w = ShowDiffraction(data, verbose=False) + w.dp_colormap = "viridis" + path = tmp_path / "diff_state.json" + w.save(str(path)) + saved = json.loads(path.read_text()) + assert saved["metadata_version"] == "1.0" + assert saved["widget_name"] == "ShowDiffraction" + assert "widget_version" in saved + assert saved["state"]["dp_colormap"] == "viridis" + w2 = ShowDiffraction(data, state=str(path), verbose=False) + assert w2.dp_colormap == "viridis" + + +def test_state_envelope_rejects_other_widget(tmp_path): + # a Show2D envelope must not load into ShowDiffraction + data = np.random.rand(16, 16).astype(np.float32) + w = ShowDiffraction(data, verbose=False) + envelope = {"metadata_version": "1.0", "widget_name": "Show2D", "state": w.state_dict()} + path = tmp_path / "show2d_state.json" + path.write_text(json.dumps(envelope)) + with pytest.raises(ValueError, match="Show2D"): + ShowDiffraction(data, state=str(path), verbose=False) + with pytest.raises(ValueError, match="Show2D"): + ShowDiffraction(data, state=envelope, verbose=False) + with pytest.raises(ValueError, match="Show2D"): + ShowDiffraction.measurements_from_state(str(path)) + with pytest.raises(ValueError, match="Show2D"): + ShowDiffraction.measurements_from_state(envelope) + + +def test_showdiffraction_ui_modes_and_summary(capsys): + data = np.random.rand(16, 16).astype(np.float32) + # presets + presentation = ShowDiffraction(data, ui_mode="presentation", verbose=False) + assert presentation.show_title is True + assert presentation.show_controls is True + assert presentation.controls_collapsed is True + assert presentation.show_stats is False + report = ShowDiffraction(data, ui_mode="report", verbose=False) + assert report.show_title is True + assert report.show_controls is False + assert report.controls_collapsed is False + assert report.show_stats is False + minimal = ShowDiffraction(data, ui_mode="minimal", verbose=False) + assert minimal.show_title is False + assert minimal.show_controls is False + assert minimal.controls_collapsed is False + assert minimal.show_stats is False + # explicit flags override the preset; toggle helpers chain + override = ShowDiffraction( + data, + ui_mode="minimal", + show_title=True, + show_controls=True, + controls_collapsed=True, + show_stats=True, + verbose=False, + ) + assert override.show_title is True + assert override.show_controls is True + assert override.controls_collapsed is True + assert override.show_stats is True + assert override.expand_controls() is override + assert override.controls_collapsed is False + assert override.collapse_controls() is override + assert override.controls_collapsed is True + assert override.toggle_controls() is override + assert override.controls_collapsed is False + # summary prints key stats + data = np.random.rand(5, 16, 16).astype(np.float32) + w = ShowDiffraction(data, pixel_size=2.39, k_pixel_size=0.1, verbose=False) + w.add_spot(5, 5) + w.summary() + out = capsys.readouterr().out + assert "Frames:" in out + assert "Detector:" in out + assert "Spots:" in out + + +def test_summary_uncalibrated_labels(capsys): + # uncalibrated: no fake units on the detector or spot lines + w = ShowDiffraction(_disk_dp(), spot_refine=False, verbose=False) + w.set_center(32, 32) + w.add_spot(32, 42) + w.summary() + out = capsys.readouterr().out + assert "(uncalibrated)" in out + assert "px/px" not in out + assert "r=10.0 px" in out + assert "d=" not in out + # calibrated: units restored + w.calibrate_from_ring(10.0, 2.0) + w.summary() + out = capsys.readouterr().out + assert "1/Å/px" in out + assert "d=2.000" in out + + +def test_showdiffraction_ring_calibration_and_picking(): + # calibration recomputes existing spots + w = ShowDiffraction(_disk_dp(), spot_refine=False, verbose=False) + w.set_center(32, 32) + w.add_spot(32, 42) + assert w.spots[0]["d_spacing"] is None + w.calibrate_from_ring(10.0, 2.0) # r=10 px -> d=2.0 A -> k=0.05 + assert w.k_calibrated and abs(w.k_pixel_size - 0.05) < 1e-9 + assert abs(w.spots[0]["d_spacing"] - 2.0) < 1e-4 + with pytest.raises(ValueError): + w.calibrate_from_ring(-1, 2.0) + # ring picking and undo + w = ShowDiffraction(_disk_dp(), k_pixel_size=0.05, verbose=False) + w.set_center(32, 32) + w.add_ring(10.0) # g = 10*0.05 -> d = 2.0 A + assert abs(w.rings[0]["d_spacing"] - 2.0) < 1e-4 + w.add_ring(20.0) + w.undo_ring() + assert len(w.rings) == 1 + + +def test_calibrate_request_channels_report_failure(): + # at-center spot and bad ring inputs surface a status instead of silence + w = ShowDiffraction(_disk_dp(), spot_refine=False, verbose=False) + w.set_center(32, 32) + w._calibrate_from_spot_request = [32.0, 32.0, 2.0] + assert w.analysis_status.startswith("Calibrate failed") + assert w._calibrate_from_spot_request == [] + w.analysis_status = "" + w._calibrate_from_ring_request = [10.0, -1.0] + assert w.analysis_status.startswith("Calibrate failed") + assert w._calibrate_from_ring_request == [] + + +def test_showdiffraction_export_and_measurements(tmp_path): + w = ShowDiffraction( + _disk_dp(), + k_pixel_size=0.05, + spot_refine=False, + center=(32, 32), + bf_radius=3, + verbose=False, + ) + w.set_center(32, 32) + w.add_spot(32, 42) + w.add_ring(20.0) + # CSV and JSON export + csv_text = w.export_measurements(tmp_path / "m.csv").read_text() + assert "g_inv_angstrom" in csv_text + assert csv_text.strip().count("\n") >= 2 + payload = json.loads(w.export_measurements(tmp_path / "m.json").read_text()) + assert payload["metadata"]["calibration_source"] == "manual" + assert len(payload["measurements"]) == 2 + # measurements straight from a saved state file + state_path = tmp_path / "state.json" + w.save(state_path) + records = ShowDiffraction.measurements_from_state(state_path) + assert [r["kind"] for r in records] == ["spot", "ring"] + assert records == build_measurement_records(w.spots, w.rings) + csv_path = ShowDiffraction.measurements_from_state(state_path, tmp_path / "from_state.csv") + assert csv_path.read_text() == w.export_measurements(tmp_path / "live.csv").read_text() + + +def test_export_ring_hkl_candidates(tmp_path): + # near-degenerate assignments stay auditable in the export + card = Phase.from_dspacings("card", [(2.00, "a"), (1.97, "b")]) + w = ShowDiffraction( + np.zeros((256, 256), np.float32), center=(128, 128), k_pixel_size=0.01, verbose=False + ) + w.add_ring(1.0 / (1.985 * 0.01)) + w.index_rings(card) + csv_text = w.export_measurements(tmp_path / "m.csv").read_text() + assert "hkl_candidates" in csv_text + assert "a|b" in csv_text + payload = json.loads(w.export_measurements(tmp_path / "m.json").read_text()) + assert payload["measurements"][0]["hkl_candidates"] == "a|b" + + +def test_showdiffraction_detect_spots_and_rings(): + # spot detection + M, cen, G = 128, (64, 64), 24.0 + rows = np.arange(M)[:, None] + cols = np.arange(M)[None, :] + + def blob(r, c, a, s): + return a * np.exp(-(((rows - r) ** 2 + (cols - c) ** 2) / (2 * s * s))) + + dp = blob(*cen, 300, 4) + truth = [ + (cen[0], cen[1] + G), + (cen[0], cen[1] - G), + (cen[0] + G, cen[1]), + (cen[0] - G, cen[1]), + ] + for r, c in truth: + dp = dp + blob(r, c, 40, 2.0) + dp = dp.astype(np.float32) + w = ShowDiffraction(dp, center=cen, bf_radius=6, k_pixel_size=1 / (2.099 * G), verbose=False) + w.detect_spots(max_spots=6) + assert 4 <= len(w.spots) <= 6 # found the spots, beam excluded + on_spot = sum( + any(abs(s["row"] - r) < 2 and abs(s["col"] - c) < 2 for r, c in truth) for s in w.spots + ) + assert on_spot >= 4 + # ring detection + M, cen = 256, (128, 128) + rows = np.arange(M)[:, None] + cols = np.arange(M)[None, :] + r = np.hypot(rows - cen[0], cols - cen[1]) + dp = 200.0 * np.exp(-(r**2) / (2 * 5.0**2)) + ring_radii = [40.0, 70.0, 100.0] + for rr in ring_radii: + dp = dp + 30.0 * np.exp(-((r - rr) ** 2) / (2 * 2.5**2)) + dp = dp.astype(np.float32) + w = ShowDiffraction(dp, center=cen, bf_radius=15, k_pixel_size=0.02, verbose=False) + w.detect_rings(max_rings=6) + found = sorted(rng["radius_px"] for rng in w.rings) + assert len(found) >= 3 + for target in ring_radii: + assert any(abs(f - target) < 3 for f in found) + + +def test_detect_rings_without_scipy_keeps_manual_rings(monkeypatch): + # a scipy-less detect must not wipe manually added rings + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, verbose=False) + w.set_center(*cen) + w.add_ring(60.0) + monkeypatch.setitem(sys.modules, "scipy.signal", None) + w.detect_rings() + assert any(abs(r["radius_px"] - 60.0) < 1.0 for r in w.rings) + + +def _dp( + radii=(60.0,), + *, + center=None, + background="exp", + arc=None, + ellipse=None, + sigma=2.5, + size=256, + amp=30.0, +): + cen = center or (size // 2, size // 2) + rows = np.arange(size, dtype=np.float64)[:, None] + cols = np.arange(size, dtype=np.float64)[None, :] + d_row, d_col = rows - cen[0], cols - cen[1] + r = np.hypot(d_row, d_col) + dp = 500.0 * (r + 1.0) ** -1.5 if background == "power" else 1000.0 * np.exp(-r / 30.0) + r_eff, mask = r, 1.0 + if ellipse is not None: + ratio, phi = ellipse[0], math.radians(ellipse[1]) + major = d_col * math.cos(phi) + d_row * math.sin(phi) + minor = -d_col * math.sin(phi) + d_row * math.cos(phi) + r_eff = np.hypot(major / ratio, minor) + if arc is not None: + theta = np.degrees(np.arctan2(d_row, d_col)) % 360 + mask = (theta >= arc[0]) & (theta <= arc[1]) + for rr in radii: + dp = dp + amp * np.exp(-((r_eff - rr) ** 2) / (2 * sigma**2)) * mask + return dp.astype(np.float32), cen + + +def _ring_dp(radii, sigma=2.5, size=256, amp=30.0, background="exp"): + return _dp(radii, background=background, sigma=sigma, size=size, amp=amp) + + +def _off_center_ring_dp(center, radii=(60.0,), sigma=2.5, size=256, amp=30.0): + return _dp(radii, center=center, background="power", sigma=sigma, size=size, amp=amp)[0] + + +def _arc_ring_dp(r0=60.0, theta_lo=10.0, theta_hi=70.0, sigma=2.5, size=256, amp=30.0): + return _dp( + (r0,), background="power", arc=(theta_lo, theta_hi), sigma=sigma, size=size, amp=amp + ) + + +def _elliptical_ring_dp(r0=60.0, ratio=1.2, angle_deg=30.0, sigma=2.5, size=256, amp=30.0): + return _dp( + (r0,), background="power", ellipse=(ratio, angle_deg), sigma=sigma, size=size, amp=amp + ) + + +def test_radial_profile_units_wedges_and_background(): + # axes and units + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, center=cen, verbose=False) + radii, intensity = w.radial_profile() + assert radii.shape == intensity.shape + assert np.all(np.diff(radii) > 0) + assert abs(radii[40:][np.argmax(intensity[40:])] - 60.0) < 2.0 + assert len(w.radial_profile(n_bins=50)[0]) == 50 + with pytest.raises(ValueError, match="calibrat"): + w.radial_profile(units="d") + with pytest.raises(ValueError, match="units"): + w.radial_profile(units="nm") + w2 = ShowDiffraction(dp, center=cen, k_pixel_size=0.02, verbose=False) + r_px, _ = w2.radial_profile(units="px") + g, _ = w2.radial_profile(units="g") + q_alias, _ = w2.radial_profile(units="q") + assert np.allclose(g, r_px * 0.02, rtol=1e-6) + assert np.allclose(q_alias, g) + assert np.allclose(w2.radial_profile()[0], g) + # azimuthal wedge: a ring painted in one sector only shows up when that sector is integrated + size, cen, r0 = 256, 128, 60.0 + rows = np.arange(size, dtype=np.float64)[:, None] + cols = np.arange(size, dtype=np.float64)[None, :] + r = np.hypot(rows - cen, cols - cen) + theta = np.degrees(np.arctan2(rows - cen, cols - cen)) % 360 + dp = 30.0 * np.exp(-((r - r0) ** 2) / (2 * 2.5**2)) * ((theta >= 10) & (theta <= 80)) + w = ShowDiffraction(dp.astype(np.float32), center=(cen, cen), verbose=False) + radii, inside = w.radial_profile(angular_range=(10, 80)) + _, outside = w.radial_profile(angular_range=(180, 250)) + band = (radii > 55) & (radii < 65) + assert inside[band].max() > 10.0 + assert outside[band].max() < 1.0 + # a wedge spanning the full circle matches the unrestricted profile + dp, cen = _arc_ring_dp(r0=60.0, theta_lo=10.0, theta_hi=70.0) + w = ShowDiffraction(dp, center=cen, verbose=False) + _, unrestricted = w.radial_profile() + for full_span in ((0, 360), (90, 450)): + _, profile = w.radial_profile(angular_range=full_span) + assert np.allclose(profile, unrestricted) + _, wedge = w.radial_profile(angular_range=(0, 90)) + assert not np.allclose(wedge, unrestricted) + # background: power-law fit flattens the falloff off-peak while preserving ring peaks + dp, cen = _ring_dp([60.0, 90.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + for rr in (60.0, 90.0): + w.add_ring(rr) + radii, intensity = w.radial_profile(units="px") + _, background = w.radial_background() + resid = intensity - background + off_peak = (radii > 20) & (np.abs(radii - 60) > 10) & (np.abs(radii - 90) > 10) & (radii < 110) + assert np.abs(resid[off_peak]).max() < 3.0 + at_peak = int(np.argmin(np.abs(radii - 60.0))) + assert resid[at_peak] > 15.0 + _, net = w.radial_profile(units="px", subtract_background=True) + assert np.allclose(net, intensity - background, atol=1e-5) + with pytest.raises(ValueError, match="method"): + w.radial_background(method="spline") + + +def test_ingest_non_finite_pixels(): + # a NaN or inf pixel must not poison ring detection + dp, cen = _ring_dp([60.0, 90.0], background="power") + clean = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + clean.detect_rings(max_rings=2) + dirty_dp = dp.copy() + dirty_dp[10, 10] = np.nan + dirty_dp[200, 200] = np.inf + dirty = ShowDiffraction(dirty_dp, center=cen, bf_radius=10, verbose=False) + dirty.detect_rings(max_rings=2) + assert len(clean.rings) == 2 + assert [r["radius_px"] for r in dirty.rings] == [r["radius_px"] for r in clean.rings] + + +def test_refine_center_offsets_and_methods(): + # recovers known offsets (second case is far off-midpoint: wrap candidates) + for true, start, radii in [ + ((121.3, 134.7), (118.0, 138.0), (50.0, 75.0)), + ((60.2, 190.6), (63.0, 187.0), (40.0,)), + ]: + dp = _off_center_ring_dp(true, radii=radii) + w = ShowDiffraction(dp, center=start, verbose=False) + w.refine_center() + assert abs(w.center_row - true[0]) < 0.3 + assert abs(w.center_col - true[1]) < 0.3 + assert w.center_mode == "auto" + # auto_detect_center(refine=True) improves on the plain centroid estimate + true = (124.4, 132.9) + dp = _off_center_ring_dp(true, radii=(50.0, 75.0)) + plain = ShowDiffraction(dp, verbose=False).auto_detect_center() + refined = ShowDiffraction(dp, verbose=False).auto_detect_center(refine=True) + err_plain = math.hypot(plain.center_row - true[0], plain.center_col - true[1]) + err_refined = math.hypot(refined.center_row - true[0], refined.center_col - true[1]) + assert err_refined <= err_plain + assert err_refined < 0.3 + assert refined.center_mode == "auto" + # phase_corr and the auto cascade both recover the offset + true = (121.3, 134.7) + dp = _off_center_ring_dp(true, radii=(50.0, 75.0)) + w = ShowDiffraction(dp, center=(118.0, 138.0), verbose=False) + w.refine_center(method="phase_corr") + assert abs(w.center_row - true[0]) < 0.5 and abs(w.center_col - true[1]) < 0.5 + assert w.center_method == "phase_corr" + w2 = ShowDiffraction(dp, center=(118.0, 138.0), verbose=False) + w2.refine_center(method="auto") + assert abs(w2.center_row - true[0]) < 0.5 and abs(w2.center_col - true[1]) < 0.5 + with pytest.raises(ValueError, match="method"): + w.refine_center(method="centroid") + + +def test_fit_ring_profile(): + # a ring picked ~2 px off refines onto the profile peak with width, quality, d, size + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, k_pixel_size=0.02, verbose=False) + w.add_ring(62.0) + d_before = w.rings[0]["d_spacing"] + w.fit_ring_profile() + ring = w.rings[0] + assert abs(ring["radius_px"] - 60.0) < 0.3 + assert ring["raw_radius_px"] == 62.0 + assert abs(ring["fwhm_px"] - 2.3548 * 2.5) < 0.8 + assert ring["fit_quality"] > 0.9 + d_true = 1.0 / (60.0 * 0.02) + assert abs(ring["d_spacing"] - d_true) < abs(d_before - d_true) + # integrated intensity scales with ring amplitude + sizes = {} + for amp in (30.0, 60.0): + dp, cen = _ring_dp([60.0], amp=amp, background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w.add_ring(60.0) + w.fit_ring_profile() + sizes[amp] = w.rings[0]["intensity_integrated"] + assert sizes[30.0] > 0 + assert abs(sizes[60.0] / sizes[30.0] - 2.0) < 0.2 + + +def test_fit_ring_peaks_close_pair(): + # neighboring rings 7.1 px apart: the fit window shrinks below the neighbor + from quantem.widget.showdiffraction import fit_ring_peaks + + radii = np.arange(0.25, 120.0, 0.5) + intensity = 300.0 * np.exp(-0.5 * ((radii - 60.0) / 1.5) ** 2) + intensity += 450.0 * np.exp(-0.5 * ((radii - 67.1) / 1.5) ** 2) + rings = [{"radius_px": 60.0}, {"radius_px": 67.1}] + for update, amp in zip(fit_ring_peaks(radii, intensity, rings), (300.0, 450.0)): + assert abs(update["intensity"] - amp) / amp < 0.08 + assert update["fit_quality"] > 0.97 + + +def test_texture_arc_vs_uniform(): + dp, cen = _arc_ring_dp(theta_lo=10.0, theta_hi=70.0) + w = ShowDiffraction(dp, center=cen, verbose=False) + w.add_ring(60.0) + theta, intensity = w.azimuthal_profile(n_theta=90) + assert theta.shape == intensity.shape == (90,) + assert theta[0] >= 0 and theta[-1] < 360 + report = w.texture() + assert report["strength"] > 0.5 + delta = abs(report["angle_deg"] - 40.0) % 180 + assert min(delta, 180 - delta) < 5.0 + dp2, cen2 = _ring_dp([60.0], background="power") + w2 = ShowDiffraction(dp2, center=cen2, verbose=False) + w2.add_ring(60.0) + assert w2.texture()["strength"] < 0.05 + # fit/texture/ellipse analysis all require a picked ring + dp3, cen3 = _ring_dp([60.0]) + w3 = ShowDiffraction(dp3, center=cen3, verbose=False) + for method in (w3.fit_ring_profile, w3.texture, w3.fit_ellipse): + with pytest.raises(ValueError): + method() + + +def test_texture_masked_wedge(): + # isotropic ring with a masked 90 deg wedge reads untextured + from quantem.widget.showdiffraction import texture_from_profile + + theta = (np.arange(72) + 0.5) * 5.0 + rng = np.random.default_rng(0) + intensity = 100.0 + rng.normal(0.0, 1.0, 72) + intensity[(theta > 45.0) & (theta < 135.0)] = 0.0 + report = texture_from_profile(theta.astype(np.float32), intensity.astype(np.float32)) + assert report["strength"] < 0.05 + + +def test_texture_low_coverage_zeroed_and_bounded(): + # a few clustered live sectors cannot constrain the harmonic fit + from quantem.widget.showdiffraction import texture_from_profile + + theta = ((np.arange(72) + 0.5) * 5.0).astype(np.float32) + rng = np.random.default_rng(3) + for n_live in (3, 4, 6): + intensity = np.zeros(72, np.float32) + intensity[:n_live] = 100.0 + rng.normal(0.0, 2.0, n_live) + report = texture_from_profile(theta, intensity) + assert report["strength"] == 0.0 + assert report["angle_deg"] == 0.0 + # strength is a fractional modulation, never above 1 + intensity = np.zeros(72, np.float32) + intensity[:24] = 1.0 + intensity[0] = 1000.0 + report = texture_from_profile(theta, intensity) + assert 0.0 <= report["strength"] <= 1.0 + + +def test_fit_ellipse_center_error_partial_coverage(): + # a 1 px center error on a circle must not alias into ellipticity + # when a 90 deg wedge is missing + from quantem.widget.showdiffraction import fit_ellipse_from_sectors + + theta_centers = (np.arange(36) + 0.5) * 10.0 + radii = 60.0 - np.cos(np.radians(theta_centers)) + counts = np.full(36, 50.0) + weight_sum = np.ones(36) + wedge = theta_centers > 270.0 + counts[wedge] = 0.0 + weight_sum[wedge] = 0.0 + report = fit_ellipse_from_sectors(theta_centers, counts, weight_sum, weight_sum * radii) + assert abs(report["ratio"] - 1.0) < 0.005 + + +def test_fit_ellipse_short_arc_rejected(): + # a short live arc leaves the order-1/order-2 harmonics collinear + from quantem.widget.showdiffraction import fit_ellipse_from_sectors + + theta_centers = (np.arange(180) + 0.5) * 2.0 + radii = np.full(180, 200.0) + for n_live in (8, 20, 30): # 16 / 40 / 60 degree arcs + counts = np.zeros(180) + weight_sum = np.zeros(180) + counts[:n_live] = 50.0 + weight_sum[:n_live] = 1.0 + with pytest.raises(ValueError, match="span"): + fit_ellipse_from_sectors(theta_centers, counts, weight_sum, weight_sum * radii) + # >= 120 degrees of coverage still fits + counts = np.zeros(180) + weight_sum = np.zeros(180) + counts[:65] = 50.0 + weight_sum[:65] = 1.0 + report = fit_ellipse_from_sectors(theta_centers, counts, weight_sum, weight_sum * radii) + assert abs(report["ratio"] - 1.0) < 0.01 + + +def test_fit_ellipse_and_correction(): + # detects elongation; a circular ring fits ratio 1 + dp, cen = _elliptical_ring_dp(r0=60.0, ratio=1.2, angle_deg=30.0) + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w.add_ring(60.0) + report = w.fit_ellipse() + assert abs(report["ratio"] - 1.2) < 0.03 + assert abs(report["angle_deg"] - 30.0) < 5.0 + assert w.ellipse_ratio == report["ratio"] + dp2, cen2 = _ring_dp([60.0], background="power") + w2 = ShowDiffraction(dp2, center=cen2, bf_radius=10, verbose=False) + w2.add_ring(60.0) + assert abs(w2.fit_ellipse()["ratio"] - 1.0) < 0.01 + # noise plus a flat pedestal must not dilute the fitted ellipse amplitude + rng = np.random.default_rng(0) + dp, cen = _elliptical_ring_dp(r0=60.0, ratio=1.10, angle_deg=30.0, sigma=6.0, amp=30.0) + noisy = (dp + 100.0 + rng.normal(0.0, 10.0, dp.shape)).astype(np.float32) + w = ShowDiffraction(noisy, center=cen, bf_radius=10, verbose=False) + w.add_ring(60.0) + report = w.fit_ellipse() + assert abs(report["ratio"] - 1.10) < 0.02 + assert abs(report["angle_deg"] - 30.0) < 5.0 + clean, cen2 = _elliptical_ring_dp(r0=60.0, ratio=1.0, angle_deg=0.0, sigma=6.0) + w2 = ShowDiffraction(clean, center=cen2, bf_radius=10, verbose=False) + w2.add_ring(60.0) + assert abs(w2.fit_ellipse()["ratio"] - 1.0) < 0.01 + # spots on the major and minor axes disagree in d until the correction is applied + dp, cen = _elliptical_ring_dp(r0=60.0, ratio=1.2, angle_deg=0.0) + w = ShowDiffraction( + dp, center=cen, bf_radius=10, k_pixel_size=0.01, spot_refine=False, verbose=False + ) + w.add_spot(cen[0], cen[1] + 60.0 * 1.2) # major axis (+col) + w.add_spot(cen[0] + 60.0, cen[1]) # minor axis (+row) + d_major, d_minor = (s["d_spacing"] for s in w.spots) + assert abs(d_major - d_minor) / d_minor > 0.1 + w.add_ring(60.0) + w.fit_ellipse() + w.apply_ellipse_correction() + d_major, d_minor = (s["d_spacing"] for s in w.spots) + assert abs(d_major - d_minor) / d_minor < 0.02 + # mean-preserving: both axes map to the mean radius sqrt(A*B) = 60*sqrt(1.2), + # so the correction does not silently rescale an existing calibration + d_mean = 1.0 / (60.0 * math.sqrt(1.2) * 0.01) + assert abs(d_major - d_mean) / d_mean < 0.03 + assert abs(d_minor - d_mean) / d_mean < 0.03 + + +def _fe3o4_dp(): + # real SAED pattern, pending the tutorial dataset upload + from quantem.widget.data.tutorials import _FE3O4_PACKAGED_PATH + + if not _FE3O4_PACKAGED_PATH.is_file(): + pytest.skip("real Fe3O4 SAED pattern unavailable") + return np.load(_FE3O4_PACKAGED_PATH) + + +def test_fit_ellipse_default_width_real_data(): + # the default annulus stays on the requested ring instead of spanning neighbors + w = ShowDiffraction(_fe3o4_dp(), verbose=False) + w.run_auto(max_rings=5, exclude_radius=70) + for ring in w.rings: + report = w.fit_ellipse(ring_id=ring["id"]) + assert abs(report["r_mean"] - ring["radius_px"]) < 5.0 + + +def test_ellipse_corrected_spot_angles(): + # inter-spot angles use the same ellipse transform as radii + ratio, cen = 1.06, (128.0, 128.0) + w = ShowDiffraction( + np.zeros((256, 256), np.float32), + center=cen, + k_pixel_size=0.01, + spot_refine=False, + verbose=False, + ) + root = math.sqrt(ratio) + for theta in (52.5, 112.5): # 60 degrees apart on the circularized pattern + a = math.radians(theta) + w.add_spot(cen[0] + 80.0 * math.sin(a) / root, cen[1] + 80.0 * math.cos(a) * root) + w.ellipse_ratio = ratio + w.apply_ellipse_correction() + assert abs(w._measured_angle(w.spots[0], w.spots[1]) - 60.0) < 0.3 + assert abs(w.spots[1]["angle_deg"] - 60.0) < 0.3 + + +def _au(): + return Phase.from_cubic("Au", 4.078, absences="fcc") + + +_AU_HKLS = {"111": (1, 1, 1), "200": (2, 0, 0), "220": (2, 2, 0), "311": (3, 1, 1)} + + +def _au_ring_widget(k=0.01, size=256): + # place rings at the exact Au d-spacings for a k-space sampling of k 1/Å per px + au, cen = _au(), (size // 2, size // 2) + w = ShowDiffraction( + np.zeros((size, size), np.float32), center=cen, k_pixel_size=k, verbose=False + ) + for hkl in _AU_HKLS.values(): + w.add_ring(1.0 / (au.d_spacing(hkl) * k)) + return w + + +def test_calibration_from_spot_and_phase(): + # calibrate_from_spot records source and reference values + w = ShowDiffraction(_disk_dp(), spot_refine=False, center=(32, 32), bf_radius=3, verbose=False) + w.set_center(32, 32) + assert w.calibration_source == "none" + w.calibrate_from_spot(32, 42, 2.0) # r=10 px, d=2 A -> k=0.05 + assert w.calibration_source == "from_spot" + assert w.calibration_ref_d == pytest.approx(2.0) + assert w.calibration_ref_radius == pytest.approx(10.0) + # multi-ring regression recovers the camera constant from unindexed Au rings + k_true, au = 0.01, _au() + w = ShowDiffraction(np.zeros((256, 256), np.float32), center=(128, 128), verbose=False) + for hkl in _AU_HKLS.values(): + w.add_ring(1.0 / (au.d_spacing(hkl) * k_true)) + w.calibrate_from_phase(au) + assert abs(w.k_pixel_size - k_true) / k_true < 0.01 + assert [rng["hkl"] for rng in w.rings] == list(_AU_HKLS) + assert w.calibration_rms_px < 0.5 + assert all(rng["radius_resid_px"] is not None for rng in w.rings) + assert w.calibration_source == "from_phase" + assert w.k_calibrated is True + w2 = ShowDiffraction(np.zeros((256, 256), np.float32), center=(128, 128), verbose=False) + w2.add_ring(49.0) + with pytest.raises(ValueError, match="2"): + w2.calibrate_from_phase(au) + # regression over all rings averages out per-ring picking error + rng, k_true, au = np.random.default_rng(7), 0.01, _au() + w = ShowDiffraction(np.zeros((256, 256), np.float32), center=(128, 128), verbose=False) + radii = [1.0 / (au.d_spacing(h) * k_true) for h in _AU_HKLS.values()] + jitter = rng.uniform(-0.6, 0.6, len(radii)) + for r, j in zip(radii, jitter): + w.add_ring(r + j) + w.calibrate_from_phase(au) + k_multi = w.k_pixel_size + k_single = 1.0 / (au.d_spacing((1, 1, 1)) * (radii[0] + jitter[0])) + assert abs(k_multi - k_true) <= abs(k_single - k_true) + + +def test_calibrate_from_phase_prefers_exact_assignment(): + # rings at exactly Au 220/311 must not lose to a lower-order near-miss + k_true, au = 0.01, _au() + w = ShowDiffraction(np.zeros((256, 256), np.float32), center=(128, 128), verbose=False) + for hkl in ((2, 2, 0), (3, 1, 1)): + w.add_ring(1.0 / (au.d_spacing(hkl) * k_true)) + w.calibrate_from_phase(au) + assert [rng["hkl"] for rng in w.rings] == ["220", "311"] + assert abs(w.k_pixel_size - k_true) / k_true < 1e-6 + + +def test_calibrate_from_phase_tolerates_ring_jitter(): + # sub-pixel ring jitter must not flip the assignment to a high-order match + from quantem.widget.showdiffraction import library_phase + + spinel = library_phase("MgAl2O4") + k_true = 0.0025 + w = ShowDiffraction(np.zeros((512, 512), np.float32), center=(256, 256), verbose=False) + for radius in (139.97, 164.38): # 220/311 at k=0.0025 with ~0.2 px jitter + w.add_ring(radius) + w.calibrate_from_phase(spinel) + assert [rng["hkl"] for rng in w.rings] == ["220", "311"] + assert abs(w.k_pixel_size - k_true) / k_true < 0.01 + + +def test_non_positive_ring_rejected(): + # add_ring rejects non-positive radii + au = _au() + w = ShowDiffraction(np.zeros((256, 256), np.float32), center=(128, 128), verbose=False) + with pytest.raises(ValueError): + w.add_ring(-30.0) + with pytest.raises(ValueError): + w.add_ring(0.0) + assert w.rings == [] + # a corrupt negative radius never anchors a calibration + w.add_ring(1.0 / (au.d_spacing((1, 1, 1)) * 0.01)) + w.rings = list(w.rings) + [{**w.rings[0], "id": 2, "radius_px": -30.0}] + with pytest.raises(ValueError): + w.calibrate_from_phase(au) + assert w.k_calibrated is False + + +def test_index_rings_and_identify_phase(): + # index_rings labels every ring against the phase + w = _au_ring_widget() + w.index_rings(_au()) + assert [rng["hkl"] for rng in w.rings] == list(_AU_HKLS) + assert all(rng["d_ref"] is not None and rng["d_error"] < 0.01 for rng in w.rings) + assert "Au" in w.phase_match + w2 = ShowDiffraction(np.zeros((64, 64), np.float32), verbose=False) + w2.add_ring(10.0) + with pytest.raises(ValueError): + w2.index_rings(_au()) + # identify_phase ranks candidates from ring d-spacings + w = ShowDiffraction( + np.zeros((256, 256), np.float32), center=(128, 128), k_pixel_size=0.01, verbose=False + ) + for d in (2.53, 1.71, 1.44): + w.add_ring(1.0 / (d * 0.01)) + magnetite = Phase.from_dspacings( + "Fe3O4", [(2.97, "220"), (2.53, "311"), (2.10, "400"), (1.71, "422"), (1.44, "440")] + ) + other = Phase.from_dspacings("Other", [(2.80, "x"), (1.90, "y"), (1.20, "z")]) + ranked = w.identify_phase([magnetite, other]) + assert ranked[0]["name"] == "Fe3O4" + assert "Fe3O4" in w.phase_match + assert "few measured lines" in w.phase_match + # Search only + assert all(rng["hkl"] == "" for rng in w.rings) + w2 = ShowDiffraction(np.zeros((64, 64), np.float32), verbose=False) + w2.add_ring(10.0) + with pytest.raises(ValueError): + w2.identify_phase([magnetite]) + # with no rings, identify_phase ranks against spot d-spacings; spots stay unlabeled + w = ShowDiffraction( + np.zeros((256, 256), np.float32), + center=(128, 128), + k_pixel_size=0.01, + spot_refine=False, + verbose=False, + ) + for d, ang in ((2.53, 0), (1.71, 50), (1.44, 100)): + r, a = 1.0 / (d * 0.01), math.radians(ang) + w.add_spot(128 + r * math.sin(a), 128 + r * math.cos(a)) + ranked = w.identify_phase([magnetite, other]) + assert ranked[0]["name"] == "Fe3O4" + assert "Fe3O4" in w.phase_match + assert all(s["hkl"] == "" for s in w.spots) + + +def test_identify_lines_unknown_intensity(): + # lattice-based phases have no intensities; lines carry None, not 0 + w = _au_ring_widget() + reports = w.identify_phase([_au()]) + assert all(row["i_rel"] is None for row in reports[0]["lines"]) + + +def test_identify_summary_tie(): + # a runner-up matching the winner's count is annotated + w = ShowDiffraction(np.zeros((64, 64), np.float32), verbose=False) + reports = [ + {"name": "Fe3O4", "matched": 6, "n_obs": 6}, + {"name": "ZnFe2O4", "matched": 6, "n_obs": 6}, + {"name": "Au", "matched": 3, "n_obs": 6}, + ] + assert w._identify_summary(reports) == "Fe3O4: 6/6 lines; next: ZnFe2O4 (also 6/6), Au" + + +def _au_spot_widget(specs, k=0.01, size=256): + # specs: [(hkl, angle_deg)] -> spot at radius 1/(d(hkl)*k) px, angle from +col axis + au, cen = _au(), (size // 2, size // 2) + w = ShowDiffraction( + np.zeros((size, size), np.float32), + center=cen, + k_pixel_size=k, + spot_refine=False, + verbose=False, + ) + for hkl, ang in specs: + r = 1.0 / (au.d_spacing(hkl) * k) + a = math.radians(ang) + w.add_spot(cen[0] + r * math.sin(a), cen[1] + r * math.cos(a)) + return w + + +def test_index_spots(): + # a (200)/(220) anchor pair at the measured 45 degrees solves zone axis [001] + w = _au_spot_widget([((2, 0, 0), 0), ((0, 2, 0), 90), ((2, 2, 0), 45)]) + w.index_spots(_au()) + assert w.zone_axis == "[001]" + assert [s["hkl"] for s in w.spots] == ["200", "200", "220"] + assert all(s["d_ref"] is not None and s["d_error"] < 1e-3 for s in w.spots) + assert "Au" in w.phase_match + # no anchor pair or collinear spots: labels fall back to best d, no zone axis + w = _au_spot_widget([((2, 0, 0), 0), ((0, 2, 0), 90)]) # same family + w.index_spots(_au()) + assert w.zone_axis == "" and w.phase_match == "" + assert [s["hkl"] for s in w.spots] == ["200", "200"] + assert all(s["d_ref"] is not None for s in w.spots) + w2 = _au_spot_widget([((2, 0, 0), 0), ((4, 0, 0), 0)]) # collinear + w2.index_spots(_au()) + assert w2.zone_axis == "" + assert [s["hkl"] for s in w2.spots] == ["200", "400"] + # error paths: uncalibrated widget and a d-spacing-only card + w = ShowDiffraction(np.zeros((64, 64), np.float32), spot_refine=False, verbose=False) + w.add_spot(20.0, 40.0) + with pytest.raises(ValueError): + w.index_spots(_au()) + w2 = _au_spot_widget([((2, 0, 0), 0)]) + with pytest.raises(ValueError, match="lattice-based"): + w2.index_spots(Phase.from_dspacings("card", [(2.0, "a"), (1.5, "b")])) + + +def _parse_zone(label): + values, sign = [], 1 + for ch in label.strip("[]"): + if ch == "-": + sign = -1 + else: + values.append(sign * int(ch)) + sign = 1 + return tuple(values) + + +def test_index_spots_zone_variants(): + # a [011]-type pattern: the canonical anchor angle matches, the variant differs + from quantem.widget.showdiffraction import _parse_hkl_label + + w = _au_spot_widget( + [ + ((2, 0, 0), 0.0), + ((1, 1, -1), 54.7356), + ((0, 2, -2), 90.0), + ((1, 1, 1), 20.0), # no 111 variant sits at 20 degrees from (200) + ] + ) + w.index_spots(_au()) + assert w.zone_axis != "" + axis = _parse_zone(w.zone_axis) + labels = [s["hkl"] for s in w.spots] + assert labels[0] and labels[1] and labels[2] + assert labels[3] == "" + for label in labels[:3]: + h, k, ell = _parse_hkl_label(label) + assert h * axis[0] + k * axis[1] + ell * axis[2] == 0 + + +def test_index_spots_hexagonal_family_closure(): + # hexagonal {110} images at 90 degrees are not signed permutations of (110) + from quantem.widget.showdiffraction import library_phase + + zno, k, cen = library_phase("ZnO"), 0.01, (128, 128) + w = ShowDiffraction( + np.zeros((256, 256), np.float32), + center=cen, + k_pixel_size=k, + spot_refine=False, + verbose=False, + ) + d100, d110 = zno.d_spacing((1, 0, 0)), zno.d_spacing((1, 1, 0)) + for d, start in ((d100, 0), (d110, 30)): + for ang in range(start, start + 360, 60): + r, a = 1.0 / (d * k), math.radians(ang) + w.add_spot(cen[0] + r * math.sin(a), cen[1] + r * math.cos(a)) + w.index_spots(zno) + assert w.zone_axis == "[001]" + assert all(s["hkl"] for s in w.spots) + + +def test_analysis_request_channels(): + # the Refine button channel runs refine_center and resets itself + true = (124.0, 130.5) + dp = _off_center_ring_dp(true, radii=(50.0, 75.0)) + w = ShowDiffraction(dp, center=(121.0, 133.0), verbose=False) + w._refine_center_request = True + assert abs(w.center_row - true[0]) < 1.0 and abs(w.center_col - true[1]) < 1.0 + assert w._refine_center_request is False + assert w.analysis_status.startswith("Center") + # the Refine channel runs the method picked in the dropdown and reports the grade + dp = _off_center_ring_dp((124.0, 130.5), radii=(50.0, 75.0)) + w = ShowDiffraction(dp, center=(121.0, 133.0), verbose=False) + w.refine_method = "phase_corr" + w._refine_center_request = True + assert "phase_corr" in w.analysis_status + assert w._refine_center_request is False + # fit-rings and fit-ellipse channels + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w.add_ring(60.0) + w._fit_rings_request = True + assert all("fwhm_px" in r for r in w.rings) + assert w._fit_rings_request is False + assert w.analysis_status.startswith("Fitted") + assert "texture" in w.analysis_status + dp2, cen2 = _elliptical_ring_dp(r0=60.0, ratio=1.2, angle_deg=30.0) + w2 = ShowDiffraction(dp2, center=cen2, bf_radius=10, verbose=False) + w2.add_ring(60.0) + w2._fit_ellipse_request = True + assert w2.ellipse_ratio == pytest.approx(1.2, rel=0.05) + assert "Ellipse" in w2.analysis_status + assert w2._fit_ellipse_request is False + # either channel with no rings picked sets a failed status + for trait in ("_fit_rings_request", "_fit_ellipse_request"): + dp, cen = _ring_dp([60.0]) + w = ShowDiffraction(dp, center=cen, verbose=False) + setattr(w, trait, True) + assert "failed" in w.analysis_status + assert getattr(w, trait) is False + + +def _blob_grid_dp(size=128, spacing=22): + cen = (size // 2, size // 2) + rows = np.arange(size)[:, None] + cols = np.arange(size)[None, :] + dp = 300.0 * np.exp(-(((rows - cen[0]) ** 2 + (cols - cen[1]) ** 2) / (2 * 4.0**2))) + coords = [20 + spacing * i for i in range(5)] + truth = [(r, c) for r in coords for c in coords if (r, c) != cen] + for r, c in truth: + dp = dp + 40.0 * np.exp(-(((rows - r) ** 2 + (cols - c) ** 2) / (2 * 2.0**2))) + return dp.astype(np.float32), cen, truth + + +def test_detect_spots_uncapped_and_rejection(): + # every blob found with no count cap; caps and relative bar still apply + dp, cen, truth = _blob_grid_dp() + w = ShowDiffraction(dp, center=cen, bf_radius=6, verbose=False) + w.detect_spots() + assert len(w.spots) == 24 # every blob, no count cap + assert all( + any(abs(s["row"] - r) < 2 and abs(s["col"] - c) < 2 for r, c in truth) for s in w.spots + ) + w.detect_spots(max_spots=5) + assert len(w.spots) == 5 + w.detect_spots(min_relative=2.0) # nothing is twice the strongest peak + assert len(w.spots) == 0 + # low-contrast lattice detected; ring crests rejected + size = 256 + center = (size - 1) / 2 + rows, cols = np.mgrid[0:size, 0:size] + lattice = np.zeros((size, size), np.float64) + for h in range(-2, 3): + for k in range(-2, 3): + amp = 6.0 if h == k == 0 else 1.0 / (1 + 0.4 * (h * h + k * k)) + lattice += amp * np.exp( + -((rows - (center + h * 28.0)) ** 2 + (cols - (center + k * 28.0)) ** 2) / 8.0 + ) + w = ShowDiffraction( + lattice.astype(np.float32), center=(center, center), bf_radius=14, verbose=False + ) + w.detect_spots() + assert len(w.spots) >= 20 # sub-unity intensities: bar is relative, not absolute + dp, cen = _dp((60.0, 90.0), background="power") + w2 = ShowDiffraction(dp, center=cen, bf_radius=10, spot_refine=False, verbose=False) + w2.detect_spots() + assert len(w2.spots) <= 2 # ring crests are not spots + # request value -1 means keep all, for spots and rings alike + dp, cen, _ = _blob_grid_dp() + w = ShowDiffraction(dp, center=cen, bf_radius=6, verbose=False) + w._detect_spots_request = -1 + assert len(w.spots) >= 21 + assert w._detect_spots_request == 0 + dp2, cen2 = _dp(tuple(range(40, 265, 25)), size=512, background="power") + w2 = ShowDiffraction(dp2, center=cen2, bf_radius=15, verbose=False) + w2._detect_rings_request = -1 + assert len(w2.rings) >= 9 # all 9 rings, not the old top-8 + assert w2._detect_rings_request == 0 + + +def test_detect_spots_close_pair_keeps_strongest(): + # peaks closer than min_distance: the strongest survives, not neither + size = 128 + rows = np.arange(size, dtype=np.float64)[:, None] + cols = np.arange(size, dtype=np.float64)[None, :] + for sep in (4.0, 5.0, 6.0): + dp = np.zeros((size, size)) + for r0, c0, amp in ((40.0, 40.0, 100.0), (40.0, 40.0 + sep, 60.0), (90.0, 90.0, 80.0)): + dp += amp * np.exp(-0.5 * (((rows - r0) / 1.5) ** 2 + ((cols - c0) / 1.5) ** 2)) + w = ShowDiffraction( + dp.astype(np.float32), center=(64, 64), spot_refine=False, verbose=False + ) + w.detect_spots(min_distance=6, exclude_radius=5) + positions = [(round(s["row"]), round(s["col"])) for s in w.spots] + assert positions.count((40, 40)) == 1 # strongest of the pair + assert (40, 40 + sep) not in positions + assert (90, 90) in positions # isolated control + assert len(w.spots) == 2 + + +def test_move_spot_repicks_and_resets_index(): + size, cen = 128, (64, 64) + rows = np.arange(size)[:, None] + cols = np.arange(size)[None, :] + + def blob(r, c, a, s): + return a * np.exp(-(((rows - r) ** 2 + (cols - c) ** 2) / (2 * s * s))) + + dp = (blob(*cen, 300, 4) + blob(40, 64, 60, 2) + blob(88, 80, 60, 2)).astype(np.float32) + w = ShowDiffraction(dp, center=cen, bf_radius=6, k_pixel_size=0.01, verbose=False) + w.add_spot(40.0, 64.0) + spot = w.spots[0] + d_before = spot["d_spacing"] + w.spots = [{**spot, "hkl": "200"}] + w.move_spot(spot["id"], 87.0, 79.0) # fit re-picks the nearby blob + moved = w.spots[0] + assert moved["id"] == spot["id"] + assert abs(moved["row"] - 88.0) < 1.0 and abs(moved["col"] - 80.0) < 1.0 + assert moved["hkl"] == "" + assert moved["d_spacing"] != d_before + w._spot_move_request = [float(spot["id"]), 40.0, 64.0] + assert abs(w.spots[0]["row"] - 40.0) < 1.0 + assert w._spot_move_request == [] + w.move_spot(999, 10.0, 10.0) + assert len(w.spots) == 1 + + +def test_profile_panel(monkeypatch): + # populates on show_profile with sorted radii peaking at the ring + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + assert w._profile_data == b"" + w.show_profile = True + arr = np.frombuffer(w._profile_data, np.float32) + assert arr.size > 0 and arr.size % 2 == 0 + n = arr.size // 2 + radii, intensity = arr[:n], arr[n:] + assert np.all(np.diff(radii) > 0) + outside = radii > 20 + assert abs(radii[outside][np.argmax(intensity[outside])] - 60.0) < 3.0 + # subtract-background toggle changes intensities but not radii + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w.show_profile = True + raw = np.frombuffer(w._profile_data, np.float32).copy() + w.profile_subtract_background = True + net = np.frombuffer(w._profile_data, np.float32) + n = raw.size // 2 + assert np.allclose(raw[:n], net[:n]) + assert not np.allclose(raw[n:], net[n:]) + + # background failure falls back to the raw profile with a failed status + def boom(**kwargs): + raise ValueError("boom") + + monkeypatch.setattr(w, "radial_background", boom) + w.profile_subtract_background = False + w.profile_subtract_background = True + assert len(w._profile_data) > 0 + assert "failed" in w.analysis_status + # the profile recomputes on frame/center changes and clears when hidden + stack = np.stack( + [_ring_dp([60.0], background="power")[0], _ring_dp([90.0], background="power")[0]] + ) + w = ShowDiffraction(stack, center=(128, 128), verbose=False) + w.frame_idx = 1 + assert w._profile_data == b"" # hidden: nothing computed + w.show_profile = True + first = bytes(w._profile_data) + assert len(first) > 0 + w.frame_idx = 0 + second = bytes(w._profile_data) + assert second != first + w.center_row = w.center_row + 4.0 + assert bytes(w._profile_data) != second + w.show_profile = False + assert w._profile_data == b"" + # theta limits restrict the profile to the azimuthal sector + dp, cen = _arc_ring_dp(theta_lo=10.0, theta_hi=70.0) + w = ShowDiffraction(dp, center=cen, verbose=False) + w.show_profile = True + w.profile_theta_min, w.profile_theta_max = 10.0, 70.0 + arr = np.frombuffer(w._profile_data, np.float32) + n = arr.size // 2 + in_sector = arr[n:][(arr[:n] > 55) & (arr[:n] < 65)].max() + w.profile_theta_min, w.profile_theta_max = 180.0, 250.0 + arr2 = np.frombuffer(w._profile_data, np.float32) + out_sector = arr2[n:][(arr2[:n] > 55) & (arr2[:n] < 65)].max() + assert in_sector > 10.0 + assert out_sector < 2.0 + + +def _library_ring_widget(name="Au", k=0.01, size=256, uncalibrated=False): + from quantem.widget import library_phase + + ph, cen = library_phase(name), (size // 2, size // 2) + kwargs = {} if uncalibrated else {"k_pixel_size": k} + w = ShowDiffraction(np.zeros((size, size), np.float32), center=cen, verbose=False, **kwargs) + for r in ph.reflections(d_min=1.4)[:5]: + w.add_ring(1.0 / (r["d"] * k)) + return w + + +def test_phase_and_indexing_request_channels(): + # calibrate channel: no phase fails, library phase calibrates, custom phase calibrates + w = _library_ring_widget("Au", uncalibrated=True) + assert {"Au", "Si", "Fe3O4"} <= {p["name"] for p in w._phase_library} + w._calibrate_phase_request = True # no phase selected + assert "failed" in w.analysis_status and "phase" in w.analysis_status.lower() + w.phase_name = "Au" + w._calibrate_phase_request = True + assert abs(w.k_pixel_size - 0.01) / 0.01 < 0.01 + assert w.calibration_source == "from_phase" + assert w._calibrate_phase_request is False + w2 = _library_ring_widget("Au", uncalibrated=True) + w2.custom_phases = [{"name": "MyAu", "a": 4.0782, "absences": "fcc"}] + w2.phase_name = "MyAu" + w2._calibrate_phase_request = True + assert abs(w2.k_pixel_size - 0.01) / 0.01 < 0.01 + # index-rings and index-spots channels + w = _library_ring_widget("Au") + w.phase_name = "Au" + w._index_rings_request = True + assert all(r["hkl"] for r in w.rings) + assert "Au" in w.phase_match + w2 = _au_spot_widget([((2, 0, 0), 0), ((0, 2, 0), 90), ((2, 2, 0), 45)]) + w2.phase_name = "Au" + w2._index_spots_request = True + assert w2.zone_axis == "[001]" + + +def test_search_phases_and_identify_request(): + # identify channel ranks the spinel family from Fe3O4 rings + from quantem.widget import library_phase + + ph = library_phase("Fe3O4") + w = ShowDiffraction( + np.zeros((512, 512), np.float32), center=(256, 256), k_pixel_size=0.004, verbose=False + ) + for r in ph.reflections(d_min=1.4)[:6]: + w.add_ring(1.0 / (r["d"] * 0.004)) + w._identify_request = True + assert "Fe3O4" in w.analysis_status or "Fe2O3" in w.analysis_status + assert len(w._identify_results) > 0 + assert w._identify_request is False + ranked = w.identify_phase(w._all_phases()) + assert ranked[0]["name"] == "Fe3O4" + # the a = 8.35-8.44 spinel family is near-degenerate on ring positions alone + spinels = {"Fe3O4", "CoFe2O4", "NiFe2O4", "ZnFe2O4", "γ-Fe2O3"} + assert {rep["name"] for rep in ranked[:5]} == spinels + # library search matches Au from its own rings, with a per-line match table + w = _library_ring_widget("Au") + reports = w.search_phases() + from quantem.widget.showdiffraction import match_sort_key + + keys = [match_sort_key(rep) for rep in reports] + assert keys == sorted(keys) + for key in ("matched", "n_obs", "mean_err", "n_missing_strong", "lines"): + assert key in reports[0] + au = next(rep for rep in reports if rep["name"] == "Au") + assert au["matched"] == au["n_obs"] == len(w.rings) + matched = [ln for ln in au["lines"] if ln["obs_d"] is not None and ln["ref_d"] is not None] + assert len(matched) == len(w.rings) + assert len(w._identify_results) > 0 + json.dumps(w._identify_results) # synced trait must be JSON-serializable + # element filter: Au lacks Fe/O, so it drops out of the candidate list + filtered = w.search_phases(elements="Fe,O") + assert "Au" not in {rep["name"] for rep in filtered} + with pytest.raises(ValueError, match="calibrat"): + _library_ring_widget("Au", uncalibrated=True).search_phases() + # on a calibrated pattern the phase matching at scale 1.0 should rank first + assert w.search_phases()[0]["name"] == "Au" + + +def test_search_phases_absurd_calibration_bounded(): + # a wrong calibration must not hang the reflection enumeration + import time + + w = ShowDiffraction( + np.zeros((256, 256), np.float32), center=(128, 128), k_pixel_size=10.0, verbose=False + ) + w.add_ring(30.0) + w.add_ring(50.0) + t0 = time.perf_counter() + reports = w.search_phases() + assert time.perf_counter() - t0 < 120.0 + assert reports + + +def test_search_phases_skips_degenerate_candidate(): + # one broken candidate must not abort ranking of the rest + w = _library_ring_widget("Au") + bad = Phase.from_cubic("Bad", 4.0) + bad._g_star = -np.eye(3) # every d_spacing raises "invalid reflection" + reports = w.search_phases(extra=[bad]) + assert reports[0]["name"] == "Au" + assert "Bad" not in {rep["name"] for rep in reports} + + +def test_custom_phases(): + # entries accept a full lattice, defaulting b=a, c=a, angles 90 + from quantem.widget import library_phase + + w = _library_ring_widget("Zr", uncalibrated=True) + w.custom_phases = [ + {"name": "MyZr", "a": 3.2320, "c": 5.1470, "gamma": 120.0, "absences": "hcp"}, + {"name": "Tet", "a": 3.0, "c": 5.0}, + ] + w.phase_name = "MyZr" + zr = w._selected_phase() + assert zr.lattice == (3.2320, 3.2320, 5.1470, 90.0, 90.0, 120.0) + assert zr.d_spacing((1, 0, 1)) == pytest.approx(library_phase("Zr").d_spacing((1, 0, 1))) + w.phase_name = "Tet" + assert w._selected_phase().lattice == (3.0, 3.0, 5.0, 90.0, 90.0, 90.0) + # a hexagonal custom phase calibrates and indexes rings like its library twin + w = _library_ring_widget("Zr", uncalibrated=True) + w.custom_phases = [ + {"name": "MyZr", "a": 3.2320, "c": 5.1470, "gamma": 120.0, "absences": "hcp"} + ] + w.phase_name = "MyZr" + w._calibrate_phase_request = True + assert abs(w.k_pixel_size - 0.01) / 0.01 < 0.01 + w._index_rings_request = True + assert all(r["hkl"] for r in w.rings) + assert "MyZr" in w.phase_match + # identify_custom_only ranks only user candidates, not the library + w = _library_ring_widget("Au") + w.custom_phases = [{"name": "MyAu", "a": 4.0782, "absences": "fcc"}] + w.identify_custom_only = True + w._identify_request = True + assert {rep["name"] for rep in w._identify_results} == {"MyAu"} + assert w.search_phases(custom_only=False)[0]["name"] == "Au" + reports = w.search_phases(custom_only=True, extra=[Phase.from_cubic("ExtraAu", 4.0782)]) + assert {rep["name"] for rep in reports} == {"MyAu", "ExtraAu"} + assert "identify_custom_only" in w.state_dict() + w.custom_phases = [] + w._identify_request = True + assert "Identify failed" in w.analysis_status + + +def test_malformed_custom_phase_request_resets_flag(): + # KeyError from a malformed entry must not leave the request flag stuck + w = _library_ring_widget("Au", uncalibrated=True) + w.custom_phases = [{"name": "Broken"}] # missing "a" + w.phase_name = "Broken" + w._calibrate_phase_request = True + assert w._calibrate_phase_request is False + assert "Phase calibration failed" in w.analysis_status + w.custom_phases = [{"name": "Broken", "a": None}] # float(None) raises TypeError + w._index_rings_request = True + assert w._index_rings_request is False + assert "Ring indexing failed" in w.analysis_status + + +def test_export_html_includes_identify_and_quality(tmp_path): + # identify table and quality panel survive into the offline export + w = _library_ring_widget("Au") + w.search_phases() + w.quality_report() + assert w._identify_results and w._quality + clone = w._clone_for_html_export() + try: + assert clone._identify_results == w._identify_results + assert clone._quality == w._quality + finally: + clone.close() + path = w.export_html(tmp_path / "dp.html") + html = path.read_text() + assert w._identify_results[0]["name"] in html + + +def _au_saed_pattern(size=512, cenpx=255.5, k=0.004): + from quantem.widget import library_phase + + au = library_phase("Au") + rows = np.arange(size, dtype=np.float64)[:, None] + cols = np.arange(size, dtype=np.float64)[None, :] + r = np.hypot(rows - cenpx, cols - cenpx) + pat = 300.0 * np.exp(-(r**2) / (2 * 8.0**2)) + 20.0 * np.exp(-r / 40.0) + for refl in au.reflections(d_min=1.2): + pat += 30.0 * np.exp(-((r - 1.0 / (refl["d"] * k)) ** 2) / (2 * 2.5**2)) + return pat.astype(np.float32) + + +def test_run_auto(): + # full pipeline in one call + w = ShowDiffraction(_au_saed_pattern(), verbose=False) + w.phase_name = "Au" + w.run_auto(max_rings=4) + assert abs(w.k_pixel_size - 0.004) / 0.004 < 0.02 + assert all(ring["hkl"] for ring in w.rings) + assert all(ring.get("fwhm_px") is not None for ring in w.rings) + assert w.analysis_status == "" + # Auto button channel + dp, _ = _ring_dp([60.0, 90.0], background="power") + w2 = ShowDiffraction(dp, verbose=False) + w2._auto_request = True + assert w2._auto_request is False + assert w2.analysis_status == "" + # stays silent on success and only surfaces failed steps + flat = np.full((256, 256), 5.0, dtype=np.float32) + w = ShowDiffraction(flat, verbose=False) + w.run_auto() + assert w.rings == [] + assert w.analysis_status == "Auto: ring detection failed (no rings found)" + # a selected phase with too few rings surfaces a calibration failure + dp, _ = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, verbose=False) + w.phase_name = "Au" + w.run_auto(max_rings=1) + assert len(w.rings) == 1 + assert "calibration failed" in w.analysis_status + assert not w.k_calibrated + # a phase_name that matches no library or custom phase is reported + dp, _ = _ring_dp([60.0, 90.0], background="power") + w = ShowDiffraction(dp, verbose=False) + w.phase_name = "NotAPhase" + w.run_auto() + assert 'calibration failed (phase "NotAPhase" not found)' in w.analysis_status + + +def test_run_auto_calibrates_fitted_radii(): + # calibration uses profile-refined radii: replaying the least-squares scale + # on the stored rings reproduces k exactly + w = ShowDiffraction(_au_saed_pattern(), verbose=False) + w.phase_name = "Au" + w.run_auto(max_rings=4) + pairs = [(rng["radius_px"], 1.0 / rng["d_ref"]) for rng in w.rings if rng["hkl"]] + scale = sum(rp * q for rp, q in pairs) / sum(q * q for _, q in pairs) + assert w.k_pixel_size == pytest.approx(1.0 / scale, rel=1e-9) + assert all(rng.get("fwhm_inv_angstrom") is not None for rng in w.rings) + + +def test_run_auto_reports_unindexed_rings(): + # a default run anchored on unexcluded artifacts leaves rings unmatched + w = ShowDiffraction(_fe3o4_dp(), verbose=False) + w.phase_name = "Fe3O4" + w.run_auto() + assert "rings unindexed" in w.analysis_status + + +def _drifted_stack(seed=11): + # same ring pattern rolled by known shifts, plus one pure-noise garbage frame + base = _dp((60.0,), background="power")[0] + shifts = [(0, 0), (2, -1), (-3, 2), (1, 3)] + frames = [np.roll(base, s, axis=(0, 1)) for s in shifts] + rng = np.random.default_rng(seed) + frames.append(rng.random(base.shape).astype(np.float32)) + return np.stack(frames) + + +def test_merge_frames(): + w = ShowDiffraction(_drifted_stack(), center=(128, 128), verbose=False) + w.add_ring(60.0) + report = w.merge_frames() + assert report["n_frames"] == 5 + assert report["n_used"] >= 4 + assert report["used"][4] is False # garbage frame gated out + assert len(report["shifts"]) == 5 + assert {"cv", "coverage", "snr"} <= set(report["before"]) + assert {"cv", "coverage", "snr"} <= set(report["after"]) + assert w.n_frames == 6 and w.frame_idx == 5 # merged pattern appended and shown + single = ShowDiffraction(_dp((60.0,))[0], verbose=False) + with pytest.raises(ValueError, match="multi-frame"): + single.merge_frames() + with pytest.raises(ValueError, match="statistic"): + w.merge_frames(statistic="sum") + # Merge button channel + w = ShowDiffraction(_drifted_stack(), center=(128, 128), verbose=False) + w._merge_request = True + assert w.analysis_status.startswith("Merged") + assert w._merge_request is False + + +def test_merge_frames_repeat_uses_originals(): + # a second merge must not fold the appended merged frame back in + w = ShowDiffraction(_drifted_stack(), center=(128, 128), verbose=False) + w.add_ring(60.0) + r1 = w.merge_frames(align=False) + first = w._data.cpu().numpy()[-1].copy() + r2 = w.merge_frames(align=False) + second = w._data.cpu().numpy()[-1].copy() + assert r1["n_frames"] == r2["n_frames"] == 5 + assert w.n_frames == 6 # merged frame replaced, not stacked + np.testing.assert_allclose(second, first) + # replacing the data resets merge provenance + w.set_image(_drifted_stack()) + assert w.merge_frames(align=False)["n_frames"] == 5 + + +def _staggered_ring_stack(size=128, radii=(30.0, 40.0, 50.0), scales=None): + rows = np.arange(size, dtype=np.float64)[:, None] + cols = np.arange(size, dtype=np.float64)[None, :] + r = np.hypot(rows - size / 2, cols - size / 2) + scales = scales or [1.0] * len(radii) + return np.stack( + [s * 1000.0 * np.exp(-((r - rr) ** 2) / (2 * 1.5**2)) for rr, s in zip(radii, scales)] + ).astype(np.float32) + + +def test_measurements_keep_source_frame(): + # records tag their frame; geometry changes resample from that frame + stack = _staggered_ring_stack() + w = ShowDiffraction(stack, center=(64, 64), spot_refine=False, verbose=False) + w.add_ring(30.0) + w.add_spot(64.0, 94.0) # on the frame-0 ring + ring_i, spot_i = w.rings[0]["intensity"], w.spots[0]["intensity"] + assert w.rings[0]["frame_idx"] == 0 + assert w.spots[0]["frame_idx"] == 0 + assert spot_i > 100.0 + w.frame_idx = 2 # scrub: values persist + w.center_row = w.center_row + 1e-9 # geometry nudge must not switch frames + # bin-edge pixels may flip bins on the nudge; frame 2 would read ~0 here + assert w.rings[0]["intensity"] == pytest.approx(ring_i, rel=0.02) + assert w.spots[0]["intensity"] == pytest.approx(spot_i, rel=1e-6) + # state roundtrip: saved values survive a restore with non-default geometry + w2 = ShowDiffraction(stack, center=(64, 64), spot_refine=False, verbose=False) + w2.add_ring(30.0) + w2.add_spot(64.0, 94.0) + w2.ellipse_ratio = 1.05 # trailing geometry field in the saved state + saved_ring_i = w2.rings[0]["intensity"] + w2.frame_idx = 2 + state = json.loads(json.dumps(w2.state_dict())) + w3 = ShowDiffraction(stack, center=(64, 64), spot_refine=False, verbose=False) + w3.load_state_dict(state) + assert w3.frame_idx == 2 + assert w3.rings[0]["intensity"] == pytest.approx(saved_ring_i, rel=1e-6) + + +def test_merge_repeat_refreshes_profile_panes(): + # a repeat merge leaves frame_idx unchanged; the panes must still refresh + stack = _staggered_ring_stack(radii=(30.0, 30.0, 30.0), scales=(1.0, 2.0, 3.0)) + w = ShowDiffraction(stack, center=(64, 64), verbose=False) + w.add_ring(30.0) + w.show_profile = True + w.show_azimuthal = True + w.merge_frames(align=False) + profile_mean, azimuthal_mean = w._profile_data, w._azimuthal_data + w.merge_frames(statistic="max", align=False) + assert w._profile_data != profile_mean + assert w._azimuthal_data != azimuthal_mean + + +def test_state_dict_merge_provenance_and_clamp_warning(): + stack = _staggered_ring_stack() + w = ShowDiffraction(stack, center=(64, 64), verbose=False) + w.merge_frames(align=False) + state = json.loads(json.dumps(w.state_dict())) + assert state["n_source_frames"] == 3 + # restored onto source+merged data, a re-merge keeps 3 sources + data4 = w._data.cpu().numpy() + w2 = ShowDiffraction(data4, center=(64, 64), verbose=False) + w2.load_state_dict(state) + assert w2.merge_frames(align=False)["n_frames"] == 3 + assert w2.n_frames == 4 + # restoring onto fewer frames clamps frame_idx and says so + w3 = ShowDiffraction(stack, center=(64, 64), verbose=False) + w3.load_state_dict(state) + assert w3.frame_idx == 2 + assert "clamp" in w3.analysis_status.lower() + + +def test_set_image_refreshes_profile_panes(): + # new data with an unchanged auto-detected center must refresh the profile + stack = _staggered_ring_stack() + w = ShowDiffraction(stack[0], verbose=False) + w.show_profile = True + before = w._profile_data + w.set_image(np.zeros((128, 128), np.float32)) # empty mask: center unchanged + after = w._profile_data + assert after != before + w._update_profile() + assert w._profile_data == after # already fresh + + +def test_quality_report_frame_attribution(): + # ring_snr follows the ring's source frame; the report names its frame + stack = _staggered_ring_stack() + w = ShowDiffraction(stack, center=(64, 64), verbose=False) + w.add_ring(30.0) + snr0 = w.quality_report()["ring_snr"] + w.frame_idx = 2 + report = w.quality_report() + assert report["frame_idx"] == 2 + assert report["ring_snr"] == snr0 + + +def test_constructor_frame_idx_kwarg(): + # frame_idx applies after ingest, not against the default n_frames=1 + stack = _staggered_ring_stack() + w = ShowDiffraction(stack, center=(64, 64), frame_idx=2, verbose=False) + assert w.frame_idx == 2 + np.testing.assert_array_equal( + np.frombuffer(w.frame_bytes, dtype=np.float32).reshape(128, 128), stack[2] + ) + + +def test_quality_report(): + dp, cen = _ring_dp([60.0, 90.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, k_pixel_size=0.02, verbose=False) + for rr in (60.0, 90.0): + w.add_ring(rr) + w.fit_ring_profile() + w.mask_regions = [{"kind": "wedge", "start_deg": 0.0, "end_deg": 90.0}] + report = w.quality_report() + for key in ( + "center", + "calibration", + "ellipse", + "rings", + "n_unexplained_rings", + "mask_coverage_pct", + ): + assert key in report + assert report["center"]["method"] + assert report["mask_coverage_pct"] > 0 + assert len(report["rings"]) == 2 + assert w._quality == report # synced trait + json.dumps(w._quality) + w2 = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w2.add_ring(60.0) + assert w2._quality == {} + w2._fit_rings_request = True # QC refresh + assert w2._quality != {} + assert "center" in w2._quality + # QC button channel refreshes the snapshot + dp, cen = _ring_dp([60.0], background="power") + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w.add_ring(60.0) + assert w._quality == {} + w._quality_request = True + assert w._quality_request is False + assert "center" in w._quality + assert w.analysis_status == "Quality updated" + + +def test_quality_report_ring_snr_respects_mask(): + # dead-wedge pattern: masking the wedge lifts ring coverage + dp, cen = _ring_dp([60.0]) + rows, cols = np.indices(dp.shape, dtype=np.float64) + theta = np.degrees(np.arctan2(rows - cen[0], cols - cen[1])) % 360.0 + dp = np.where((theta >= 0.0) & (theta <= 90.0), 0.0, dp).astype(np.float32) + w = ShowDiffraction(dp, center=cen, verbose=False) + w.add_ring(60.0) + before = w.quality_report()["ring_snr"] + w.mask_regions = [{"kind": "wedge", "start_deg": 0.0, "end_deg": 90.0}] + after = w.quality_report()["ring_snr"] + assert after["coverage"] > before["coverage"] + assert after["cv"] < before["cv"] + + +def test_mask_regions(): + # masks exclude from the radial profile + dp, cen = _arc_ring_dp(theta_lo=10.0, theta_hi=70.0) + w = ShowDiffraction(dp, center=cen, verbose=False) + radii, before = w.radial_profile(units="px") + band = (radii > 55) & (radii < 65) + w.mask_regions = [{"kind": "wedge", "start_deg": 0.0, "end_deg": 90.0}] + _, after = w.radial_profile(units="px") + assert before[band].max() > 4.0 + assert after[band].max() < 2.0 + dp2, cen2 = _ring_dp([60.0], background="power") + w2 = ShowDiffraction(dp2, center=cen2, verbose=False) + radii2, before2 = w2.radial_profile(units="px") + w2.mask_regions = [{"kind": "disk", "row": cen2[0], "col": cen2[1] + 60.0, "radius": 15.0}] + _, after2 = w2.radial_profile(units="px") + band2 = (radii2 > 55) & (radii2 < 65) + assert after2[band2].max() < before2[band2].max() + # masks respected by detect_spots + dp = np.zeros((128, 128), np.float32) + dp[40, 40] = 100.0 + dp[90, 90] = 100.0 + w = ShowDiffraction(dp, center=(64, 64), spot_refine=False, verbose=False) + w.mask_regions = [{"kind": "disk", "row": 40.0, "col": 40.0, "radius": 8.0}] + w.detect_spots(max_spots=5, exclude_radius=5) + positions = [(round(s["row"]), round(s["col"])) for s in w.spots] + assert (90, 90) in positions + assert (40, 40) not in positions + + +def test_full_circle_wedge_masks_all(): + # a wedge spanning >= 360 degrees masks every pixel + from quantem.widget.showdiffraction import build_analysis_mask + + shape, cen = (64, 64), (31.5, 31.5) + for start, end in ((0.0, 360.0), (90.0, 450.0)): + mask = build_analysis_mask( + shape, [{"kind": "wedge", "start_deg": start, "end_deg": end}], cen + ) + assert mask.all() + quarter = build_analysis_mask( + shape, [{"kind": "wedge", "start_deg": 0.0, "end_deg": 90.0}], cen + ) + assert abs(quarter.mean() - 0.25) < 0.02 + wrap = build_analysis_mask( + shape, [{"kind": "wedge", "start_deg": 350.0, "end_deg": 20.0}], cen + ) + assert 0.06 < wrap.mean() < 0.11 + + +def test_azimuthal_panel_populates(): + dp, cen = _arc_ring_dp(theta_lo=10.0, theta_hi=70.0) + w = ShowDiffraction(dp, center=cen, verbose=False) + w.add_ring(60.0) + w.show_azimuthal = True + arr = np.frombuffer(w._azimuthal_data, np.float32) + n = arr.size // 2 + theta, intensity = arr[:n], arr[n:] + assert 0 <= theta[np.argmax(intensity)] <= 90 + w.show_azimuthal = False + assert w._azimuthal_data == b"" + w2 = ShowDiffraction(_ring_dp([60.0])[0], center=(128, 128), verbose=False) + w2.show_azimuthal = True # no rings + assert w2._azimuthal_data == b"" + assert "failed" in w2.analysis_status + + +def test_analysis_state_roundtrip(): + dp, cen = _elliptical_ring_dp() + w = ShowDiffraction(dp, center=cen, bf_radius=10, verbose=False) + w.add_ring(60.0) + w.fit_ellipse() + w.apply_ellipse_correction() + w.mask_regions = [{"kind": "wedge", "start_deg": 350.0, "end_deg": 20.0}] + w.show_profile = True + w.profile_log = False + w.profile_subtract_background = True + sd = w.state_dict() + assert "analysis_status" not in sd and "_profile_data" not in sd + w2 = ShowDiffraction(np.zeros((256, 256), np.float32), verbose=False) + w2.load_state_dict(json.loads(json.dumps(sd))) + assert w2.ellipse_ratio == w.ellipse_ratio + assert w2.ellipse_angle == w.ellipse_angle + assert w2.ellipse_corrected is True + assert w2.mask_regions[0]["kind"] == "wedge" + assert w2.show_profile is True and w2.profile_log is False + assert w2.profile_subtract_background is True + assert len(w2._profile_data) > 0 + w3 = _au_ring_widget() + w3.index_rings(_au()) + state = json.loads(json.dumps(w3.state_dict())) + w4 = ShowDiffraction(np.zeros((256, 256), np.float32), verbose=False) + w4.load_state_dict(state) + assert w4.phase_match == w3.phase_match + assert [rng["hkl"] for rng in w4.rings] == [rng["hkl"] for rng in w3.rings] + # refine method, center method, and element filter roundtrip to the export header + dp = _off_center_ring_dp((124.0, 130.5), radii=(50.0, 75.0)) + w = ShowDiffraction(dp, center=(121.0, 133.0), verbose=False) + w.refine_method = "phase_corr" + w.identify_elements = "Fe,O" + w.refine_center(method="phase_corr") + w.mask_regions = [{"kind": "wedge", "start_deg": 0.0, "end_deg": 90.0}] + w.profile_subtract_background = True + sd = w.state_dict() + for key in ("refine_method", "center_method", "identify_elements"): + assert key in sd + w2 = ShowDiffraction(np.zeros((256, 256), np.float32), verbose=False) + w2.load_state_dict(json.loads(json.dumps(sd))) + assert w2.refine_method == "phase_corr" + assert w2.center_method == "phase_corr" + assert w2.identify_elements == "Fe,O" + meta = measurement_metadata(w.state_dict()) + assert meta["center_method"] == "phase_corr" + assert meta["mask_regions"] == [{"kind": "wedge", "start_deg": 0.0, "end_deg": 90.0}] + assert meta["background_subtracted"] is True diff --git a/tests/test_showdiffraction_center.py b/tests/test_showdiffraction_center.py new file mode 100644 index 00000000..b97112f7 --- /dev/null +++ b/tests/test_showdiffraction_center.py @@ -0,0 +1,178 @@ +import numpy as np +import pytest +from scipy import ndimage + +from quantem.widget.showdiffraction import ( + align_frames, + center_phase_correlation, + center_symmetry, + pick_center, + ring_uniformity, +) + + +def _radius_grid(shape, center): + rows, cols = np.indices(shape, dtype=np.float64) + return np.hypot(rows - center[0], cols - center[1]) + + +def _ring_pattern(shape, center, rings, width=2.0, background=True, noise=0.5, seed=0): + r = _radius_grid(shape, center) + img = np.zeros(shape, dtype=np.float64) + for radius, amp in rings: + img += amp * np.exp(-0.5 * ((r - radius) / width) ** 2) + if background: + img += 400.0 / (1.0 + (r / 8.0) ** 1.5) + if noise: + rng = np.random.default_rng(seed) + img += rng.normal(0.0, noise, shape) + return np.clip(img, 0.0, None) + + +def test_center_recovery(): + # center_symmetry refines a nearby guess + truth = (61.3, 66.8) + frame = _ring_pattern((128, 128), truth, [(20.0, 300.0), (38.0, 150.0)]) + row, col = center_symmetry(frame, guess=(59.0, 69.0)) + assert abs(row - truth[0]) < 0.3 + assert abs(col - truth[1]) < 0.3 + + # center_phase_correlation on the same ring pattern + row, col = center_phase_correlation(frame) + assert abs(row - truth[0]) < 0.3 + assert abs(col - truth[1]) < 0.3 + + # phase correlation far off-center + truth = (24.7, 98.3) + frame = _ring_pattern((128, 128), truth, [(15.0, 300.0), (28.0, 150.0)]) + row, col = center_phase_correlation(frame) + assert abs(row - truth[0]) < 0.3 + assert abs(col - truth[1]) < 0.3 + + # phase correlation with a masked beam stop + truth = (64.2, 62.6) + frame = _ring_pattern((128, 128), truth, [(22.0, 300.0), (40.0, 150.0)]) + mask = _radius_grid(frame.shape, truth) <= 12.0 + frame = np.where(mask, 0.0, frame) + row, col = center_phase_correlation(frame, mask=mask) + assert abs(row - truth[0]) < 0.3 + assert abs(col - truth[1]) < 0.3 + + # pick_center auto-selects a method + truth = (63.0, 66.0) + frame = _ring_pattern((128, 128), truth, [(20.0, 300.0), (38.0, 150.0)]) + result = pick_center(frame) + assert set(result) == {"row", "col", "method"} + assert result["method"] in {"phase_corr", "symmetry"} + assert abs(result["row"] - truth[0]) < 1.0 + assert abs(result["col"] - truth[1]) < 1.0 + + # pick_center honors an explicit method and rejects an unknown one + truth = (64.0, 64.0) + frame = _ring_pattern((128, 128), truth, [(30.0, 300.0)]) + for method in ("symmetry", "phase_corr"): + result = pick_center(frame, method=method) + assert result["method"] == method + assert abs(result["row"] - truth[0]) < 1.5 + assert abs(result["col"] - truth[1]) < 1.5 + with pytest.raises(ValueError): + pick_center(frame, method="bogus") + + +def test_center_phase_correlation_integer_shift(): + # integer inversion shift: the wrap alternate lands on the frame border, + # where the symmetry score degenerates, and must not win + truth = (255.5, 255.5) + rows, cols = np.indices((511, 511), dtype=np.float64) + r = np.hypot(rows - truth[0], cols - truth[1]) + frame = 400.0 / (1.0 + (r / 8.0) ** 1.5) + for radius, amp in [(80.0, 300.0), (150.0, 150.0), (250.0, 200.0)]: + frame += amp * np.exp(-0.5 * ((r - radius) / 2.0) ** 2) + # one unmirrored spot + frame += 50.0 * np.exp(-0.5 * (((rows - 150.0) ** 2 + (cols - 350.0) ** 2) / 10.0**2)) + row, col = center_phase_correlation(frame) + assert abs(row - truth[0]) < 0.5 + assert abs(col - truth[1]) < 0.5 + + +def test_align_frames(): + # recovers known sub-pixel drifts + base = _ring_pattern((96, 96), (47.5, 47.5), [(15.0, 300.0), (28.0, 150.0)]) + applied = [(0.0, 0.0), (2.5, -1.5), (-3.25, 2.0), (1.0, 4.5)] + frames = np.stack([ndimage.shift(base, s, order=3) for s in applied]) + aligned, shifts, used = align_frames(frames) + assert aligned.shape == frames.shape + assert all(used) + for measured, truth in zip(shifts, applied): + assert abs(measured[0] + truth[0]) < 0.3 + assert abs(measured[1] + truth[1]) < 0.3 + + # gates a garbage frame out and keeps the good ones + rng = np.random.default_rng(1) + garbage = ndimage.shift(base, (20.0, -14.0), order=1) + rng.normal(0.0, 50.0, base.shape) + frames = np.stack([base, ndimage.shift(base, (1.5, -2.0), order=3), garbage]) + aligned, shifts, used = align_frames(frames, max_shift=8.0) + assert used[0] and used[1] + assert not used[2] + assert np.allclose(aligned[2], frames[2]) + assert abs(shifts[1][0] + 1.5) < 0.3 + assert abs(shifts[1][1] - 2.0) < 0.3 + + +def test_align_frames_external_reference(): + # Poisson-noisy frames register against a smooth noise-free model + center = (47.5, 47.5) + rows, cols = np.indices((96, 96), dtype=np.float64) + r = np.hypot(rows - center[0], cols - center[1]) + model = 30.0 + 100.0 * np.exp(-0.5 * (r / 2.0) ** 2) + spots = [(0, 20, 90.0), (0, -20, 90.0), (17, -6, 70.0), (-17, 6, 70.0), (12, 24, 50.0)] + for d_row, d_col, amp in spots: + off = (rows - center[0] - d_row) ** 2 + (cols - center[1] - d_col) ** 2 + model += amp * np.exp(-0.5 * off / 2.0**2) + applied = (1.3, -2.4) + shifted = np.clip(ndimage.shift(model, applied, order=3), 0.0, None) + frames = np.stack( + [np.random.default_rng(seed).poisson(shifted).astype(np.float64) for seed in (0, 1, 8)] + ) + aligned, shifts, used = align_frames(frames, reference=model) + assert all(used) + for measured in shifts: + assert abs(measured[0] + applied[0]) < 0.5 + assert abs(measured[1] + applied[1]) < 0.5 + + +def test_ring_uniformity(): + # a full clean ring reads high coverage, low spread, high SNR + center = (63.5, 63.5) + frame = _ring_pattern((128, 128), center, [(30.0, 300.0)], background=False, noise=0.0) + qc = ring_uniformity(frame, center, 30.0) + assert qc["coverage"] > 0.95 + assert qc["cv"] < 0.2 + assert qc["snr"] > 5.0 + + # a narrow arc reads low coverage and high spread + rows, cols = np.indices(frame.shape, dtype=np.float64) + theta = np.arctan2(rows - center[0], cols - center[1]) + frame = np.where(np.abs(theta) <= np.pi / 6.0, frame, 0.0) + qc = ring_uniformity(frame, center, 30.0) + assert qc["coverage"] < 0.4 + assert qc["cv"] > 1.0 + + +def test_ring_uniformity_mask_and_empty_annulus(): + # a dead wedge scores against live sectors only once masked + center = (63.5, 63.5) + frame = _ring_pattern((128, 128), center, [(30.0, 300.0)], background=False, noise=0.0) + rows, cols = np.indices(frame.shape, dtype=np.float64) + theta = np.degrees(np.arctan2(rows - center[0], cols - center[1])) % 360.0 + wedge = (theta >= 0.0) & (theta <= 90.0) + dead = np.where(wedge, 0.0, frame) + unmasked = ring_uniformity(dead, center, 30.0) + masked = ring_uniformity(dead, center, 30.0, mask=wedge) + assert masked["coverage"] > unmasked["coverage"] + assert masked["cv"] < unmasked["cv"] + assert masked["coverage"] > 0.95 + + # an annulus entirely off the detector carries no evidence + empty = ring_uniformity(frame, center, 500.0) + assert empty == {"cv": 0.0, "coverage": 0.0, "snr": 0.0} diff --git a/tests/test_showdiffraction_crystal.py b/tests/test_showdiffraction_crystal.py new file mode 100644 index 00000000..c21e7e75 --- /dev/null +++ b/tests/test_showdiffraction_crystal.py @@ -0,0 +1,376 @@ +import math + +import numpy as np +import pytest + +from quantem.widget import Phase + +# Gold: FCC, a = 4.078 Å. d_hkl and plane angles are analytic for a cubic cell. +AU_A = 4.078 + + +def test_cubic_dspacing_and_plane_angles(): + au = Phase.from_cubic("Au", AU_A, absences="fcc") + # d_hkl = a / sqrt(h^2+k^2+l^2) + assert au.d_spacing((1, 1, 1)) == pytest.approx(AU_A / np.sqrt(3), abs=1e-4) + assert au.d_spacing((2, 0, 0)) == pytest.approx(AU_A / 2, abs=1e-4) + assert au.d_spacing((2, 2, 0)) == pytest.approx(AU_A / np.sqrt(8), abs=1e-4) + assert au.d_spacing((3, 1, 1)) == pytest.approx(AU_A / np.sqrt(11), abs=1e-4) + # (111)^(200) = 54.7356°, (200)^(020) = 90° + assert au.plane_angle((1, 1, 1), (2, 0, 0)) == pytest.approx(54.7356, abs=1e-2) + assert au.plane_angle((2, 0, 0), (0, 2, 0)) == pytest.approx(90.0, abs=1e-4) + + +def test_reflections_and_match_d(): + au = Phase.from_cubic("Au", AU_A, absences="fcc") + refls = au.reflections(d_min=1.0, max_index=4) + ds = [r["d"] for r in refls] + assert ds == sorted(ds, reverse=True) # largest d first + assert all(d >= 1.0 for d in ds) + # innermost FCC family is {111} + assert refls[0]["hkl_str"] == "111" + assert refls[0]["d"] == pytest.approx(AU_A / np.sqrt(3), abs=1e-4) + # no forbidden families present + assert all(au.is_allowed(r["hkl"]) for r in refls) + + # match_d returns ranked candidates + d111 = AU_A / np.sqrt(3) + cands = au.match_d(d111 * 1.005, tol=0.02) # measured 0.5% high + assert cands, "expected at least one candidate within tolerance" + assert cands[0]["hkl_str"] == "111" + assert cands[0]["d_error"] == pytest.approx(0.005, abs=2e-3) # fractional + # a d-spacing far from any reflection returns nothing within a tight tol + assert au.match_d(3.5, tol=0.01) == [] + + # default reflections reach high-index families + fe3o4 = Phase.from_cubic("Fe3O4", 8.3963, absences="fcc") + d800 = 8.3963 / np.sqrt(64) + by_hkl = {r["hkl_str"]: r for r in fe3o4.reflections(d_min=0.9)} + assert "800" in by_hkl + assert by_hkl["800"]["d"] == pytest.approx(d800, abs=1e-4) + assert "840" in by_hkl + cands = fe3o4.match_d(d800, tol=0.01) + assert cands and cands[0]["hkl_str"] == "800" + + # non-cubic phases keep h, k, l order; cubic phases use family labels + graphite = Phase("graphite", 2.4640, 2.4640, 6.7110, 90.0, 90.0, 120.0, absences="hcp") + by_d = graphite.reflections(d_min=2.0) + assert by_d[0]["hkl_str"] == "002" + assert by_d[0]["d"] == pytest.approx(6.7110 / 2, abs=1e-3) + rutile = Phase("rutile", 4.5940, 4.5940, 2.9589) + labels = {reflection["hkl_str"] for reflection in rutile.reflections(d_min=2.8)} + assert "001" in labels + assert "100" in labels + + +def test_match_d_reference_relative_error(): + # match_d and match_candidate agree at the tolerance boundary + from quantem.widget.showdiffraction import match_candidate + + au = Phase.from_cubic("Au", AU_A, absences="fcc") + d_ref = au.d_spacing((1, 1, 1)) + # 3.05% above the reference line: rejected in both paths + d_obs = d_ref * 1.0305 + assert au.match_d(d_obs, tol=0.03) == [] + report = match_candidate([d_obs], [{"d": d_ref, "intensity": 100.0}], tol=0.03) + assert report["matched"] == 0 + # 2.95% above: accepted in both paths with the same error + d_obs = d_ref * 1.0295 + cands = au.match_d(d_obs, tol=0.03) + assert cands and cands[0]["hkl_str"] == "111" + assert cands[0]["d_error"] == pytest.approx(0.0295, abs=1e-4) + report = match_candidate([d_obs], [{"d": d_ref, "intensity": 100.0}], tol=0.03) + assert report["matched"] == 1 + assert report["mean_err"] == pytest.approx(0.0295, abs=1e-4) + + +def test_reflections_degenerate_families(): + # distinct cubic families at one d keep both labels + si = Phase.from_cubic("Si", 5.4310, absences="diamond") + d27 = 5.4310 / math.sqrt(27) + refls = [r for r in si.reflections(d_min=1.0) if abs(r["d"] - d27) < 1e-3] + assert len(refls) == 1 + assert refls[0]["hkl_str"] == "511/333" + assert refls[0]["hkl"] == (5, 1, 1) + assert refls[0]["multiplicity"] == 32 + cands = si.match_d(d27, tol=0.005) + assert cands and cands[0]["hkl_str"] == "511/333" + # hexagonal family members keep a single conventional label + from quantem.widget import library_phase + + graphite = library_phase("C (graphite)") + labels = {r["hkl_str"] for r in graphite.reflections(d_min=1.2)} + assert "100" in labels + assert not any("010" in label for label in labels) + + +def test_from_dspacings_and_lattice_validation(): + # reference table keeps its own hkl labels and matches back + ref = Phase.from_dspacings("Sample", [(2.355, "111"), (2.039, "200"), (1.442, "220")]) + refls = ref.reflections() + assert [r["hkl_str"] for r in refls] == ["111", "200", "220"] + cands = ref.match_d(2.04, tol=0.02) + assert cands[0]["hkl_str"] == "200" + + # optional intensity per line carries through reflections and matches + ref = Phase.from_dspacings("X", [(2.53, "311", 100), (1.48, "440", 40), (2.10, "400")]) + by_hkl = {r["hkl_str"]: r for r in ref.reflections()} + assert by_hkl["311"]["intensity"] == 100 + assert by_hkl["400"]["intensity"] is None + assert ref.match_d(2.53, tol=0.02)[0]["intensity"] == 100 + + # a non-positive lattice constant is rejected + with pytest.raises(ValueError): + Phase.from_cubic("bad", 0.0) + with pytest.raises(ValueError): + Phase.from_cubic("bad", -1.0) + + +def test_degenerate_cell_rejected(): + # angles outside (0, 180) are invalid + for angles in ({"gamma": 180.0}, {"alpha": 0.0}, {"beta": -30.0}, {"gamma": 360.0}): + with pytest.raises(ValueError): + Phase("bad", 4.0, 4.0, 4.0, **angles) + # in-range angles with a negative metric determinant are invalid too + with pytest.raises(ValueError): + Phase("bad", 4.0, 4.0, 4.0, 150.0, 150.0, 150.0) + + +def test_reflections_bounded(): + # absurd d_min: the 0.2 A floor and index cap keep enumeration finite + import time + + au = Phase.from_cubic("Au", AU_A, absences="fcc") + t0 = time.perf_counter() + refls = au.reflections(d_min=1e-6) + assert time.perf_counter() - t0 < 2.0 + assert refls + assert min(r["d"] for r in refls) >= 0.2 + # an explicit max_index above the cap is clamped + t0 = time.perf_counter() + au.reflections(d_min=0.5, max_index=500) + assert time.perf_counter() - t0 < 2.0 + + +def test_structure_absence_rules(): + cases = [ + # fcc: all-even or all-odd allowed; mixed parity forbidden + ( + Phase.from_cubic("Au", AU_A, absences="fcc"), + [(1, 1, 1), (2, 0, 0), (2, 2, 0)], + [(1, 0, 0), (1, 1, 0), (2, 1, 0)], + ), + # bcc: h+k+l even allowed + ( + Phase.from_cubic("Fe", 2.866, absences="bcc"), + [(1, 1, 0), (2, 0, 0), (2, 1, 1)], + [(1, 0, 0), (1, 1, 1)], + ), + # diamond: all-odd allowed; all-even needs h+k+l divisible by 4 + ( + Phase.from_cubic("Si", 5.4310, absences="diamond"), + [(1, 1, 1), (2, 2, 0), (3, 1, 1), (4, 0, 0)], + [(2, 0, 0), (2, 2, 2), (4, 2, 0)], + ), + # hcp (P6_3/mmc): h+2k = 3n with l odd are absent + ( + Phase("Ti", 2.9505, 2.9505, 4.6826, 90.0, 90.0, 120.0, absences="hcp"), + [(0, 0, 2), (1, 0, 0), (1, 0, 1), (1, 1, 2)], + [(0, 0, 1), (1, 1, 1)], + ), + # wurtzite: same extinctions as hcp (h+2k = 3n, l odd) + ( + Phase("ZnO", 3.2495, 3.2495, 5.2069, 90.0, 90.0, 120.0, absences="wurtzite"), + [(0, 0, 2), (1, 0, 0), (1, 0, 1), (1, 1, 0), (1, 0, 3)], + [(0, 0, 1), (1, 1, 1), (3, 0, 1)], + ), + # rhombohedral R-3m (hex setting): -h + k + l = 3n + ( + Phase("Bi", 4.5460, 4.5460, 11.8620, 90.0, 90.0, 120.0, absences="rhombohedral"), + [(0, 0, 3), (1, 0, 4), (1, 1, 0), (0, 1, 2)], + [(1, 0, 0), (0, 1, 1)], + ), + # rhombohedral-c R-3c/R3c: c glide zeros 00l, h0l, 0kl with odd l + ( + Phase("Fe2O3", 5.0356, 5.0356, 13.7489, 90.0, 90.0, 120.0, absences="rhombohedral-c"), + [(0, 1, 2), (1, 0, 4), (1, 1, 0), (3, 0, 0)], + [(0, 0, 3), (1, 0, 1), (0, 1, 5)], + ), + # spinel + ( + Phase.from_cubic("Fe3O4", 8.3967, absences="spinel"), + [(1, 1, 1), (2, 2, 0), (3, 1, 1), (2, 2, 2), (4, 0, 0), (4, 2, 2), (4, 4, 0), (6, 2, 0)], + [(2, 0, 0), (4, 2, 0), (6, 0, 0), (6, 4, 0), (2, 1, 0)], + ), + # i41amd (anatase / β-Sn) + ( + Phase("TiO2", 3.7852, 3.7852, 9.5139, absences="i41amd"), + [(1, 0, 1), (0, 0, 4), (1, 0, 3), (1, 1, 2), (2, 0, 0), (2, 1, 1), (2, 2, 0), (2, 0, 4), (1, 1, 6)], + [(0, 0, 2), (1, 1, 0), (2, 2, 2), (4, 0, 2), (1, 0, 0)], + ), + # rutile + ( + Phase("TiO2", 4.5940, 4.5940, 2.9589, absences="rutile"), + [(1, 1, 0), (1, 0, 1), (2, 0, 0), (1, 1, 1), (2, 1, 0), (0, 0, 2)], + [(1, 0, 0), (0, 0, 1), (0, 1, 2), (1, 0, 2)], + ), + # bixbyite + ( + Phase.from_cubic("Y2O3", 10.6040, absences="bixbyite"), + [(2, 1, 1), (2, 2, 2), (4, 0, 0), (4, 1, 1), (3, 3, 2), (4, 4, 0)], + [(1, 1, 0), (3, 1, 0), (1, 0, 1), (2, 1, 0)], + ), + # cuprite + ( + Phase.from_cubic("Cu2O", 4.2696, absences="cuprite"), + [(1, 1, 0), (1, 1, 1), (2, 0, 0), (2, 1, 1), (3, 1, 1), (2, 2, 2)], + [(1, 0, 0), (2, 1, 0), (2, 2, 1), (3, 0, 0)], + ), + ] + for phase, allowed, forbidden in cases: + for hkl in allowed: + assert phase.is_allowed(hkl), (phase.absences, hkl) + for hkl in forbidden: + assert not phase.is_allowed(hkl), (phase.absences, hkl) + + +def test_phase_library(): + from quantem.widget import library_phase + from quantem.widget.showdiffraction import PHASE_LIBRARY + + # core names + per-phase metadata and d-spacings + assert {"Au", "Al", "Si", "α-Fe", "MgO", "Fe3O4"} <= set(PHASE_LIBRARY) + au = library_phase("Au") + assert au.name == "Au" and au.absences == "fcc" + assert au.d_spacing((1, 1, 1)) == pytest.approx(4.0782 / math.sqrt(3), abs=1e-4) + si = library_phase("Si") + assert si.absences == "diamond" + assert si.d_spacing((3, 1, 1)) == pytest.approx(5.4311 / math.sqrt(11), abs=1e-4) + fe = library_phase("α-Fe") + assert fe.absences == "bcc" + + # unknown name raises + with pytest.raises(ValueError, match="Kryptonite"): + library_phase("Kryptonite") + + # hexagonal d-spacings and absence + ti = library_phase("Ti") + assert ti.d_spacing((1, 0, 0)) == pytest.approx(2.9500 * math.sqrt(3) / 2, abs=1e-3) + graphite = library_phase("C (graphite)") + assert graphite.d_spacing((0, 0, 2)) == pytest.approx(6.7110 / 2, abs=1e-3) + assert not graphite.is_allowed((0, 0, 1)) + + # expanded coverage (>= 40 entries) + assert len(PHASE_LIBRARY) >= 40 + expected = { + "Pd", + "Pb", + "Mo", + "Ta", + "NaCl", + "TiN", + "CaF2", + "CeO2", + "GaAs", + "ZnS", + "3C-SiC", + "SrTiO3", + "MgAl2O4", + "Ti", + "Mg", + "Zn", + "C (graphite)", + } + assert expected <= set(PHASE_LIBRARY) + gaas = library_phase("GaAs") + assert gaas.d_spacing((1, 1, 1)) == pytest.approx(5.6533 / math.sqrt(3), abs=1e-4) + srtio3 = library_phase("SrTiO3") + assert srtio3.is_allowed((1, 0, 0)) # simple cubic: no centering extinctions + + # tetragonal + newer entries (>= 90 entries) + assert len(PHASE_LIBRARY) >= 90 + anatase = library_phase("TiO2 (anatase)") + assert anatase.d_spacing((1, 0, 1)) == pytest.approx(3.517, abs=5e-3) + rutile = library_phase("TiO2 (rutile)") + assert rutile.d_spacing((1, 1, 0)) == pytest.approx(4.5940 / math.sqrt(2), abs=1e-3) + hematite = library_phase("α-Fe2O3 (hematite)") + assert hematite.d_spacing((1, 0, 4)) == pytest.approx(2.700, abs=5e-3) + zno = library_phase("ZnO") + assert zno.d_spacing((1, 0, 1)) == pytest.approx(2.476, abs=5e-3) + lab6 = library_phase("LaB6") + assert lab6.is_allowed((1, 0, 0)) + assert lab6.d_spacing((1, 1, 0)) == pytest.approx(4.1568 / math.sqrt(2), abs=1e-3) + + # d-spacing spot checks for later additions against their cited cells + checks = { + "CaO": ((2, 0, 0), 2.4054), + "ZrC": ((2, 0, 0), 2.3502), + "CuI": ((1, 1, 1), 3.5005), + "Co3O4": ((3, 1, 1), 2.4368), + "NiFe2O4": ((3, 1, 1), 2.5143), + "BaTiO3": ((1, 1, 0), 2.8242), + "WC": ((0, 0, 1), 2.8377), + "TiB2": ((1, 0, 1), 2.0359), + "ZnS (wurtzite)": ((1, 0, 0), 3.3105), + "Bi": ((0, 1, 2), 3.2802), + "Sb": ((0, 1, 2), 3.1113), + } + for name, (hkl, d_ref) in checks.items(): + phase = library_phase(name) + assert phase.is_allowed(hkl) + assert abs(phase.d_spacing(hkl) - d_ref) < 0.002 + + +def test_library_absence_reassignments(): + from quantem.widget import library_phase + from quantem.widget.showdiffraction import PHASE_LIBRARY + + spinels = ["Fe3O4", "γ-Fe2O3", "MgAl2O4", "Co3O4", "CoFe2O4", "ZnFe2O4", "γ-Al2O3"] + for name in spinels + ["NiFe2O4"]: + assert PHASE_LIBRARY[name]["absences"] == "spinel" + + fe3o4 = library_phase("Fe3O4") + by_hkl = {r["hkl_str"]: r["d"] for r in fe3o4.reflections(d_min=1.0)} + assert "200" not in by_hkl and "420" not in by_hkl + assert by_hkl["111"] == pytest.approx(4.848, abs=1e-3) + assert by_hkl["220"] == pytest.approx(2.969, abs=1e-3) + assert by_hkl["311"] == pytest.approx(2.532, abs=1e-3) + assert by_hkl["222"] == pytest.approx(2.424, abs=1e-3) + + anatase = library_phase("TiO2 (anatase)") + assert anatase.absences == "i41amd" + by_hkl = {r["hkl_str"]: r["d"] for r in anatase.reflections(d_min=1.5)} + assert "002" not in by_hkl and "110" not in by_hkl + assert by_hkl["101"] == pytest.approx(3.517, abs=1e-3) + + rutile = library_phase("TiO2 (rutile)") + assert rutile.absences == "rutile" + by_hkl = {r["hkl_str"]: r["d"] for r in rutile.reflections(d_min=1.0)} + assert "100" not in by_hkl and "001" not in by_hkl + assert by_hkl["110"] == pytest.approx(3.248, abs=1e-3) + + sno2 = library_phase("SnO2") + assert sno2.absences == "rutile" + by_hkl = {r["hkl_str"]: r["d"] for r in sno2.reflections(d_min=1.0)} + assert "100" not in by_hkl and "001" not in by_hkl + assert by_hkl["110"] == pytest.approx(3.350, abs=1e-3) + + sn = library_phase("β-Sn") + assert sn.absences == "i41amd" + by_hkl = {r["hkl_str"]: r["d"] for r in sn.reflections(d_min=1.0)} + assert "110" not in by_hkl + assert by_hkl["200"] == pytest.approx(2.916, abs=1e-3) + assert by_hkl["101"] == pytest.approx(2.793, abs=1e-3) + + for name, d211 in [("Y2O3", 4.330), ("In2O3", 4.130)]: + oxide = library_phase(name) + assert oxide.absences == "bixbyite" + by_hkl = {r["hkl_str"]: r["d"] for r in oxide.reflections(d_min=1.5)} + assert "110" not in by_hkl and "310" not in by_hkl + assert by_hkl["211"] == pytest.approx(d211, abs=2e-3) + + cu2o = library_phase("Cu2O") + assert cu2o.absences == "cuprite" + by_hkl = {r["hkl_str"]: r["d"] for r in cu2o.reflections(d_min=1.0)} + assert "100" not in by_hkl and "210" not in by_hkl + assert by_hkl["110"] == pytest.approx(3.020, abs=2e-3) diff --git a/tests/test_showdiffraction_matching.py b/tests/test_showdiffraction_matching.py new file mode 100644 index 00000000..64e208ae --- /dev/null +++ b/tests/test_showdiffraction_matching.py @@ -0,0 +1,110 @@ +import pytest + +from quantem.widget.showdiffraction import match_candidate, match_sort_key + + +def _lines(entries): + return [{"d": d, "i_rel": i} for d, i in entries] + + +def test_match_candidate_matching(): + # perfect match: every observed line pairs to a reference + refs = _lines([(3.0, 100), (2.0, 80), (1.5, 60), (1.2, 40)]) + sc = match_candidate([3.0, 2.0, 1.5, 1.2], refs, tol=0.03) + assert sc["matched"] == 4 and sc["n_obs"] == 4 + assert sc["mean_err"] == pytest.approx(0.0, abs=1e-9) + assert sc["n_missing_strong"] == 0 + assert all(j is not None for _, j in sc["assignments"]) + + # each reference line is used at most once + sc = match_candidate([2.0, 2.01], _lines([(2.0, 100)]), tol=0.03) + assert sc["matched"] == 1 + assert [ref for _, ref in sc["assignments"]].count(0) == 1 + + # optimal assignment: both lines pair even when greedy would strand one + refs = _lines([(2.001, 100), (1.985, 80)]) + sc = match_candidate([2.0, 2.01], refs, tol=0.01) + assert sc["matched"] == 2 + assert all(j is not None for _, j in sc["assignments"]) + # crossed pairing also matches both lines but with larger error + refs = _lines([(2.0, 100), (2.01, 80)]) + sc = match_candidate([2.0, 2.01], refs, tol=0.03) + assert sc["matched"] == 2 + assert dict(sc["assignments"]) == {0: 0, 1: 1} + assert sc["mean_err"] == pytest.approx(0.0, abs=1e-9) + + # reference cards may carry "intensity" instead of "i_rel" + refs = [ + {"d": 3.0, "intensity": 100.0}, + {"d": 2.0, "intensity": 90.0}, + {"d": 1.5, "intensity": 80.0}, + ] + sc = match_candidate([3.0, 1.5], refs, tol=0.03) + assert sc["matched"] == 2 + assert sc["n_missing_strong"] == 1 # the strong d=2.0 line is unexplained + + +def test_match_sort_key_ranking(): + # absolute calibration: a uniform 5% scale breaks every match + refs = _lines([(3.0, 100), (2.0, 80), (1.5, 60), (1.2, 40)]) + perfect = match_candidate([3.0, 2.0, 1.5, 1.2], refs, tol=0.03) + off = match_candidate([3.0 * 1.05, 2.0 * 1.05, 1.5 * 1.05, 1.2 * 1.05], refs, tol=0.03) + assert off["matched"] == 0 + assert match_sort_key(perfect) < match_sort_key(off) + + # a phase that explains every ring ranks above one that leaves a ring unexplained + obs = [3.0, 2.0, 1.5, 1.05] + explains_all = match_candidate(obs, _lines([(3.0, 100), (2.0, 80), (1.5, 60), (1.05, 40)])) + leaves_one = match_candidate(obs, _lines([(3.0, 100), (2.0, 80), (1.5, 60), (1.2, 40)])) + assert explains_all["matched"] == 4 and leaves_one["matched"] == 3 + assert match_sort_key(explains_all) < match_sort_key(leaves_one) + + # a missing strong line is penalized more than a missing weak line + strong = _lines([(3.0, 100), (2.0, 90), (1.5, 80)]) + weak = _lines([(3.0, 100), (2.0, 10), (1.5, 80)]) + obs = [3.0, 1.5] # the d=2.0 line is inside the observed range but unmatched + sc_strong = match_candidate(obs, strong, tol=0.03) + sc_weak = match_candidate(obs, weak, tol=0.03) + assert sc_strong["n_missing_strong"] == 1 and sc_weak["n_missing_strong"] == 0 + assert match_sort_key(sc_weak) < match_sort_key(sc_strong) + + # cards with no intensity data, and all-zero intensities, both read unknown + sc = match_candidate([3.0, 1.5], [{"d": 3.0}, {"d": 2.0}, {"d": 1.5}], tol=0.03) + assert sc["matched"] == 2 + assert sc["n_missing_strong"] is None + sc_zero = match_candidate([3.0, 1.5], _lines([(3.0, 0), (2.0, 0), (1.5, 0)]), tol=0.03) + assert sc_zero["n_missing_strong"] is None + # unknown orders like zero in the sort key + with_data = match_candidate([3.0, 1.5], _lines([(3.0, 100), (2.0, 10), (1.5, 80)]), tol=0.03) + assert match_sort_key(with_data) == match_sort_key(sc_zero) + + +def test_match_candidate_non_finite(): + # non-finite observed or reference d-spacings are dropped, not fatal + refs = _lines([(3.0, 100), (2.3, 80)]) + sc = match_candidate([float("inf"), 2.3], refs, tol=0.03) + assert sc["matched"] == 1 and sc["n_obs"] == 1 + sc = match_candidate([float("nan"), 2.3], refs, tol=0.03) + assert sc["matched"] == 1 and sc["n_obs"] == 1 + sc = match_candidate([2.3], _lines([(float("inf"), 100), (2.3, 80)]), tol=0.03) + assert sc["matched"] == 1 + assert sc["n_missing_strong"] == 0 + + +def test_match_report_shape(): + # plain report keys, even against a dense reference card + dense = _lines([(1.0 + 0.01 * i, 20) for i in range(200)]) # 200 lines, d 1.0-2.99 + sc = match_candidate([2.5, 1.7], dense, tol=0.03) + assert sc["matched"] == 2 + assert set(sc) == { + "matched", + "n_obs", + "mean_err", + "n_missing_strong", + "assignments", + } + + # no reference lines: nothing matches, empty assignments + sc = match_candidate([2.5, 1.7], [], tol=0.03) + assert sc["matched"] == 0 + assert sc["assignments"] == [] diff --git a/tests/test_showeds.py b/tests/test_showeds.py index 0675bf18..40f5a505 100644 --- a/tests/test_showeds.py +++ b/tests/test_showeds.py @@ -12,6 +12,7 @@ detect_elements, detect_peaks, detector_fwhm_kev, + all_eds_lines, eds_line_hints, load_spectrum_image_sidecar, match_elements, @@ -1324,3 +1325,16 @@ def test_detect_peaks_rejects_single_channel_spike(): spectrum[700] += 50000.0 assert detect_peaks(spectrum, energy) == [] + + +def test_line_hints_cap_keeps_major_lines_across_range(): + hints = eds_line_hints(0.1, 12.0) + energies = [h["energy_keV"] for h in hints] + assert energies == sorted(energies) + assert any(abs(e - 6.404) < 0.01 for e in energies) # Fe Ka1 + assert any(abs(e - 8.048) < 0.02 for e in energies) # Cu Ka1 + assert any(abs(e - 9.713) < 0.02 for e in energies) # Au La1 + kept = {(h["element"], h["line"]) for h in hints} + dropped_satellites = [h for h in all_eds_lines() + if 0.1 <= h["energy_keV"] <= 12.0 and (h["element"], h["line"]) not in kept] + assert all(h["line"] not in {"Ka1", "La1", "Ma"} for h in dropped_satellites) diff --git a/tests/test_tutorial_data.py b/tests/test_tutorial_data.py index a9441217..6f277853 100644 --- a/tests/test_tutorial_data.py +++ b/tests/test_tutorial_data.py @@ -251,3 +251,65 @@ def fail_download(*args, **kwargs): assert "offline" in str(exc) else: raise AssertionError("expected real-data tutorial loader to raise when download fails") + + +def test_showdiffraction_fe3o4_uses_widget_tutorial_source(tmp_path, monkeypatch): + root = tmp_path / "hf-cache" + data_dir = root / "widget-tutorials" / "showdiffraction" / "fe3o4-saed" / "small" + data_dir.mkdir(parents=True) + np.save(data_dir / "data.npy", np.arange(16, dtype=np.float32).reshape(4, 4)) + (data_dir / "meta.json").write_text(json.dumps({"name": "fe3o4-saed"})) + calls = [] + + def fake_snapshot_download(**kwargs): + calls.append(kwargs) + return root + + monkeypatch.setattr(tutorials, "snapshot_download", fake_snapshot_download, raising=False) + + pattern = datasets.showdiffraction_fe3o4(verbose=False) + + assert pattern.shape == (4, 4) + assert pattern.dtype == np.float32 + assert calls == [ + { + "repo_id": "bobleesj/quantem-data", + "repo_type": "dataset", + "allow_patterns": ["widget-tutorials/showdiffraction/fe3o4-saed/small/*"], + "force_download": False, + } + ] + + +def test_showdiffraction_fe3o4_falls_back_to_packaged_file(tmp_path, monkeypatch): + # a failed download, and a snapshot without data.npy, both use the packaged copy + packaged = tmp_path / "fe3o4_saed_512.npy" + np.save(packaged, np.ones((6, 6), dtype=np.float32)) + monkeypatch.setattr(tutorials, "_FE3O4_PACKAGED_PATH", packaged) + + def fail_download(*args, **kwargs): + raise OSError("offline") + + monkeypatch.setattr(tutorials, "snapshot_download", fail_download, raising=False) + assert tutorials.showdiffraction_fe3o4(verbose=False).shape == (6, 6) + + root = tmp_path / "hf-cache" + (root / "widget-tutorials" / "showdiffraction" / "fe3o4-saed" / "small").mkdir(parents=True) + monkeypatch.setattr(tutorials, "snapshot_download", lambda **kwargs: root, raising=False) + assert tutorials.showdiffraction_fe3o4(verbose=False).shape == (6, 6) + + +def test_showdiffraction_fe3o4_raises_without_usable_source(tmp_path, monkeypatch): + def fail_download(*args, **kwargs): + raise OSError("offline") + + monkeypatch.setattr(tutorials, "snapshot_download", fail_download, raising=False) + monkeypatch.setattr(tutorials, "_FE3O4_PACKAGED_PATH", tmp_path / "missing.npy") + + for kwargs in ({"allow_fallback": False}, {}): + try: + tutorials.showdiffraction_fe3o4(verbose=False, **kwargs) + except OSError as exc: + assert "offline" in str(exc) + else: + raise AssertionError("expected diffraction tutorial loader to raise") diff --git a/tests/test_widget_showdiffraction.py b/tests/test_widget_showdiffraction.py deleted file mode 100644 index 8a6cd25d..00000000 --- a/tests/test_widget_showdiffraction.py +++ /dev/null @@ -1,477 +0,0 @@ -import json -from collections import namedtuple - -import numpy as np -import pytest -import torch - -from quantem.widget import ShowDiffraction - -LoadResult = namedtuple("LoadResult", ("data", "metadata")) - - -def test_showdiffraction_2d_single_frame(): - dp = np.random.rand(32, 48).astype(np.float32) - w = ShowDiffraction(dp, verbose=False) - assert w.n_frames == 1 - assert w.frame_idx == 0 - assert (w.det_rows, w.det_cols) == (32, 48) - assert w.detector_shape == (32, 48) - assert len(w.frame_bytes) == 32 * 48 * 4 - assert w.dp_scale_mode == "log" - assert w.panel_width_px == 384 - - -def test_showdiffraction_panel_width_hint(): - dp = np.random.rand(32, 48).astype(np.float32) - w = ShowDiffraction(dp, panel_width_px=480, verbose=False) - assert w.panel_width_px == 480 - - -def test_showdiffraction_3d_stack(): - data = np.random.rand(5, 16, 16).astype(np.float32) - w = ShowDiffraction(data, verbose=False) - assert w.n_frames == 5 - assert (w.det_rows, w.det_cols) == (16, 16) - - -def test_showdiffraction_frame_idx_changes_frame(): - data = np.zeros((4, 8, 8), dtype=np.float32) - for i in range(4): - data[i] = float(i + 1) # each frame a distinct constant - w = ShowDiffraction(data, verbose=False) - assert w.n_frames == 4 - f0 = np.frombuffer(w.frame_bytes, dtype=np.float32).copy() - assert np.allclose(f0, 1.0) - w.frame_idx = 2 - f2 = np.frombuffer(w.frame_bytes, dtype=np.float32) - assert np.allclose(f2, 3.0) - # out-of-range frame index is clamped into [0, n_frames) - w.frame_idx = 99 - assert w.frame_idx == 3 - - -def test_showdiffraction_offline_frames_baked(): - data = np.zeros((4, 8, 8), dtype=np.float32) - for i in range(4): - data[i] = float(i + 1) - frame_len = 8 * 8 - # offline multi-frame: whole stack baked so the kernel-less HTML can scrub it - w = ShowDiffraction(data, offline=True, verbose=False) - assert len(w.offline_frames) == 4 * frame_len * 4 - baked = np.frombuffer(w.offline_frames, dtype=np.float32).reshape(4, 8, 8) - assert np.allclose(baked[2], 3.0) - # live widget stays empty (frames stream through frame_bytes per frame) - live = ShowDiffraction(data, offline=False, verbose=False) - assert live.offline_frames == b"" - # toggling offline bakes / clears - live.offline = True - assert len(live.offline_frames) == 4 * frame_len * 4 - live.offline = False - assert live.offline_frames == b"" - # single pattern never bakes: nothing to scrub - single = ShowDiffraction(np.ones((8, 8), dtype=np.float32), offline=True, verbose=False) - assert single.offline_frames == b"" - - -def test_showdiffraction_4d_raises(): - with pytest.raises(ValueError, match="Show4DSTEM"): - ShowDiffraction(np.random.rand(4, 4, 16, 16).astype(np.float32), verbose=False) - - -def test_showdiffraction_wrong_ndim_raises(): - with pytest.raises(ValueError, match="Expected a 2D or 3D"): - ShowDiffraction(np.zeros((4,), dtype=np.float32), verbose=False) - with pytest.raises(ValueError, match="Expected a 2D or 3D"): - ShowDiffraction(np.zeros((2, 2, 4, 4, 4), dtype=np.float32), verbose=False) - - -def test_showdiffraction_auto_detect_center(): - data = np.zeros((3, 7, 7), dtype=np.float32) - for i in range(7): - for j in range(7): - if np.sqrt((i - 3) ** 2 + (j - 3) ** 2) <= 1.5: - data[:, i, j] = 100.0 - w = ShowDiffraction(data, verbose=False) - assert abs(w.center_row - 3.0) < 0.5 - assert abs(w.center_col - 3.0) < 0.5 - assert w.bf_radius > 0 - assert w.auto_detect_center() is w - - -def test_showdiffraction_manual_center(): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction(data, center=(5.0, 6.0), bf_radius=3.0, verbose=False) - assert w.center_row == 5.0 - assert w.center_col == 6.0 - assert w.bf_radius == 3.0 - w.set_center(7.0, 8.0) - assert (w.center_row, w.center_col) == (7.0, 8.0) - assert w.center_mode == "manual" - - -def test_showdiffraction_add_spot_calibrated(): - data = np.random.rand(32, 32).astype(np.float32) - w = ShowDiffraction( - data, k_pixel_size=0.1, spot_refine=False, center=(16, 16), bf_radius=5, verbose=False - ) - w.add_spot(16, 26) - spot = w.spots[0] - assert spot["id"] == 1 - assert abs(spot["r_pixels"] - 10.0) < 0.01 - assert abs(spot["g_magnitude"] - 1.0) < 0.01 - assert abs(spot["d_spacing"] - 1.0) < 0.01 - - -def test_showdiffraction_add_spot_uncalibrated(): - data = np.random.rand(32, 32).astype(np.float32) - w = ShowDiffraction(data, center=(16, 16), bf_radius=5, verbose=False) - w.add_spot(16, 26) - assert w.spots[0]["d_spacing"] is None - assert w.spots[0]["g_magnitude"] is None - - -def test_showdiffraction_spot_at_center(): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction( - data, k_pixel_size=0.1, spot_refine=False, center=(8, 8), bf_radius=3, verbose=False - ) - w.add_spot(8, 8) - assert w.spots[0]["r_pixels"] == pytest.approx(0.0) - assert w.spots[0]["d_spacing"] is None - - -def test_showdiffraction_snap_to_peak(): - data = np.zeros((16, 16), dtype=np.float32) - data[5, 8] = 100.0 - w = ShowDiffraction( - data, snap_enabled=True, spot_refine=False, snap_radius=3, - center=(8, 8), bf_radius=3, verbose=False, - ) - w.add_spot(6, 7) - assert w.spots[0]["row"] == 5.0 - assert w.spots[0]["col"] == 8.0 - assert w.spots[0]["raw_row"] == 6.0 - - -def test_showdiffraction_undo_clear(): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction(data, center=(8, 8), bf_radius=3, verbose=False) - w.add_spot(5, 5).add_spot(10, 10) - assert len(w.spots) == 2 - w.undo_spot() - assert len(w.spots) == 1 - w.clear_spots() - assert len(w.spots) == 0 - w.undo_spot() - assert len(w.spots) == 0 - - -def test_showdiffraction_remove_spot(): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction(data, center=(8, 8), bf_radius=3, verbose=False) - w.add_spot(5, 5).add_spot(10, 10) - sid = w.spots[0]["id"] - w.remove_spot(sid) - assert len(w.spots) == 1 - assert all(s["id"] != sid for s in w.spots) - - -def test_showdiffraction_state_dict_roundtrip(): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction(data, center=(5.0, 6.0), bf_radius=3.0, k_pixel_size=0.1, verbose=False) - w.dp_scale_mode = "linear" - w.dp_colormap = "viridis" - w.snap_enabled = True - w.add_spot(8, 8) - sd = w.state_dict() - assert sd["dp_scale_mode"] == "linear" - assert sd["dp_colormap"] == "viridis" - assert sd["center_row"] == 5.0 - assert sd["k_pixel_size"] == pytest.approx(0.1) - assert sd["snap_enabled"] is True - assert "frame_idx" in sd - assert len(sd["spots"]) == 1 - w2 = ShowDiffraction(data, state=sd, verbose=False) - assert w2.dp_scale_mode == "linear" - assert w2.dp_colormap == "viridis" - assert w2.bf_radius == 3.0 - assert w2.snap_enabled is True - assert len(w2.spots) == 1 - - -def test_showdiffraction_ui_mode_presets_and_overrides(): - data = np.random.rand(16, 16).astype(np.float32) - - presentation = ShowDiffraction(data, ui_mode="presentation", verbose=False) - assert presentation.show_title is True - assert presentation.show_controls is True - assert presentation.controls_collapsed is True - assert presentation.show_stats is False - - report = ShowDiffraction(data, ui_mode="report", verbose=False) - assert report.show_title is True - assert report.show_controls is False - assert report.controls_collapsed is False - assert report.show_stats is False - - minimal = ShowDiffraction(data, ui_mode="minimal", verbose=False) - assert minimal.show_title is False - assert minimal.show_controls is False - assert minimal.controls_collapsed is False - assert minimal.show_stats is False - - override = ShowDiffraction( - data, - ui_mode="minimal", - show_title=True, - show_controls=True, - controls_collapsed=True, - show_stats=True, - verbose=False, - ) - assert override.show_title is True - assert override.show_controls is True - assert override.controls_collapsed is True - assert override.show_stats is True - assert override.expand_controls() is override - assert override.controls_collapsed is False - assert override.collapse_controls() is override - assert override.controls_collapsed is True - assert override.toggle_controls() is override - assert override.controls_collapsed is False - - -def test_showdiffraction_save_load_file(tmp_path): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction(data, verbose=False) - w.dp_colormap = "viridis" - path = tmp_path / "diff_state.json" - w.save(str(path)) - saved = json.loads(path.read_text()) - assert saved["metadata_version"] == "1.0" - assert saved["widget_name"] == "ShowDiffraction" - assert "widget_version" in saved - assert saved["state"]["dp_colormap"] == "viridis" - w2 = ShowDiffraction(data, state=str(path), verbose=False) - assert w2.dp_colormap == "viridis" - - -def test_showdiffraction_summary(capsys): - data = np.random.rand(5, 16, 16).astype(np.float32) - w = ShowDiffraction(data, pixel_size=2.39, k_pixel_size=0.1, verbose=False) - w.add_spot(5, 5) - w.summary() - out = capsys.readouterr().out - assert "Frames:" in out - assert "Detector:" in out - assert "Spots:" in out - - -def test_showdiffraction_set_image(): - data = np.random.rand(32, 32).astype(np.float32) - w = ShowDiffraction(data, verbose=False) - w.add_spot(10, 10) - new_data = np.random.rand(8, 64, 64).astype(np.float32) - w.set_image(new_data) - assert w.n_frames == 8 - assert w.det_rows == 64 - assert len(w.spots) == 0 - - -def test_showdiffraction_set_image_loadresult(): - data = np.random.rand(16, 16).astype(np.float32) - w = ShowDiffraction(data, verbose=False) - result = LoadResult( - data=np.random.rand(8, 32, 32).astype(np.float32), - metadata={"pixel_size": 3.0}, - ) - w.set_image(result) - assert w.pixel_size == 3.0 - - -def test_showdiffraction_accepts_torch(): - w = ShowDiffraction(torch.rand(4, 16, 16), verbose=False) - assert w.n_frames == 4 - - -def test_showdiffraction_accepts_loadresult(): - result = LoadResult( - data=np.random.rand(4, 16, 16).astype(np.float32), - metadata={"pixel_size": 2.0}, - ) - w = ShowDiffraction(result, verbose=False) - assert w.pixel_size == 2.0 - - -def test_showdiffraction_hot_pixel_removal(): - data = np.ones((4, 32, 32), dtype=np.uint16) * 100 - data[0, 3, 5] = 65535 - w = ShowDiffraction(data, verbose=False) - assert w._get_frame(0)[3, 5] == 0 - - -def test_showdiffraction_repr(): - w = ShowDiffraction(np.random.rand(4, 16, 16).astype(np.float32), k_pixel_size=0.1, verbose=False) - r = repr(w) - assert "ShowDiffraction" in r - assert "sampling=" in r - assert "frame=" in r - - -def test_showdiffraction_free(): - w = ShowDiffraction(np.random.rand(4, 16, 16).astype(np.float32), verbose=False) - w.free() - assert not hasattr(w, "_data") - - -def _disk_dp(size=64, center=(32, 30), radius=6): - rows = np.arange(size)[:, None] - cols = np.arange(size)[None, :] - r2 = (rows - center[0]) ** 2 + (cols - center[1]) ** 2 - return np.exp(-r2 / (2 * radius**2)).astype(np.float32) - - -def test_showdiffraction_calibration_recomputes(): - w = ShowDiffraction(_disk_dp(), spot_refine=False, verbose=False) - w.set_center(32, 32) - w.add_spot(32, 42) - assert w.spots[0]["d_spacing"] is None - w.calibrate_from_ring(10.0, 2.0) # r=10 px -> d=2.0 A -> k=0.05 - assert w.k_calibrated and abs(w.k_pixel_size - 0.05) < 1e-9 - assert abs(w.spots[0]["d_spacing"] - 2.0) < 1e-4 - with pytest.raises(ValueError): - w.calibrate_from_ring(-1, 2.0) - - -def test_showdiffraction_ring_picking(): - w = ShowDiffraction(_disk_dp(), k_pixel_size=0.05, verbose=False) - w.set_center(32, 32) - w.add_ring(10.0) # g = 10*0.05 -> d = 2.0 A - assert abs(w.rings[0]["d_spacing"] - 2.0) < 1e-4 - w.add_ring(20.0) - w.undo_ring() - assert len(w.rings) == 1 - - -def _two_spot_dp(size=64, center=(32, 32), spot=(28, 44), sigma=2.0): - rows = np.arange(size)[:, None] - cols = np.arange(size)[None, :] - beam = np.exp(-((rows - center[0]) ** 2 + (cols - center[1]) ** 2) / (2 * 2.0**2)) - blob = np.exp(-((rows - spot[0]) ** 2 + (cols - spot[1]) ** 2) / (2 * sigma**2)) - return (50.0 * beam + 40.0 * blob).astype(np.float32) - - -def test_showdiffraction_gaussian_spot_refine(): - spot = (28, 44) - w = ShowDiffraction( - _two_spot_dp(spot=spot), k_pixel_size=0.05, center=(32, 32), bf_radius=3, verbose=False - ) - w.add_spot(spot[0] + 1.4, spot[1] - 1.2) # click ~2 px off the true spot - s = w.spots[0] - assert abs(s["row"] - spot[0]) < 0.5 and abs(s["col"] - spot[1]) < 0.5 # refined to the centroid - assert s["raw_row"] == pytest.approx(spot[0] + 1.4) - assert s["fit_quality"] > 0.9 - assert s["row_err"] is not None and s["d_spacing_err"] is not None and s["d_spacing_err"] >= 0 - - -def test_showdiffraction_interplanar_angle(): - w = ShowDiffraction(_disk_dp(), spot_refine=False, center=(32, 32), bf_radius=3, verbose=False) - w.set_center(32, 32) - w.add_spot(32, 42) - w.add_spot(42, 32) - # Angles are measured relative to the first spot. - assert w.spots[0]["angle_deg"] == pytest.approx(0.0, abs=1e-6) - assert w.spots[1]["angle_deg"] == pytest.approx(90.0, abs=1e-6) - - -def test_showdiffraction_calibration_provenance(): - w = ShowDiffraction(_disk_dp(), spot_refine=False, center=(32, 32), bf_radius=3, verbose=False) - w.set_center(32, 32) - assert w.calibration_source == "none" - w.calibrate_from_spot(32, 42, 2.0) # r=10 px, d=2 A -> k=0.05 - assert w.calibration_source == "from_spot" - assert w.calibration_ref_d == pytest.approx(2.0) - assert w.calibration_ref_radius == pytest.approx(10.0) - - -def test_showdiffraction_export(tmp_path): - w = ShowDiffraction( - _disk_dp(), k_pixel_size=0.05, spot_refine=False, center=(32, 32), bf_radius=3, verbose=False - ) - w.set_center(32, 32) - w.add_spot(32, 42) - w.add_ring(20.0) - - csv_text = w.export_measurements(tmp_path / "m.csv").read_text() - assert "g_inv_angstrom" in csv_text - assert csv_text.strip().count("\n") >= 2 - - payload = json.loads(w.export_measurements(tmp_path / "m.json").read_text()) - assert payload["metadata"]["calibration_source"] == "manual" - assert len(payload["measurements"]) == 2 - - -def test_showdiffraction_measurements_from_state(tmp_path): - # The saved state holds every spot and ring, so the measurement table is - # rebuildable from it alone -- no separate export file needs to be kept. - w = ShowDiffraction( - _disk_dp(), k_pixel_size=0.05, spot_refine=False, center=(32, 32), bf_radius=3, verbose=False - ) - w.set_center(32, 32) - w.add_spot(32, 42) - w.add_ring(20.0) - - state_path = tmp_path / "state.json" - w.save(state_path) - - records = ShowDiffraction.measurements_from_state(state_path) - assert [r["kind"] for r in records] == ["spot", "ring"] - assert records == w._measurement_records() - - csv_path = ShowDiffraction.measurements_from_state(state_path, tmp_path / "from_state.csv") - assert csv_path.read_text() == w.export_measurements(tmp_path / "live.csv").read_text() - - -def test_showdiffraction_center_mode_validator(): - w = ShowDiffraction(_disk_dp(), verbose=False) - w.center_mode = "manual" - assert w.center_mode == "manual" - with pytest.raises(ValueError): - w.center_mode = "midpoint" - - -def test_showdiffraction_detect_spots(): - M, cen, G = 128, (64, 64), 24.0 - rows = np.arange(M)[:, None] - cols = np.arange(M)[None, :] - def blob(r, c, a, s): - return a * np.exp(-(((rows - r) ** 2 + (cols - c) ** 2) / (2 * s * s))) - dp = blob(*cen, 300, 4) - truth = [(cen[0], cen[1] + G), (cen[0], cen[1] - G), (cen[0] + G, cen[1]), (cen[0] - G, cen[1])] - for r, c in truth: - dp = dp + blob(r, c, 40, 2.0) - dp = dp.astype(np.float32) - w = ShowDiffraction(dp, center=cen, bf_radius=6, k_pixel_size=1 / (2.099 * G), verbose=False) - w.detect_spots(max_spots=6) - assert 4 <= len(w.spots) <= 6 # found the spots, beam excluded - on_spot = sum(any(abs(s["row"] - r) < 2 and abs(s["col"] - c) < 2 for r, c in truth) for s in w.spots) - assert on_spot >= 4 - - -def test_showdiffraction_detect_rings(): - M, cen = 256, (128, 128) - rows = np.arange(M)[:, None] - cols = np.arange(M)[None, :] - r = np.hypot(rows - cen[0], cols - cen[1]) - dp = 200.0 * np.exp(-(r**2) / (2 * 5.0**2)) - ring_radii = [40.0, 70.0, 100.0] - for rr in ring_radii: - dp = dp + 30.0 * np.exp(-((r - rr) ** 2) / (2 * 2.5**2)) - dp = dp.astype(np.float32) - w = ShowDiffraction(dp, center=cen, bf_radius=15, k_pixel_size=0.02, verbose=False) - w.detect_rings(max_rings=6) - found = sorted(rng["radius_px"] for rng in w.rings) - assert len(found) >= 3 - for target in ring_radii: - assert any(abs(f - target) < 3 for f in found)